diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000000..a6b2dd3c1a --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "nswag.consolecore": { + "version": "14.6.3", + "commands": [ + "nswag" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/.github/workflows/blazor.yml b/.github/workflows/blazor.yml deleted file mode 100644 index 1939d2b8db..0000000000 --- a/.github/workflows/blazor.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Build / Publish Blazor WebAssembly Project - -on: - workflow_dispatch: - - push: - branches: - - main - paths: - - "src/apps/blazor/**" - - "src/Directory.Packages.props" - - "src/Dockerfile.Blazor" - - pull_request: - branches: - - main - paths: - - "src/apps/blazor/**" - - "src/Directory.Packages.props" - - "src/Dockerfile.Blazor" - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: setup dotnet - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 9.x - - name: restore dependencies - run: dotnet restore ./src/apps/blazor/client/Client.csproj - - name: build - run: dotnet build ./src/apps/blazor/client/Client.csproj --no-restore - - name: test - run: dotnet test ./src/apps/blazor/client/Client.csproj --no-build --verbosity normal - - publish: - needs: build - if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - steps: - - name: checkout - uses: actions/checkout@v4 - - name: docker login - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: build and publish to github container registry - working-directory: ./src/ - run: | - docker build -t ghcr.io/${{ github.repository_owner }}/blazor:latest -f Dockerfile.Blazor . - docker push ghcr.io/${{ github.repository_owner }}/blazor:latest diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml deleted file mode 100644 index 7a88fcb9b4..0000000000 --- a/.github/workflows/changelog.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Release Drafter - -on: - workflow_dispatch: - push: - branches: - - main - -permissions: - contents: read - -jobs: - update_release_draft: - permissions: - # write permission is required to create a github release - contents: write - # write permission is required for autolabeler - # otherwise, read permission is required at least - pull-requests: write - runs-on: ubuntu-latest - steps: - - uses: release-drafter/release-drafter@v6 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..1dab843232 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,262 @@ +name: CI/CD Pipeline + +on: + push: + branches: + - main + - develop + tags: + - 'v*' + pull_request: + branches: + - main + - develop + workflow_dispatch: + inputs: + version: + description: 'Package version (e.g., 10.0.0-rc.1)' + required: false + type: string + +permissions: + contents: read + packages: write + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + +jobs: + build: + name: Build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Restore dependencies + run: dotnet restore src/FSH.Framework.slnx + + - name: Build + run: dotnet build src/FSH.Framework.slnx -c Release --no-restore + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: build-output + path: | + src/**/bin/Release + src/**/obj/Release + retention-days: 1 + + test: + name: Test - ${{ matrix.test-project.name }} + runs-on: ubuntu-latest + needs: build + + strategy: + fail-fast: false + matrix: + test-project: + - name: Architecture.Tests + path: src/Tests/Architecture.Tests + - name: Auditing.Tests + path: src/Tests/Auditing.Tests + - name: Generic.Tests + path: src/Tests/Generic.Tests + - name: Identity.Tests + path: src/Tests/Identity.Tests + - name: Multitenancy.Tests + path: src/Tests/Multitenacy.Tests + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: build-output + path: src + + - name: Run ${{ matrix.test-project.name }} + run: dotnet test ${{ matrix.test-project.path }} -c Release --no-build --verbosity normal + + publish-dev-containers: + name: Publish Dev Containers + runs-on: ubuntu-latest + needs: test + if: github.ref == 'refs/heads/develop' && github.event_name == 'push' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish API container image + run: | + dotnet publish src/Playground/Playground.Api/Playground.Api.csproj \ + -c Release -r linux-x64 \ + -p:PublishProfile=DefaultContainer \ + -p:ContainerRepository=ghcr.io/${{ github.repository_owner }}/fsh-playground-api \ + -p:ContainerImageTags='"dev-${{ github.sha }};dev-latest"' + + - name: Publish Blazor container image + run: | + dotnet publish src/Playground/Playground.Blazor/Playground.Blazor.csproj \ + -c Release -r linux-x64 \ + -p:PublishProfile=DefaultContainer \ + -p:ContainerRepository=ghcr.io/${{ github.repository_owner }}/fsh-playground-blazor \ + -p:ContainerImageTags='"dev-${{ github.sha }};dev-latest"' + + - name: Push containers to GHCR + run: | + docker push ghcr.io/${{ github.repository_owner }}/fsh-playground-api:dev-${{ github.sha }} + docker push ghcr.io/${{ github.repository_owner }}/fsh-playground-api:dev-latest + docker push ghcr.io/${{ github.repository_owner }}/fsh-playground-blazor:dev-${{ github.sha }} + docker push ghcr.io/${{ github.repository_owner }}/fsh-playground-blazor:dev-latest + + publish-release: + name: Publish Release (NuGet + Containers) + runs-on: ubuntu-latest + needs: test + if: | + (github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch') || + startsWith(github.ref, 'refs/tags/v') + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Determine version + id: version + run: | + if [ "${{ github.event_name }}" == "workflow_dispatch" ] && [ -n "${{ github.event.inputs.version }}" ]; then + VERSION="${{ github.event.inputs.version }}" + elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF#refs/tags/v}" + else + echo "No version specified and not a tag push" + exit 1 + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Publishing version: $VERSION" + + - name: Restore and Build with version + run: | + dotnet restore src/FSH.Framework.slnx + dotnet build src/FSH.Framework.slnx -c Release --no-restore -p:Version=${{ steps.version.outputs.version }} + + - name: Pack BuildingBlocks + run: | + dotnet pack src/BuildingBlocks/Core/Core.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/BuildingBlocks/Shared/Shared.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/BuildingBlocks/Persistence/Persistence.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/BuildingBlocks/Caching/Caching.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/BuildingBlocks/Mailing/Mailing.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/BuildingBlocks/Jobs/Jobs.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/BuildingBlocks/Storage/Storage.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/BuildingBlocks/Eventing/Eventing.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/BuildingBlocks/Web/Web.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/BuildingBlocks/Blazor.UI/Blazor.UI.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + + - name: Pack Modules + run: | + dotnet pack src/Modules/Auditing/Modules.Auditing.Contracts/Modules.Auditing.Contracts.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/Modules/Auditing/Modules.Auditing/Modules.Auditing.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/Modules/Identity/Modules.Identity.Contracts/Modules.Identity.Contracts.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/Modules/Identity/Modules.Identity/Modules.Identity.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Modules.Multitenancy.Contracts.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/Modules/Multitenancy/Modules.Multitenancy/Modules.Multitenancy.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + + - name: Pack CLI Tool + run: dotnet pack src/Tools/CLI/FSH.CLI.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + + - name: Push to NuGet.org + run: dotnet nuget push "./nupkgs/*.nupkg" --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push API container + run: | + dotnet publish src/Playground/Playground.Api/Playground.Api.csproj \ + -c Release -r linux-x64 \ + -p:PublishProfile=DefaultContainer \ + -p:ContainerRepository=ghcr.io/${{ github.repository_owner }}/fsh-playground-api \ + -p:ContainerImageTags='"${{ steps.version.outputs.version }};latest"' + docker push ghcr.io/${{ github.repository_owner }}/fsh-playground-api:${{ steps.version.outputs.version }} + docker push ghcr.io/${{ github.repository_owner }}/fsh-playground-api:latest + + - name: Build and push Blazor container + run: | + dotnet publish src/Playground/Playground.Blazor/Playground.Blazor.csproj \ + -c Release -r linux-x64 \ + -p:PublishProfile=DefaultContainer \ + -p:ContainerRepository=ghcr.io/${{ github.repository_owner }}/fsh-playground-blazor \ + -p:ContainerImageTags='"${{ steps.version.outputs.version }};latest"' + docker push ghcr.io/${{ github.repository_owner }}/fsh-playground-blazor:${{ steps.version.outputs.version }} + docker push ghcr.io/${{ github.repository_owner }}/fsh-playground-blazor:latest diff --git a/.github/workflows/nuget.yml b/.github/workflows/nuget.yml deleted file mode 100644 index 4ee62a6ff5..0000000000 --- a/.github/workflows/nuget.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Publish Package to NuGet.org -on: - push: - branches: - - main - paths: - - "FSH.StarterKit.nuspec" -jobs: - publish: - name: publish nuget - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - name: checkout code - - uses: nuget/setup-nuget@v2 - name: setup nuget - with: - nuget-version: "latest" - nuget-api-key: ${{ secrets.NUGET_API_KEY }} - - name: generate package - run: nuget pack FSH.StarterKit.nuspec -NoDefaultExcludes - - name: publish package - run: nuget push *.nupkg -Source 'https://api.nuget.org/v3/index.json' -SkipDuplicate diff --git a/.github/workflows/webapi.yml b/.github/workflows/webapi.yml deleted file mode 100644 index a84e28f03a..0000000000 --- a/.github/workflows/webapi.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Build / Publish .NET WebAPI Project - -on: - workflow_dispatch: - - push: - branches: - - main - paths: - - "src/api/**" - - "src/Directory.Packages.props" - - pull_request: - branches: - - main - paths: - - "src/api/**" - - "src/Directory.Packages.props" - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: setup dotnet - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 9.x - - name: restore dependencies - run: dotnet restore ./src/api/server/Server.csproj - - name: build - run: dotnet build ./src/api/server/Server.csproj --no-restore - - name: test - run: dotnet test ./src/api/server/Server.csproj --no-build --verbosity normal - - publish: - needs: build - if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - steps: - - name: checkout - uses: actions/checkout@v4 - - name: setup dotnet - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 9.x - - name: docker login - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: publish to github container registry - working-directory: ./src/api/server/ - run: | - dotnet publish -c Release -p:ContainerRepository=ghcr.io/${{ github.repository_owner}}/webapi -p:RuntimeIdentifier=linux-x64 - docker push ghcr.io/${{ github.repository_owner}}/webapi --all-tags diff --git a/.gitignore b/.gitignore index 9995d856ac..b6a5e7344e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,13 @@ ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. ## -## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore +## Get latest from `dotnet new gitignore` + +*.terraform +*.terraform.lock.hcl +terraform.tfstate +# dotenv files +.env # User-specific files *.rsuser @@ -31,16 +37,12 @@ bld/ [Oo]bj/ [Ll]og/ [Ll]ogs/ -[Ii]mages/ -[Dd]atabases/ # Visual Studio 2015/2017 cache/options directory .vs/ # Uncomment if you have tasks that create the project's static files in wwwroot #wwwroot/ -.vscode/ - # Visual Studio 2017 auto generated files Generated\ Files/ @@ -61,7 +63,7 @@ dlldata.c # Benchmark Results BenchmarkDotNet.Artifacts/ -# .NET Core +# .NET project.lock.json project.fragment.lock.json artifacts/ @@ -97,6 +99,7 @@ StyleCopReport.xml *.tmp_proj *_wpftmp.csproj *.log +*.tlog *.vspscc *.vssscc .builds @@ -300,6 +303,17 @@ node_modules/ # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) *.vbw +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + # Visual Studio LightSwitch build output **/*.HTMLClient/GeneratedArtifacts **/*.DesktopClient/GeneratedArtifacts @@ -356,6 +370,9 @@ ASALocalRun/ # Local History for Visual Studio .localhistory/ +# Visual Studio History (VSHistory) files +.vshistory/ + # BeatPulse healthcheck temp database healthchecksdb @@ -368,6 +385,28 @@ MigrationBackup/ # Fody - auto-generated XML schema FodyWeavers.xsd +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + ## ## Visual studio for Mac ## @@ -390,7 +429,7 @@ test-results/ *.dmg *.app -# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore # General .DS_Store .AppleDouble @@ -419,7 +458,7 @@ Network Trash Folder Temporary Items .apdisk -# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore # Windows thumbnail cache files Thumbs.db ehthumbs.db @@ -444,17 +483,15 @@ $RECYCLE.BIN/ # Windows shortcuts *.lnk -# JetBrains Rider -.idea/ -*.sln.iml - -## -## Visual Studio Code -## -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json - -**/Internal/Generated +# Vim temporary swap files +*.swp +/.bmad + +team/ +docs/ +spec-os/ +/PLAN.md +**/nul +**/wwwroot/uploads/* +/agent_docs/blazor.md +/.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..b6854f2d8c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,174 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Run Commands + +```bash +# Restore and build +dotnet restore src/FSH.Framework.slnx +dotnet build src/FSH.Framework.slnx + +# Run with Aspire (spins up Postgres + Redis via Docker) +dotnet run --project src/Playground/FSH.Playground.AppHost + +# Run API standalone (requires DB/Redis/JWT config in appsettings) +dotnet run --project src/Playground/Playground.Api + +# Run all tests +dotnet test src/FSH.Framework.slnx + +# Run single test project +dotnet test src/Tests/Architecture.Tests + +# Run specific test +dotnet test src/Tests/Architecture.Tests --filter "FullyQualifiedName~TestMethodName" + +# Generate C# API client from OpenAPI spec (requires API running) +./scripts/openapi/generate-api-clients.ps1 -SpecUrl "https://localhost:7030/openapi/v1.json" + +# Check for OpenAPI drift (CI validation) +./scripts/openapi/check-openapi-drift.ps1 -SpecUrl "" +``` + +## Architecture + +FullStackHero .NET 10 Starter Kit - multi-tenant SaaS framework using vertical slice architecture. + +### Repository Structure + +- **src/BuildingBlocks/** - Reusable framework components (packaged as NuGets): Core (DDD primitives), Persistence (EF Core + specifications), Caching (Redis), Mailing, Jobs (Hangfire), Storage, Web (host wiring), Eventing +- **src/Modules/** - Feature modules (packaged as NuGets): Identity (JWT auth, users, roles), Multitenancy (Finbuckle), Auditing +- **src/Playground/** - Reference implementation using direct project references for development; includes Aspire AppHost, API, Blazor UI, PostgreSQL migrations +- **src/Tests/** - Architecture tests using NetArchTest.Rules, xUnit, Shouldly +- **scripts/openapi/** - NSwag-based C# client generation from OpenAPI spec; outputs to `Playground.Blazor/ApiClient/Generated.cs` +- **terraform/** - AWS infrastructure as code (modular) + - `modules/` - Reusable: network, ecs_cluster, ecs_service, rds_postgres, elasticache_redis, alb, s3_bucket + - `apps/playground/` - Playground deployment stack with `envs/{dev,staging,prod}/{region}/` + - `bootstrap/` - Initial AWS setup (S3 backend, etc.) + +### Module Pattern + +Each module implements `IModule` with: +- `ConfigureServices(IHostApplicationBuilder)` - DI registration +- `MapEndpoints(IEndpointRouteBuilder)` - Minimal API endpoint mapping + +Feature structure within modules: +``` +Features/v1/{Feature}/ +├── {Feature}Command.cs (or Query) +├── {Feature}Handler.cs +├── {Feature}Validator.cs (FluentValidation) +└── {Feature}Endpoint.cs (static extension method on IEndpointRouteBuilder) +``` + +Contracts projects (`Modules.{Name}.Contracts/`) contain public DTOs shareable with clients. + +### Endpoint Pattern + +Endpoints are static extension methods returning `RouteHandlerBuilder`: +```csharp +public static RouteHandlerBuilder MapXxxEndpoint(this IEndpointRouteBuilder endpoint) +{ + return endpoint.MapPost("/path", async (..., IMediator mediator, CancellationToken ct) => + { + var result = await mediator.Send(command, ct); + return TypedResults.Ok(result); + }); +} +``` + +### Platform Wiring + +In `Program.cs`: +1. Register Mediator with command/query assemblies +2. Call `builder.AddHeroPlatform(...)` - enables auth, OpenAPI, caching, mailing, jobs, health, OTel +3. Call `builder.AddModules(moduleAssemblies)` to load modules +4. Call `app.UseHeroMultiTenantDatabases()` for tenant DB migrations +5. Call `app.UseHeroPlatform(p => p.MapModules = true)` to wire endpoints + +## Configuration + +Key settings (appsettings or env vars): +- `DatabaseOptions:Provider` - postgres or mssql +- `DatabaseOptions:ConnectionString` - Primary database +- `CachingOptions:Redis` - Redis connection +- `JwtOptions:SigningKey` - Required in production + +## Code Standards + +- .NET 10, C# latest, nullable enabled +- SonarAnalyzer.CSharp with code style enforced in build +- API versioning in URL path (`/api/v1/...`) +- Mediator library (not MediatR) for commands/queries +- FluentValidation for request validation + +## Blazor UI Components + +The framework provides reusable Blazor components in `BuildingBlocks/Blazor.UI/Components/` with consistent styling. + +### FshPageHeader Component + +Use `FshPageHeader` for consistent page headers across Playground.Blazor: + +```razor +@using FSH.BuildingBlocks.Blazor.UI.Components.Page + + + + + Action + + +``` + +**Parameters:** +- `Title` (required): Main page title +- `Description` (optional): Description text below title +- `DescriptionContent` (optional): RenderFragment for complex descriptions +- `ActionContent` (optional): RenderFragment for action buttons on the right +- `TitleTypo` (optional): Typography style (default: Typo.h4) +- `Elevation` (optional): Paper elevation (default: 0) +- `Class` (optional): Additional CSS classes + +**Styling:** +- Uses `.hero-card` class from `fsh-theme.css` +- Gradient background with primary color accent border +- Shared utility classes: `.fw-600`, `.fw-700` for font weights + +### FshUserProfile Component + +Modern user profile dropdown for app bars/navbars with avatar, user info, and menu: + +```razor +@using FSH.Framework.Blazor.UI.Components.User + + +``` + +**Parameters:** +- `UserName` (required): User's display name +- `UserEmail` (optional): User's email address +- `UserRole` (optional): User's role or title +- `AvatarUrl` (optional): URL to user's avatar (shows initials if not provided) +- `ShowUserName` (optional): Show username next to avatar (default: true, hidden on mobile) +- `ShowUserInfo` (optional): Show user info in menu header (default: true) +- `MenuItems` (optional): Custom RenderFragment for menu items (uses default Profile/Settings/Logout if not provided) +- `OnProfileClick` (optional): Callback for Profile menu item +- `OnSettingsClick` (optional): Callback for Settings menu item +- `OnLogoutClick` (optional): Callback for Logout menu item + +**Features:** +- Responsive design (hides username on mobile) +- Avatar with initials fallback +- Smooth hover animations and transitions +- Gradient menu header with user info +- Customizable menu items via RenderFragment +- Scoped CSS for isolated styling diff --git a/FSH.StarterKit.nuspec b/FSH.StarterKit.nuspec deleted file mode 100644 index a96e4b69f1..0000000000 --- a/FSH.StarterKit.nuspec +++ /dev/null @@ -1,24 +0,0 @@ - - - - FullStackHero.NET.StarterKit - FullStackHero .NET Starter Kit - 2.0.4-rc - Mukesh Murugan - The best way to start a full-stack Multi-tenant .NET 9 Web App. - en-US - ./content/LICENSE - 2024 - ./content/README.md - https://fullstackhero.net/dotnet-starter-kit/general/getting-started/ - - - - - cleanarchitecture clean architecture WebAPI mukesh codewithmukesh fullstackhero solution csharp - ./content/icon.png - - - - - \ No newline at end of file diff --git a/LICENSE b/LICENSE deleted file mode 100644 index fc25cd4f55..0000000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2024 fullstackhero - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README-template.md b/README-template.md deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/README.md b/README.md index 7682ba1331..1c0be1877b 100644 --- a/README.md +++ b/README.md @@ -1,95 +1,58 @@ -# FullStackHero .NET 9 Starter Kit 🚀 - -> With ASP.NET Core Web API & Blazor Client - -FullStackHero .NET Starter Kit is a starting point for your next `.NET 9 Clean Architecture` Solution that incorporates the most essential packages and features your projects will ever need including out-of-the-box Multi-Tenancy support. This project can save well over 200+ hours of development time for your team. - -![FullStackHero .NET Starter Kit](./assets/fullstackhero-dotnet-starter-kit.png) - -# Important - -This project is currently work in progress. The NuGet package is not yet available for v2. For now, you can fork this repository to try it out. [Follow @iammukeshm on X](https://x.com/iammukeshm) for project related updates. - -# Quick Start Guide - -As the project is still in beta, the NuGet packages are not yet available. You can try out the project by pulling the code directly from this repository. - -Prerequisites: - -- .NET 9 SDK installed. -- Visual Studio IDE. -- Docker Desktop. -- PostgreSQL instance running on your machine or docker container. - -Please follow the below instructions. - -1. Fork this repository to your local. -2. Open up the `./src/FSH.Starter.sln`. -3. This would up the FSH Starter solution which has 3 main components. - 1. Aspire Dashboard (set as the default project) - 2. Web API - 3. Blazor -4. Now we will have to set the connection string for the API. Navigate to `./src/api/server/appsettings.Development.json` and change the `ConnectionString` under `DatabaseOptions`. Save it. -5. Once that is done, run the application via Visual Studio, with Aspire as the default project. This will open up Aspire Dashboard at `https://localhost:7200/`. -6. API will be running at `https://localhost:7000/swagger/index.html`. -7. Blazor will be running at `https://localhost:7100/`. - -# 🔎 The Project - -# ✨ Technologies - -- .NET 9 -- Entity Framework Core 9 -- Blazor -- MediatR -- PostgreSQL -- Redis -- FluentValidation - -# 👨‍🚀 Architecture - -# 📬 Service Endpoints - -| Endpoint | Method | Description | -| -------- | ------ | ---------------- | -| `/token` | POST | Generates Token. | - -# 🧪 Running Locally - -# 🐳 Docker Support - -# ☁️ Deploying to AWS - -# 🤝 Contributing - -# 🍕 Community - -Thanks to the community who contribute to this repository! [Submit your PR and join the elite list!](CONTRIBUTING.md) - -[![FullStackHero .NET Starter Kit Contributors](https://contrib.rocks/image?repo=fullstackhero/dotnet-starter-kit "FullStackHero .NET Starter Kit Contributors")](https://github.com/fullstackhero/dotnet-starter-kit/graphs/contributors) - -# 📝 Notes - -## Add Migrations - -Navigate to `./api/server` and run the following EF CLI commands. - -```bash -dotnet ef migrations add "Add Identity Schema" --project .././migrations/postgresql/ --context IdentityDbContext -o Identity -dotnet ef migrations add "Add Tenant Schema" --project .././migrations/postgresql/ --context TenantDbContext -o Tenant -dotnet ef migrations add "Add Todo Schema" --project .././migrations/postgresql/ --context TodoDbContext -o Todo -dotnet ef migrations add "Add Catalog Schema" --project .././migrations/postgresql/ --context CatalogDbContext -o Catalog -``` - -## What's Pending? - -- Few Identity Endpoints -- Blazor Client -- File Storage Service -- NuGet Generation Pipeline -- Source Code Generation -- Searching / Sorting - -# ⚖️ LICENSE - -MIT © [fullstackhero](LICENSE) +# FullStackHero .NET 10 Starter Kit + +An opinionated, production-first starter for building multi-tenant SaaS and enterprise APIs on .NET 10. You get ready-to-ship Identity, Multitenancy, Auditing, caching, mailing, jobs, storage, health, OpenAPI, and OpenTelemetry—wired through Minimal APIs, Mediator, and EF Core. + +## Why teams pick this +- Modular vertical slices: drop `Modules.Identity`, `Modules.Multitenancy`, `Modules.Auditing` into any API and let the module loader wire endpoints. +- Battle-tested building blocks: persistence + specifications, distributed caching, mailing, jobs via Hangfire, storage abstractions, and web host primitives (auth, rate limiting, versioning, CORS, exception handling). +- Cloud-ready out of the box: Aspire AppHost spins up Postgres + Redis + the Playground API/Blazor with OTLP tracing enabled. +- Multi-tenant from day one: Finbuckle-powered tenancy across Identity and your module DbContexts; helpers to migrate and seed tenant databases on startup. +- Observability baked in: OpenTelemetry traces/metrics/logs, structured logging, health checks, and security/exception auditing. + +## Stack highlights +- .NET 10, C# latest, Minimal APIs, Mediator for commands/queries, FluentValidation. +- EF Core 10 with domain events + specifications; Postgres by default, SQL Server ready. +- ASP.NET Identity with JWT issuance/refresh, roles/permissions, rate-limited auth endpoints. +- Hangfire for background jobs; Redis-backed distributed cache; pluggable storage. +- API versioning, rate limiting, CORS, security headers, OpenAPI (Swagger) + Scalar docs. + +## Repository map +- `src/BuildingBlocks` — Core abstractions (DDD primitives, exceptions), Persistence, Caching, Mailing, Jobs, Storage, Web host wiring. +- `src/Modules` — `Identity`, `Multitenancy`, `Auditing` runtime + contracts projects. +- `src/Playground` — Reference host (`Playground.Api`), Aspire app host (`FSH.Playground.AppHost`), Blazor UI, Postgres migrations. +- `src/Tests` — Architecture tests that enforce layering and module boundaries. +- `docs/framework` — Deep dives on architecture, modules, and developer recipes. +- `terraform` — Infra as code scaffolding (optional starting point). + +## Run it now (Aspire) +Prereqs: .NET 10 SDK, Aspire workload, Docker running (for Postgres/Redis). + +1. Restore: `dotnet restore src/FSH.Framework.slnx` +2. Start everything: `dotnet run --project src/Playground/FSH.Playground.AppHost` + - Aspire brings up Postgres + Redis containers, wires env vars, launches the Playground API and Blazor front end, and enables OTLP export on https://localhost:4317. +3. Hit the API: `https://localhost:5285` (Swagger/Scalar and module endpoints under `/api/v1/...`). + +### Run the API only +- Set env vars or appsettings for `DatabaseOptions__Provider`, `DatabaseOptions__ConnectionString`, `DatabaseOptions__MigrationsAssembly`, `CachingOptions__Redis`, and JWT options. +- Run: `dotnet run --project src/Playground/Playground.Api` +- The host applies migrations/seeding via `UseHeroMultiTenantDatabases()` and maps module endpoints via `UseHeroPlatform`. + +## Bring the framework into your API +- Reference the building block and module projects you need. +- In `Program.cs`: + - Register Mediator with assemblies containing your commands/queries and module handlers. + - Call `builder.AddHeroPlatform(...)` to enable auth, OpenAPI, caching, mailing, jobs, health, OTel, rate limiting. + - Call `builder.AddModules(moduleAssemblies)` and `app.UseHeroPlatform(p => p.MapModules = true);`. +- Configure connection strings, Redis, JWT, CORS, and OTel endpoints via configuration. Example wiring lives in `src/Playground/Playground.Api/Program.cs`. + +## Included modules +- **Identity** — ASP.NET Identity + JWT issuance/refresh, user/role/permission management, profile image storage, login/refresh auditing, health checks. +- **Multitenancy** — Tenant provisioning, migrations, status/upgrade APIs, tenant-aware EF Core contexts, health checks. +- **Auditing** — Security/exception/activity auditing with queryable endpoints; plugs into global exception handling and Identity events. + +## Development notes +- Target framework: `net10.0`; nullable enabled; analyzers on. +- Tests: `dotnet test src/FSH.Framework.slnx` (includes architecture guardrails). +- Want the deeper story? Start with `docs/framework/architecture.md` and the developer cookbook in `docs/framework/developer-cookbook.md`. + +Built and maintained by Mukesh Murugan for teams that want to ship faster without sacrificing architecture discipline. diff --git a/assets/fullstackhero-dotnet-starter-kit.png b/assets/fullstackhero-dotnet-starter-kit.png deleted file mode 100644 index d5ac1f26ff..0000000000 Binary files a/assets/fullstackhero-dotnet-starter-kit.png and /dev/null differ diff --git a/compose/.env b/compose/.env deleted file mode 100644 index 1226475d10..0000000000 --- a/compose/.env +++ /dev/null @@ -1,11 +0,0 @@ -#BASE_PATH=/mnt/c/docker-services/fsh-dotnet-starter-kit -BASE_PATH=. -############################################################################################################################################################################ -# API Services -############################################################################################################################################################################ -FSH_DOTNETSTARTERKIT_WEBAPI_IMAGE=ghcr.io/fullstackhero/webapi:latest - -############################################################################################################################################################################ -# Websites -############################################################################################################################################################################ -FSH_DOTNETSTARTERKIT_BLAZOR_IMAGE=ghcr.io/fullstackhero/blazor:latest diff --git a/compose/docker-compose.yml b/compose/docker-compose.yml deleted file mode 100644 index 8d75bcb074..0000000000 --- a/compose/docker-compose.yml +++ /dev/null @@ -1,195 +0,0 @@ -version: "4" #on wsl linux replace 3.8 -name: fullstackhero #on wsl linux replace with export COMPOSE_PROJECT_NAME=fullstackhero before docker-compose up command - -services: - webapi: - image: ${FSH_DOTNETSTARTERKIT_WEBAPI_IMAGE} - pull_policy: always - container_name: webapi - networks: - - fullstackhero - environment: - ASPNETCORE_ENVIRONMENT: docker - ASPNETCORE_URLS: https://+:7000;http://+:5000 - ASPNETCORE_HTTPS_PORT: 7000 - ASPNETCORE_Kestrel__Certificates__Default__Password: password! - ASPNETCORE_Kestrel__Certificates__Default__Path: /https/cert.pfx - DatabaseOptions__ConnectionString: Server=postgres;Port=5433;Database=fullstackhero;User Id=pgadmin;Password=pgadmin - DatabaseOptions__Provider: postgresql - JwtOptions__Key: QsJbczCNysv/5SGh+U7sxedX8C07TPQPBdsnSDKZ/aE= - HangfireOptions__Username: admin - HangfireOptions__Password: Secure1234!Me - MailOptions__From: mukesh@fullstackhero.net - MailOptions__Host: smtp.ethereal.email - MailOptions__Port: 587 - MailOptions__UserName: sherman.oconnell47@ethereal.email - MailOptions__Password: KbuTCFv4J6Fy7256vh - MailOptions__DisplayName: Mukesh Murugan - CorsOptions__AllowedOrigins__0: http://localhost:5010 - CorsOptions__AllowedOrigins__1: http://localhost:7100 - CorsOptions__AllowedOrigins__2: https://localhost:7020 - OpenTelemetryOptions__Endpoint: http://otel-collector:4317 - RateLimitOptions__EnableRateLimiting: "false" - OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317 - OTEL_SERVICE_NAME: FSH.Starter.WebApi.Host - volumes: - - ~/.aspnet/https:/https:ro #on wsl linux - #- /mnt/c/Users/eduar/.aspnet/https:/https:ro - ports: - - 7000:7000 - - 5000:5000 - depends_on: - postgres: - condition: service_healthy - restart: on-failure - - blazor: - image: ${FSH_DOTNETSTARTERKIT_BLAZOR_IMAGE} - pull_policy: always - container_name: blazor - environment: - Frontend_FSHStarterBlazorClient_Settings__AppSettingsTemplate: /usr/share/nginx/html/appsettings.json.TEMPLATE - Frontend_FSHStarterBlazorClient_Settings__AppSettingsJson: /usr/share/nginx/html/appsettings.json - FSHStarterBlazorClient_ApiBaseUrl: https://localhost:7000 - ApiBaseUrl: https://localhost:7000 - networks: - - fullstackhero - entrypoint: [ - "/bin/sh", - "-c", - "envsubst < - $${Frontend_FSHStarterBlazorClient_Settings__AppSettingsTemplate} > - $${Frontend_FSHStarterBlazorClient_Settings__AppSettingsJson} && find - /usr/share/nginx/html -type f | xargs chmod +r && exec nginx -g - 'daemon off;'", - ] - volumes: - - ~/.aspnet/https:/https:ro - ports: - - 7100:80 - depends_on: - postgres: - condition: service_healthy - restart: on-failure - - postgres: - container_name: postgres - image: postgres:15-alpine - networks: - - fullstackhero - environment: - POSTGRES_USER: pgadmin - POSTGRES_PASSWORD: pgadmin - PGPORT: 5433 - ports: - - 5433:5433 - volumes: - - postgres-data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U pgadmin"] - interval: 10s - timeout: 5s - retries: 5 - - prometheus: - image: prom/prometheus:latest - container_name: prometheus - restart: unless-stopped - networks: - - fullstackhero - volumes: - - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml - - prometheus-data:/prometheus - ports: - - 9090:9090 - - grafana: - container_name: grafana - image: grafana/grafana:latest - user: "472" - environment: - GF_INSTALL_PLUGINS: "grafana-clock-panel,grafana-simple-json-datasource" - ports: - - 3000:3000 - volumes: - - grafana-data:/var/lib/grafana - - ./grafana/config/:/etc/grafana/ - - ./grafana/dashboards/:/var/lib/grafana/dashboards - depends_on: - - prometheus - restart: unless-stopped - networks: - - fullstackhero - - otel-collector: - image: otel/opentelemetry-collector-contrib:latest - container_name: otel-collector - command: --config /etc/otel/config.yaml - environment: - JAEGER_ENDPOINT: "jaeger:4317" - LOKI_ENDPOINT: "http://loki:3100/loki/api/v1/push" - volumes: - - $BASE_PATH/otel-collector/otel-config.yaml:/etc/otel/config.yaml - - $BASE_PATH/otel-collector/log:/log/otel - depends_on: - - jaeger - - loki - - prometheus - ports: - - 8888:8888 # Prometheus metrics exposed by the collector - - 8889:8889 # Prometheus metrics exporter (scrape endpoint) - - 13133:13133 # health_check extension - - "55679:55679" # ZPages extension - - 4317:4317 # OTLP gRPC receiver - - 4318:4318 # OTLP Http receiver (Protobuf) - networks: - - fullstackhero - - jaeger: - container_name: jaeger - image: jaegertracing/all-in-one:latest - command: --query.ui-config /etc/jaeger/jaeger-ui.json - environment: - - METRICS_STORAGE_TYPE=prometheus - - PROMETHEUS_SERVER_URL=http://prometheus:9090 - - COLLECTOR_OTLP_ENABLED=true - volumes: - - $BASE_PATH/jaeger/jaeger-ui.json:/etc/jaeger/jaeger-ui.json - depends_on: - - prometheus - ports: - - "16686:16686" - networks: - - fullstackhero - - loki: - container_name: loki - image: grafana/loki:3.1.0 - command: -config.file=/mnt/config/loki-config.yml - volumes: - - $BASE_PATH/loki/loki.yml:/mnt/config/loki-config.yml - ports: - - "3100:3100" - networks: - - fullstackhero - - node_exporter: - image: quay.io/prometheus/node-exporter:v1.5.0 - container_name: node_exporter - command: "--path.rootfs=/host" - pid: host - restart: unless-stopped - volumes: - - /proc:/host/proc:ro - - /sys:/host/sys:ro - - /:/rootfs:ro - networks: - - fullstackhero - -volumes: - postgres-data: - grafana-data: - prometheus-data: - -networks: - fullstackhero: diff --git a/compose/grafana/config/grafana.ini b/compose/grafana/config/grafana.ini deleted file mode 100644 index 4277397334..0000000000 --- a/compose/grafana/config/grafana.ini +++ /dev/null @@ -1,16 +0,0 @@ -[auth.anonymous] -enabled = true - -# Organization name that should be used for unauthenticated users -org_name = Main Org. - -# Role for unauthenticated users, other valid values are `Editor` and `Admin` -org_role = Admin - -# Hide the Grafana version text from the footer and help tooltip for unauthenticated users (default: false) -hide_version = true - -[dashboards] -default_home_dashboard_path = /var/lib/grafana/dashboards/aspnet-core.json - -min_refresh_interval = 1s \ No newline at end of file diff --git a/compose/grafana/config/provisioning/dashboards/default.yml b/compose/grafana/config/provisioning/dashboards/default.yml deleted file mode 100644 index d2f0a7ca80..0000000000 --- a/compose/grafana/config/provisioning/dashboards/default.yml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: 1 - -providers: -- name: 'Prometheus' - orgId: 1 - folder: '' - type: file - disableDeletion: false - editable: true - options: - path: /var/lib/grafana/dashboards \ No newline at end of file diff --git a/compose/grafana/config/provisioning/datasources/default.yml b/compose/grafana/config/provisioning/datasources/default.yml deleted file mode 100644 index 428d40e2ed..0000000000 --- a/compose/grafana/config/provisioning/datasources/default.yml +++ /dev/null @@ -1,69 +0,0 @@ -# config file version -apiVersion: 1 - -# list of datasources that should be deleted from the database -deleteDatasources: - - name: Prometheus - orgId: 1 - -# list of datasources to insert/update depending -# whats available in the database -datasources: -- name: Prometheus - type: prometheus - access: proxy - # Access mode - proxy (server in the UI) or direct (browser in the UI). - url: http://host.docker.internal:9090 - uid: prom - -- name: Loki - uid: loki - type: loki - access: proxy - url: http://loki:3100 - # allow users to edit datasources from the UI. - editable: true - jsonData: - derivedFields: - - datasourceUid: jaeger - matcherRegex: (?:"traceid"):"(\w+)" - name: TraceID - url: $${__value.raw} - -- name: Jaeger - type: jaeger - uid: jaeger - access: proxy - url: http://jaeger:16686 - readOnly: false - isDefault: false - # allow users to edit datasources from the UI. - editable: true - jsonData: - tracesToLogsV2: - # Field with an internal link pointing to a logs data source in Grafana. - # datasourceUid value must match the uid value of the logs data source. - datasourceUid: 'loki' - spanStartTimeShift: '1h' - spanEndTimeShift: '-1h' - tags: [{ key: 'service.names', value: 'service_name' }] - filterByTraceID: false - filterBySpanID: false - customQuery: true - query: '{$${__tags}} |="$${__trace.traceId}"' - tracesToMetrics: - datasourceUid: 'prom' - spanStartTimeShift: '1h' - spanEndTimeShift: '-1h' - tags: [{ key: 'service.name', value: 'service' }, { key: 'job' }] - queries: - - name: 'Sample query' - query: 'sum(rate(traces_spanmetrics_latency_bucket{$$__tags}[5m]))' - nodeGraph: - enabled: true - traceQuery: - timeShiftEnabled: true - spanStartTimeShift: '1h' - spanEndTimeShift: '-1h' - spanBar: - type: 'None' \ No newline at end of file diff --git a/compose/grafana/dashboards/aspnet-core-endpoint.json b/compose/grafana/dashboards/aspnet-core-endpoint.json deleted file mode 100644 index 05b5496712..0000000000 --- a/compose/grafana/dashboards/aspnet-core-endpoint.json +++ /dev/null @@ -1,933 +0,0 @@ -{ - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "target": { - "limit": 100, - "matchAny": false, - "tags": [], - "type": "dashboard" - }, - "type": "dashboard" - } - ] - }, - "description": "ASP.NET Core endpoint metrics from OpenTelemetry", - "editable": true, - "fiscalYearStartMonth": 0, - "gnetId": 19925, - "graphTooltip": 0, - "id": 10, - "links": [ - { - "asDropdown": false, - "icon": "dashboard", - "includeVars": false, - "keepTime": true, - "tags": [], - "targetBlank": false, - "title": " ASP.NET Core", - "tooltip": "", - "type": "link", - "url": "/d/KdDACDp4z/asp-net-core-metrics" - } - ], - "liveNow": false, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "dark-green", - "mode": "continuous-GrYlRd", - "seriesBy": "max" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "axisSoftMin": 0, - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 50, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "smooth", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [ - { - "options": { - "match": "null+nan", - "result": { - "index": 0, - "text": "0 ms" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "s" - }, - "overrides": [ - { - "__systemRef": "hideSeriesFrom", - "matcher": { - "id": "byNames", - "options": { - "mode": "exclude", - "names": [ - "p50" - ], - "prefix": "All except:", - "readOnly": true - } - }, - "properties": [ - { - "id": "custom.hideFrom", - "value": { - "legend": false, - "tooltip": false, - "viz": false - } - } - ] - } - ] - }, - "gridPos": { - "h": 9, - "w": 12, - "x": 0, - "y": 0 - }, - "id": 40, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "min", - "max" - ], - "displayMode": "table", - "placement": "right", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.50, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m])) by (le))", - "legendFormat": "p50", - "range": true, - "refId": "p50" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.75, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m])) by (le))", - "hide": false, - "legendFormat": "p75", - "range": true, - "refId": "p75" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.90, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m])) by (le))", - "hide": false, - "legendFormat": "p90", - "range": true, - "refId": "p90" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.95, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m])) by (le))", - "hide": false, - "legendFormat": "p95", - "range": true, - "refId": "p95" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.98, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m])) by (le))", - "hide": false, - "legendFormat": "p98", - "range": true, - "refId": "p98" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.99, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m])) by (le))", - "hide": false, - "legendFormat": "p99", - "range": true, - "refId": "p99" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.999, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m])) by (le))", - "hide": false, - "legendFormat": "p99.9", - "range": true, - "refId": "p99.9" - } - ], - "title": "Requests Duration - $method $route", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic", - "seriesBy": "max" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 50, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "smooth", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [ - { - "options": { - "match": "null+nan", - "result": { - "index": 0, - "text": "0%" - } - }, - "type": "special" - } - ], - "max": 1, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "percentunit" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "All" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-orange", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "4XX" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "yellow", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "5XX" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-red", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 9, - "w": 12, - "x": 12, - "y": 0 - }, - "id": 46, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "min", - "max" - ], - "displayMode": "table", - "placement": "right", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(rate(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\", http_response_status_code=~\"4..|5..\"}[5m]) or vector(0)) / sum(rate(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m]))", - "legendFormat": "All", - "range": true, - "refId": "All" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(rate(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\", http_response_status_code=~\"4..\"}[5m]) or vector(0)) / sum(rate(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m]))", - "hide": false, - "legendFormat": "4XX", - "range": true, - "refId": "4XX" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(rate(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\", http_response_status_code=~\"5..\"}[5m]) or vector(0)) / sum(rate(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m]))", - "hide": false, - "legendFormat": "5XX", - "range": true, - "refId": "5XX" - } - ], - "title": "Errors Rate - $method $route", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "inspect": false - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Requests" - }, - "properties": [ - { - "id": "custom.width", - "value": 300 - }, - { - "id": "custom.cellOptions", - "value": { - "mode": "gradient", - "type": "gauge" - } - }, - { - "id": "color", - "value": { - "mode": "continuous-YlRd" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Route" - }, - "properties": [ - { - "id": "links", - "value": [ - { - "title": "", - "url": "/d/NagEsjE4z/asp-net-core-endpoint-details?var-route=${__data.fields.Route}&var-method=${__data.fields.Method}&${__url_time_range}" - } - ] - } - ] - } - ] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 9 - }, - "hideTimeOverride": false, - "id": 44, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true, - "sortBy": [ - { - "desc": true, - "displayName": "Value" - } - ] - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "exemplar": false, - "expr": "sum by (error_type) (\r\n max_over_time(http_server_request_duration_seconds_count{http_route=\"$route\", http_request_method=\"$method\", error_type!=\"\"}[$__rate_interval])\r\n)", - "format": "table", - "instant": true, - "interval": "", - "legendFormat": "{{route}}", - "range": false, - "refId": "A" - } - ], - "title": "Unhandled Exceptions", - "transformations": [ - { - "id": "organize", - "options": { - "excludeByName": { - "Time": true, - "method": false - }, - "indexByName": { - "Time": 0, - "Value": 2, - "error_type": 1 - }, - "renameByName": { - "Value": "Requests", - "error_type": "Exception", - "http_request_method": "Method", - "http_route": "Route" - } - } - } - ], - "type": "table" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "blue", - "mode": "fixed" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 12, - "x": 12, - "y": 9 - }, - "id": 42, - "options": { - "colorMode": "background", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "max" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum by (http_response_status_code) (\r\n max_over_time(http_server_request_duration_seconds_count{http_route=\"$route\", http_request_method=\"$method\"}[$__rate_interval])\r\n )", - "legendFormat": "Status {{http_response_status_code}}", - "range": true, - "refId": "A" - } - ], - "title": "Requests HTTP Status Code", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "green", - "mode": "fixed" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 6, - "x": 12, - "y": 13 - }, - "id": 48, - "options": { - "colorMode": "background", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "max" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum by (url_scheme) (\r\n max_over_time(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[$__rate_interval])\r\n )", - "legendFormat": "{{scheme}}", - "range": true, - "refId": "A" - } - ], - "title": "Requests Secured", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "purple", - "mode": "fixed" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 6, - "x": 18, - "y": 13 - }, - "id": 50, - "options": { - "colorMode": "background", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "max" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum by (method_route) (\r\n label_replace(max_over_time(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[$__rate_interval]), \"method_route\", \"http/$1\", \"network_protocol_version\", \"(.*)\")\r\n )", - "legendFormat": "{{protocol}}", - "range": true, - "refId": "A" - } - ], - "title": "Requests HTTP Protocol", - "type": "stat" - } - ], - "refresh": "", - "revision": 1, - "schemaVersion": 39, - "tags": [ - "dotnet", - "prometheus", - "aspnetcore" - ], - "templating": { - "list": [ - { - "current": { - "selected": false, - "text": "fullstackhero.api", - "value": "fullstackhero.api" - }, - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "definition": "label_values(http_server_active_requests,job)", - "hide": 0, - "includeAll": false, - "label": "Job", - "multi": false, - "name": "job", - "options": [], - "query": { - "query": "label_values(http_server_active_requests,job)", - "refId": "PrometheusVariableQueryEditor-VariableQuery" - }, - "refresh": 1, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "type": "query" - }, - { - "current": { - "selected": false, - "text": "host.docker.internal:5000", - "value": "host.docker.internal:5000" - }, - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "definition": "label_values(http_server_active_requests{job=~\"$job\"},instance)", - "hide": 0, - "includeAll": false, - "label": "Instance", - "multi": false, - "name": "instance", - "options": [], - "query": { - "query": "label_values(http_server_active_requests{job=~\"$job\"},instance)", - "refId": "PrometheusVariableQueryEditor-VariableQuery" - }, - "refresh": 1, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "type": "query" - }, - { - "current": { - "selected": false, - "text": "api/roles/", - "value": "api/roles/" - }, - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "definition": "label_values(http_server_request_duration_seconds_count,http_route)", - "description": "Route", - "hide": 0, - "includeAll": false, - "label": "Route", - "multi": false, - "name": "route", - "options": [], - "query": { - "query": "label_values(http_server_request_duration_seconds_count,http_route)", - "refId": "PrometheusVariableQueryEditor-VariableQuery" - }, - "refresh": 1, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "type": "query" - }, - { - "current": { - "selected": false, - "text": "GET", - "value": "GET" - }, - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "definition": "label_values(http_server_request_duration_seconds_count{http_route=~\"$route\"},http_request_method)", - "hide": 0, - "includeAll": false, - "label": "Method", - "multi": false, - "name": "method", - "options": [], - "query": { - "query": "label_values(http_server_request_duration_seconds_count{http_route=~\"$route\"},http_request_method)", - "refId": "PrometheusVariableQueryEditor-VariableQuery" - }, - "refresh": 1, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "type": "query" - } - ] - }, - "time": { - "from": "now-15m", - "to": "now" - }, - "timepicker": { - "refresh_intervals": [ - "1s", - "5s", - "10s", - "30s", - "1m", - "5m", - "15m", - "30m", - "1h", - "2h", - "1d" - ] - }, - "timezone": "", - "title": "ASP.NET Core Endpoint", - "uid": "NagEsjE4j", - "version": 2, - "weekStart": "" -} \ No newline at end of file diff --git a/compose/grafana/dashboards/aspnet-core.json b/compose/grafana/dashboards/aspnet-core.json deleted file mode 100644 index a0d2aa1740..0000000000 --- a/compose/grafana/dashboards/aspnet-core.json +++ /dev/null @@ -1,1332 +0,0 @@ -{ - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "target": { - "limit": 100, - "matchAny": false, - "tags": [], - "type": "dashboard" - }, - "type": "dashboard" - } - ] - }, - "description": "ASP.NET Core metrics from OpenTelemetry", - "editable": true, - "fiscalYearStartMonth": 0, - "gnetId": 19924, - "graphTooltip": 0, - "id": 9, - "links": [], - "liveNow": false, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "dark-green", - "mode": "continuous-GrYlRd", - "seriesBy": "max" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "axisSoftMin": 0, - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 50, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "smooth", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [ - { - "options": { - "match": "null+nan", - "result": { - "index": 1, - "text": "0 ms" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "s" - }, - "overrides": [ - { - "__systemRef": "hideSeriesFrom", - "matcher": { - "id": "byNames", - "options": { - "mode": "exclude", - "names": [ - "p50" - ], - "prefix": "All except:", - "readOnly": true - } - }, - "properties": [ - { - "id": "custom.hideFrom", - "value": { - "legend": false, - "tooltip": false, - "viz": false - } - } - ] - } - ] - }, - "gridPos": { - "h": 9, - "w": 12, - "x": 0, - "y": 0 - }, - "id": 40, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "min", - "max" - ], - "displayMode": "table", - "placement": "right", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.50, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\"}[$__rate_interval])) by (le))", - "legendFormat": "p50", - "range": true, - "refId": "p50" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.75, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p75", - "range": true, - "refId": "p75" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.90, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p90", - "range": true, - "refId": "p90" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.95, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p95", - "range": true, - "refId": "p95" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.98, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p98", - "range": true, - "refId": "p98" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.99, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p99", - "range": true, - "refId": "p99" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.999, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p99.9", - "range": true, - "refId": "p99.9" - } - ], - "title": "Requests Duration", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic", - "seriesBy": "max" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 50, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "smooth", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [ - { - "options": { - "match": "null+nan", - "result": { - "index": 1, - "text": "0%" - } - }, - "type": "special" - } - ], - "max": 1, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "percentunit" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "All" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-orange", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "4XX" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "yellow", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "5XX" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-red", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 9, - "w": 12, - "x": 12, - "y": 0 - }, - "id": 47, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "min", - "max" - ], - "displayMode": "table", - "placement": "right", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_response_status_code=~\"4..|5..\"}[$__rate_interval]) or vector(0)) / sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\"}[$__rate_interval]))", - "legendFormat": "All", - "range": true, - "refId": "All" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_response_status_code=~\"4..\"}[$__rate_interval]) or vector(0)) / sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\"}[$__rate_interval]))", - "hide": false, - "legendFormat": "4XX", - "range": true, - "refId": "4XX" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_response_status_code=~\"5..\"}[$__rate_interval]) or vector(0)) / sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\"}[$__rate_interval]))", - "hide": false, - "legendFormat": "5XX", - "range": true, - "refId": "5XX" - } - ], - "title": "Errors Rate", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 6, - "x": 0, - "y": 9 - }, - "id": 49, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": false - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(kestrel_active_connections{job=\"$job\", instance=\"$instance\"})", - "legendFormat": "__auto", - "range": true, - "refId": "A" - } - ], - "title": "Current Connections", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 6, - "x": 6, - "y": 9 - }, - "id": 55, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": false - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(http_server_active_requests{job=\"$job\", instance=\"$instance\"})", - "legendFormat": "__auto", - "range": true, - "refId": "A" - } - ], - "title": "Current Requests", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "blue", - "mode": "fixed" - }, - "mappings": [], - "noValue": "0", - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 6, - "x": 12, - "y": 9 - }, - "id": 58, - "options": { - "colorMode": "background", - "graphMode": "area", - "justifyMode": "center", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "text": {}, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "exemplar": false, - "expr": "sum(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\"})", - "instant": false, - "legendFormat": "__auto", - "range": true, - "refId": "A" - } - ], - "title": "Total Requests", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "dark-red", - "mode": "fixed" - }, - "mappings": [], - "noValue": "0", - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 6, - "x": 18, - "y": 9 - }, - "id": 59, - "options": { - "colorMode": "background", - "graphMode": "area", - "justifyMode": "center", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "text": {}, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "exemplar": false, - "expr": "sum(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", error_type!=\"\"})", - "instant": false, - "legendFormat": "__auto", - "range": true, - "refId": "A" - } - ], - "title": "Total Unhandled Exceptions", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "green", - "mode": "fixed" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 6, - "x": 12, - "y": 13 - }, - "id": 60, - "options": { - "colorMode": "background", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "max" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum by (url_scheme) (\r\n max_over_time(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\"}[$__rate_interval])\r\n )", - "legendFormat": "{{scheme}}", - "range": true, - "refId": "A" - } - ], - "title": "Requests Secured", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "purple", - "mode": "fixed" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 6, - "x": 18, - "y": 13 - }, - "id": 42, - "options": { - "colorMode": "background", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "max" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum by (method_route) (\r\n label_replace(max_over_time(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\"}[$__rate_interval]), \"method_route\", \"http/$1\", \"network_protocol_version\", \"(.*)\")\r\n )", - "legendFormat": "{{protocol}}", - "range": true, - "refId": "A" - } - ], - "title": "Requests HTTP Protocol", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "inspect": false - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Requests" - }, - "properties": [ - { - "id": "custom.width", - "value": 300 - }, - { - "id": "custom.cellOptions", - "value": { - "mode": "gradient", - "type": "gauge" - } - }, - { - "id": "color", - "value": { - "mode": "continuous-BlPu" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Endpoint" - }, - "properties": [ - { - "id": "links", - "value": [ - { - "targetBlank": false, - "title": "Test", - "url": "/d/NagEsjE4z/asp-net-core-endpoint-details?var-route=${__data.fields.http_route}&var-method=${__data.fields.http_request_method}&${__url_time_range}" - } - ] - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "http_route" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "http_request_method" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - } - ] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 17 - }, - "hideTimeOverride": false, - "id": 51, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true, - "sortBy": [ - { - "desc": true, - "displayName": "Value" - } - ] - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "exemplar": false, - "expr": " topk(10,\r\n sum by (http_route, http_request_method, method_route) (\r\n label_join(max_over_time(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route!=\"\"}[$__rate_interval]), \"method_route\", \" \", \"http_request_method\", \"http_route\")\r\n ))", - "format": "table", - "instant": true, - "interval": "", - "legendFormat": "{{route}}", - "range": false, - "refId": "A" - } - ], - "title": "Top 10 Requested Endpoints", - "transformations": [ - { - "id": "organize", - "options": { - "excludeByName": { - "Time": true, - "method": false, - "route": false - }, - "indexByName": { - "Time": 0, - "Value": 4, - "method": 2, - "method_route": 3, - "route": 1 - }, - "renameByName": { - "Value": "Requests", - "method": "", - "method_route": "Endpoint", - "route": "" - } - } - } - ], - "type": "table" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "inspect": false - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Requests" - }, - "properties": [ - { - "id": "custom.width", - "value": 300 - }, - { - "id": "custom.cellOptions", - "value": { - "mode": "gradient", - "type": "gauge" - } - }, - { - "id": "color", - "value": { - "mode": "continuous-YlRd" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Endpoint" - }, - "properties": [ - { - "id": "links", - "value": [ - { - "title": "", - "url": "/d/NagEsjE4z/asp-net-core-endpoint-details?var-route=${__data.fields.http_route}&var-method=${__data.fields.http_request_method}&${__url_time_range}" - } - ] - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "http_route" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "http_request_method" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - } - ] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 17 - }, - "hideTimeOverride": false, - "id": 54, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true, - "sortBy": [ - { - "desc": true, - "displayName": "Value" - } - ] - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "exemplar": false, - "expr": " topk(10,\r\n sum by (http_route, http_request_method, method_route) (\r\n label_join(max_over_time(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route!=\"\", error_type!=\"\"}[$__rate_interval]), \"method_route\", \" \", \"http_request_method\", \"http_route\")\r\n ))", - "format": "table", - "instant": true, - "interval": "", - "legendFormat": "{{route}}", - "range": false, - "refId": "A" - } - ], - "title": "Top 10 Unhandled Exception Endpoints", - "transformations": [ - { - "id": "organize", - "options": { - "excludeByName": { - "Time": true, - "method": false - }, - "indexByName": { - "Time": 0, - "Value": 4, - "method": 2, - "method_route": 3, - "route": 1 - }, - "renameByName": { - "Value": "Requests", - "method": "", - "method_route": "Endpoint", - "route": "" - } - } - } - ], - "type": "table" - } - ], - "refresh": "", - "revision": 1, - "schemaVersion": 39, - "tags": [ - "dotnet", - "prometheus", - "aspnetcore" - ], - "templating": { - "list": [ - { - "current": { - "selected": false, - "text": "fullstackhero.api", - "value": "fullstackhero.api" - }, - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "definition": "label_values(http_server_active_requests,job)", - "hide": 0, - "includeAll": false, - "label": "Job", - "multi": false, - "name": "job", - "options": [], - "query": { - "query": "label_values(http_server_active_requests,job)", - "refId": "PrometheusVariableQueryEditor-VariableQuery" - }, - "refresh": 2, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "type": "query" - }, - { - "current": { - "selected": false, - "text": "host.docker.internal:5000", - "value": "host.docker.internal:5000" - }, - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "definition": "label_values(http_server_active_requests{job=~\"$job\"},instance)", - "hide": 0, - "includeAll": false, - "label": "Instance", - "multi": false, - "name": "instance", - "options": [], - "query": { - "query": "label_values(http_server_active_requests{job=~\"$job\"},instance)", - "refId": "PrometheusVariableQueryEditor-VariableQuery" - }, - "refresh": 2, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "type": "query" - } - ] - }, - "time": { - "from": "now-15m", - "to": "now" - }, - "timepicker": { - "refresh_intervals": [ - "1s", - "5s", - "10s", - "30s", - "1m", - "5m", - "15m", - "30m", - "1h", - "2h", - "1d" - ] - }, - "timezone": "", - "title": "ASP.NET Core", - "uid": "KdDACDp4z", - "version": 2, - "weekStart": "" -} \ No newline at end of file diff --git a/compose/grafana/dashboards/dotnet-otel-dashboard.json b/compose/grafana/dashboards/dotnet-otel-dashboard.json deleted file mode 100644 index 1b179c6791..0000000000 --- a/compose/grafana/dashboards/dotnet-otel-dashboard.json +++ /dev/null @@ -1,2031 +0,0 @@ -{ - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "target": { - "limit": 100, - "matchAny": false, - "tags": [], - "type": "dashboard" - }, - "type": "dashboard" - } - ] - }, - "description": "Shows ASP.NET metrics from OpenTelemetry NuGet", - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "id": 9, - "links": [], - "liveNow": false, - "panels": [ - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 15, - "panels": [], - "title": "Process", - "type": "row" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 90, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "percentunit" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "system" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-orange", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "user" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-green", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 9, - "w": 11, - "x": 0, - "y": 1 - }, - "id": 19, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "min", - "max", - "mean" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "irate(process_cpu_time{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])", - "legendFormat": "__auto", - "range": true, - "refId": "CPU Usage" - } - ], - "title": "CPU Usage", - "transformations": [ - { - "id": "labelsToFields", - "options": { - "keepLabels": [ - "state" - ], - "valueLabel": "state" - } - } - ], - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "dark-green", - "mode": "fixed" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 90, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineStyle": { - "fill": "solid" - }, - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 9, - "w": 10, - "x": 11, - "y": 1 - }, - "id": 16, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "min", - "max", - "mean" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "max_over_time(process_memory_usage{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])", - "legendFormat": "Memory Usage", - "range": true, - "refId": "Memory Usage" - } - ], - "title": "Memory Usage", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "dark-green", - "value": null - }, - { - "color": "dark-yellow", - "value": 50 - }, - { - "color": "dark-orange", - "value": 100 - }, - { - "color": "dark-red", - "value": 150 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 9, - "w": 3, - "x": 21, - "y": 1 - }, - "id": 12, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "textMode": "value" - }, - "pluginVersion": "10.0.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "max_over_time(process_threads{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])", - "legendFormat": "Threads", - "range": true, - "refId": "Threads" - } - ], - "title": "Threads", - "type": "stat" - }, - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 10 - }, - "id": 2, - "panels": [], - "title": "Runtime", - "type": "row" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "continuous-GrYlRd", - "seriesBy": "max" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 50, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineStyle": { - "fill": "solid" - }, - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 9, - "w": 8, - "x": 0, - "y": 11 - }, - "id": 6, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "min", - "max", - "mean" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "max_over_time(process_runtime_dotnet_gc_committed_memory_size{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])", - "hide": false, - "legendFormat": "Committed Memory Size", - "range": true, - "refId": "Committed Memory Size" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "max_over_time(process_runtime_dotnet_gc_committed_memory_size{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])", - "legendFormat": "Objects Size", - "range": true, - "refId": "Objects Size" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "exemplar": false, - "expr": "irate(process_runtime_dotnet_gc_committed_memory_size{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])", - "hide": false, - "instant": false, - "legendFormat": "Allocations Size", - "range": true, - "refId": "Allocations Size" - } - ], - "title": "General Memory Usage", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "text", - "mode": "fixed" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 60, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineWidth": 0, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "gen0" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-green", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "gen1" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-yellow", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "gen2" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-red", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "loh" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-orange", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "poh" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-blue", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 9, - "w": 8, - "x": 8, - "y": 11 - }, - "id": 8, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "max_over_time(process_runtime_dotnet_gc_heap_size{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])", - "legendFormat": "__auto", - "range": true, - "refId": "Heap Size" - } - ], - "title": "Heap Generations (bytes)", - "transformations": [ - { - "id": "labelsToFields", - "options": { - "keepLabels": [ - "generation" - ], - "valueLabel": "generation" - } - } - ], - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "text", - "mode": "fixed" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 60, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineWidth": 0, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "gen0" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-green", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "gen1" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-yellow", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "gen2" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-red", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "loh" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-orange", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "poh" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-blue", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 9, - "w": 8, - "x": 16, - "y": 11 - }, - "id": 9, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "max_over_time(process_runtime_dotnet_gc_heap_fragmentation_size{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])", - "legendFormat": "__auto", - "range": true, - "refId": "Heap Fragmentation" - } - ], - "title": "Heap Fragmentation (bytes)", - "transformations": [ - { - "id": "labelsToFields", - "options": { - "keepLabels": [ - "generation" - ], - "valueLabel": "generation" - } - } - ], - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "green", - "mode": "fixed" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": -1, - "drawStyle": "bars", - "fillOpacity": 100, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 0, - "pointSize": 1, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [ - { - "options": { - "0": { - "color": "transparent", - "index": 0, - "text": "None" - } - }, - "type": "value" - } - ], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "gen0" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-green", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "gen1" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-yellow", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "gen2" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-red", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 9, - "w": 8, - "x": 0, - "y": 20 - }, - "id": 4, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.3.2", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "exemplar": false, - "expr": "idelta(process_runtime_dotnet_gc_collections_count{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])", - "hide": false, - "instant": false, - "legendFormat": "__auto", - "range": true, - "refId": "gc" - } - ], - "title": "GC Collections", - "transformations": [ - { - "id": "labelsToFields", - "options": { - "keepLabels": [ - "generation" - ], - "mode": "columns", - "valueLabel": "generation" - } - } - ], - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "dark-red", - "mode": "fixed" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 90, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineStyle": { - "fill": "solid" - }, - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 9, - "w": 8, - "x": 8, - "y": 20 - }, - "id": 13, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": false - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "increase(process_runtime_dotnet_exceptions_count{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])", - "legendFormat": "Exceptions", - "range": true, - "refId": "Exceptions" - } - ], - "title": "Exceptions", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "dark-green", - "mode": "fixed" - }, - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 9, - "w": 4, - "x": 16, - "y": 20 - }, - "id": 11, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "pluginVersion": "10.0.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "max_over_time(process_runtime_dotnet_thread_pool_threads_count{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])", - "legendFormat": "ThreadPool Threads", - "range": true, - "refId": "ThreadPool Threads" - } - ], - "title": "ThreadPool Threads", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "dark-green", - "mode": "fixed" - }, - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 9, - "w": 4, - "x": 20, - "y": 20 - }, - "id": 17, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "pluginVersion": "10.0.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "max_over_time(process_runtime_dotnet_thread_pool_queue_length{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])", - "legendFormat": "ThreadPool Threads Queue Length", - "range": true, - "refId": "ThreadPool Threads Queue Length" - } - ], - "title": "ThreadPool Threads Queue Length", - "type": "stat" - }, - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 29 - }, - "id": 33, - "panels": [], - "title": "HTTP Server", - "type": "row" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "dark-green", - "mode": "continuous-GrYlRd", - "seriesBy": "max" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 50, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - } - ] - }, - "unit": "ms" - }, - "overrides": [] - }, - "gridPos": { - "h": 9, - "w": 12, - "x": 0, - "y": 30 - }, - "id": 40, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "min", - "max" - ], - "displayMode": "table", - "placement": "right", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.50, sum(rate(http_server_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])) by (le))", - "legendFormat": "p50", - "range": true, - "refId": "p50" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.75, sum(rate(http_server_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p75", - "range": true, - "refId": "p75" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.90, sum(rate(http_server_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p90", - "range": true, - "refId": "p90" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.95, sum(rate(http_server_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p95", - "range": true, - "refId": "p95" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.98, sum(rate(http_server_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p98", - "range": true, - "refId": "p98" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.99, sum(rate(http_server_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p99", - "range": true, - "refId": "p99" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.999, sum(rate(http_server_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p99.9", - "range": true, - "refId": "p99.9" - } - ], - "title": "Responses Duration", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic", - "seriesBy": "max" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 50, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - } - ] - }, - "unit": "percentunit" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "All" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-orange", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "4XX" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "yellow", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "5XX" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-red", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 9, - "w": 12, - "x": 12, - "y": 30 - }, - "id": 47, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "min", - "max" - ], - "displayMode": "table", - "placement": "right", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(rate(http_server_duration_count{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", http_status_code!~\"2..\"}[$__rate_interval]) or vector(0)) / sum(rate(http_server_duration_count{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval]))", - "legendFormat": "All", - "range": true, - "refId": "All" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(rate(http_server_duration_count{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", http_status_code=~\"4..\"}[$__rate_interval]) or vector(0)) / sum(rate(http_server_duration_count{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval]))", - "hide": false, - "legendFormat": "4XX", - "range": true, - "refId": "4XX" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(rate(http_server_duration_count{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", http_status_code=~\"5..\"}[$__rate_interval]) or vector(0)) / sum(rate(http_server_duration_count{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval]))", - "hide": false, - "legendFormat": "5XX", - "range": true, - "refId": "5XX" - } - ], - "title": "Errors Rate", - "type": "timeseries" - }, - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 39 - }, - "id": 21, - "panels": [], - "repeat": "http_client_peer_name", - "repeatDirection": "h", - "title": "HTTP Client ($http_client_peer_name)", - "type": "row" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "dark-green", - "mode": "continuous-GrYlRd", - "seriesBy": "max" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 50, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - } - ] - }, - "unit": "ms" - }, - "overrides": [] - }, - "gridPos": { - "h": 9, - "w": 12, - "x": 0, - "y": 40 - }, - "id": 23, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "min", - "max" - ], - "displayMode": "table", - "placement": "right", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.50, sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\"}[$__rate_interval])) by (le))", - "legendFormat": "p50", - "range": true, - "refId": "p50" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.75, sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p75", - "range": true, - "refId": "p75" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.90, sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p90", - "range": true, - "refId": "p90" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.95, sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p95", - "range": true, - "refId": "p95" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.98, sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p98", - "range": true, - "refId": "p98" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.99, sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p99", - "range": true, - "refId": "p99" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.999, sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p99.9", - "range": true, - "refId": "p99.9" - } - ], - "title": "Requests Duration", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic", - "seriesBy": "max" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 50, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - } - ] - }, - "unit": "percentunit" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "All" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-orange", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "4XX" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "light-yellow", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "5XX" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-red", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 9, - "w": 12, - "x": 12, - "y": 40 - }, - "id": 25, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "min", - "max" - ], - "displayMode": "table", - "placement": "right", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\", http_status_code!~\"2..\"}[$__rate_interval]) or vector(0)) / sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\"}[$__rate_interval]))", - "legendFormat": "All", - "range": true, - "refId": "All" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\", http_status_code=~\"4..\"}[$__rate_interval]) or vector(0)) / sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\"}[$__rate_interval]))", - "hide": false, - "legendFormat": "4XX", - "range": true, - "refId": "4XX" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\", http_status_code=~\"5..\"}[$__rate_interval]) or vector(0)) / sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\"}[$__rate_interval]))", - "hide": false, - "legendFormat": "5XX", - "range": true, - "refId": "5XX" - } - ], - "title": "Errors Rate", - "type": "timeseries" - } - ], - "refresh": "1m", - "schemaVersion": 38, - "style": "dark", - "tags": [], - "templating": { - "list": [ - { - "current": { - "selected": false, - "text": "FST.TAG.Manager", - "value": "FST.TAG.Manager" - }, - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "definition": "label_values(process_runtime_dotnet_gc_collections_count,exported_job)", - "hide": 0, - "includeAll": false, - "label": "Job", - "multi": false, - "name": "exported_job", - "options": [], - "query": { - "query": "label_values(process_runtime_dotnet_gc_collections_count,exported_job)", - "refId": "PrometheusVariableQueryEditor-VariableQuery" - }, - "refresh": 2, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "type": "query" - }, - { - "current": { - "selected": false, - "text": "39230ae2-5527-47b3-b546-6f8d4cfc9ab0", - "value": "39230ae2-5527-47b3-b546-6f8d4cfc9ab0" - }, - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "definition": "label_values(process_runtime_dotnet_gc_collections_count{exported_job=~\"$exported_job\"},exported_instance)", - "hide": 0, - "includeAll": false, - "label": "Instance", - "multi": false, - "name": "exported_instance", - "options": [], - "query": { - "query": "label_values(process_runtime_dotnet_gc_collections_count{exported_job=~\"$exported_job\"},exported_instance)", - "refId": "PrometheusVariableQueryEditor-VariableQuery" - }, - "refresh": 2, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "type": "query" - }, - { - "current": { - "selected": false, - "text": "All", - "value": "$__all" - }, - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "definition": "label_values(http_client_duration_bucket{exported_job=~\"$exported_job\",exported_instance=~\"$exported_instance\"},net_peer_name)", - "hide": 2, - "includeAll": true, - "label": "HTTP Client Pear Name", - "multi": false, - "name": "http_client_peer_name", - "options": [], - "query": { - "query": "label_values(http_client_duration_bucket{exported_job=~\"$exported_job\",exported_instance=~\"$exported_instance\"},net_peer_name)", - "refId": "PrometheusVariableQueryEditor-VariableQuery" - }, - "refresh": 2, - "regex": "", - "skipUrlSync": false, - "sort": 5, - "type": "query" - } - ] - }, - "time": { - "from": "now-5m", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "ASP.NET OTEL Metrics", - "uid": "bc47b423-0b3c-4538-8e20-f84f84deefe5", - "version": 6, - "weekStart": "" -} \ No newline at end of file diff --git a/compose/grafana/dashboards/logs-dashboard.json b/compose/grafana/dashboards/logs-dashboard.json deleted file mode 100644 index f4ddf3b973..0000000000 --- a/compose/grafana/dashboards/logs-dashboard.json +++ /dev/null @@ -1,334 +0,0 @@ -{ - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "target": { - "limit": 100, - "matchAny": false, - "tags": [], - "type": "dashboard" - }, - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "links": [], - "liveNow": false, - "panels": [ - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 6, - "panels": [], - "title": "Logs by Level", - "type": "row" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 1, - "drawStyle": "bars", - "fillOpacity": 100, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "stepBefore", - "lineStyle": { - "fill": "solid" - }, - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 24, - "x": 0, - "y": 1 - }, - "id": 3, - "interval": "1m", - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "sum by (level) (count_over_time({service_name=\"$service_name\", level=~\"$level\"} [$__interval]))", - "legendFormat": "{{level}}", - "queryType": "range", - "refId": "A" - } - ], - "title": "Log Volume", - "type": "timeseries" - }, - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 9 - }, - "id": 5, - "panels": [], - "title": "Logs Detailed Information", - "type": "row" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "gridPos": { - "h": 11, - "w": 24, - "x": 0, - "y": 10 - }, - "id": 2, - "options": { - "dedupStrategy": "none", - "enableLogDetails": true, - "prettifyLogMessage": false, - "showCommonLabels": false, - "showLabels": false, - "showTime": true, - "sortOrder": "Descending", - "wrapLogMessage": false - }, - "pluginVersion": "9.3.2", - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "{service_name=\"$service_name\", severity_text=~\"$level\"} |=\"$search\" | line_format `[{{ .severity_text }}] {{ .message_template_text }}`", - "queryType": "range", - "refId": "A" - } - ], - "title": "Logs", - "type": "logs" - }, - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 21 - }, - "id": 4, - "panels": [], - "title": "Logs with TraceId Link", - "type": "row" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "gridPos": { - "h": 13, - "w": 24, - "x": 0, - "y": 22 - }, - "id": 7, - "options": { - "dedupStrategy": "none", - "enableLogDetails": true, - "prettifyLogMessage": false, - "showCommonLabels": false, - "showLabels": false, - "showTime": false, - "sortOrder": "Descending", - "wrapLogMessage": false - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "{service_name=\"$service_name\", level=~\"$level\"} |=\"$search\" | json ", - "key": "Q-b242453d-acff-49f2-9239-12ceaf57fa43-0", - "queryType": "range", - "refId": "A" - } - ], - "title": "Log Entries with Trace Link", - "type": "logs" - } - ], - "refresh": "", - "schemaVersion": 39, - "tags": [], - "templating": { - "list": [ - { - "current": { - "selected": false, - "text": "FSH.Starter.WebApi.Host", - "value": "FSH.Starter.WebApi.Host" - }, - "datasource": { - "type": "loki", - "uid": "loki" - }, - "definition": "", - "hide": 0, - "includeAll": false, - "label": "Service", - "multi": false, - "name": "service_name", - "options": [], - "query": { - "label": "service_name", - "refId": "LokiVariableQueryEditor-VariableQuery", - "stream": "", - "type": 1 - }, - "refresh": 1, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "type": "query" - }, - { - "current": { - "selected": false, - "text": "All", - "value": "$__all" - }, - "datasource": { - "type": "loki", - "uid": "loki" - }, - "definition": "", - "hide": 0, - "includeAll": true, - "multi": false, - "name": "level", - "options": [], - "query": { - "label": "severity_text", - "refId": "LokiVariableQueryEditor-VariableQuery", - "stream": "", - "type": 1 - }, - "refresh": 1, - "regex": "", - "skipUrlSync": false, - "sort": 0, - "type": "query" - }, - { - "current": { - "selected": false, - "text": "", - "value": "" - }, - "hide": 0, - "label": "Search Text", - "name": "search", - "options": [ - { - "selected": true, - "text": "", - "value": "" - } - ], - "query": "", - "skipUrlSync": false, - "type": "textbox" - } - ] - }, - "time": { - "from": "now-15m", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Logs", - "uid": "f4463c33-40c8-4def-aac2-95d365040f2e", - "version": 1, - "weekStart": "" -} \ No newline at end of file diff --git a/compose/grafana/dashboards/node-exporter.json b/compose/grafana/dashboards/node-exporter.json deleted file mode 100644 index cb734d8060..0000000000 --- a/compose/grafana/dashboards/node-exporter.json +++ /dev/null @@ -1,23870 +0,0 @@ -{ - "annotations": { - "list": [ - { - "$$hashKey": "object:1058", - "builtIn": 1, - "datasource": { - "type": "datasource", - "uid": "grafana" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "target": { - "limit": 100, - "matchAny": false, - "tags": [], - "type": "dashboard" - }, - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "gnetId": 1860, - "graphTooltip": 1, - "id": 8, - "links": [ - { - "icon": "external link", - "tags": [], - "targetBlank": true, - "title": "GitHub", - "type": "link", - "url": "https://github.com/rfmoz/grafana-dashboards" - }, - { - "icon": "external link", - "tags": [], - "targetBlank": true, - "title": "Grafana", - "type": "link", - "url": "https://grafana.com/grafana/dashboards/1860" - } - ], - "liveNow": false, - "panels": [ - { - "collapsed": false, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 261, - "panels": [], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "Quick CPU / Mem / Disk", - "type": "row" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Resource pressure via PSI", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, - "links": [], - "mappings": [], - "max": 1, - "min": 0, - "thresholds": { - "mode": "percentage", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "dark-yellow", - "value": 70 - }, - { - "color": "dark-red", - "value": 90 - } - ] - }, - "unit": "percentunit" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 3, - "x": 0, - "y": 1 - }, - "id": 323, - "options": { - "displayMode": "basic", - "maxVizHeight": 300, - "minVizHeight": 10, - "minVizWidth": 0, - "namePlacement": "auto", - "orientation": "horizontal", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showUnfilled": true, - "sizing": "auto", - "text": {}, - "valueMode": "color" - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "irate(node_pressure_cpu_waiting_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "instant": true, - "intervalFactor": 1, - "legendFormat": "CPU", - "range": false, - "refId": "CPU some", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "irate(node_pressure_memory_waiting_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "instant": true, - "intervalFactor": 1, - "legendFormat": "Mem", - "range": false, - "refId": "Memory some", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "irate(node_pressure_io_waiting_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "instant": true, - "intervalFactor": 1, - "legendFormat": "I/O", - "range": false, - "refId": "I/O some", - "step": 240 - } - ], - "title": "Pressure", - "type": "bargauge" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Busy state of all CPU cores together", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "rgba(50, 172, 45, 0.97)", - "value": null - }, - { - "color": "rgba(237, 129, 40, 0.89)", - "value": 85 - }, - { - "color": "rgba(245, 54, 54, 0.9)", - "value": 95 - } - ] - }, - "unit": "percent" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 3, - "x": 3, - "y": 1 - }, - "id": 20, - "options": { - "minVizHeight": 75, - "minVizWidth": 75, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showThresholdLabels": false, - "showThresholdMarkers": true, - "sizing": "auto" - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "100 * (1 - avg(rate(node_cpu_seconds_total{mode=\"idle\", instance=\"$node\"}[$__rate_interval])))", - "hide": false, - "instant": true, - "intervalFactor": 1, - "legendFormat": "", - "range": false, - "refId": "A", - "step": 240 - } - ], - "title": "CPU Busy", - "type": "gauge" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "System load over all CPU cores together", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "rgba(50, 172, 45, 0.97)", - "value": null - }, - { - "color": "rgba(237, 129, 40, 0.89)", - "value": 85 - }, - { - "color": "rgba(245, 54, 54, 0.9)", - "value": 95 - } - ] - }, - "unit": "percent" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 3, - "x": 6, - "y": 1 - }, - "id": 155, - "options": { - "minVizHeight": 75, - "minVizWidth": 75, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showThresholdLabels": false, - "showThresholdMarkers": true, - "sizing": "auto" - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "scalar(node_load1{instance=\"$node\",job=\"$job\"}) * 100 / count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu))", - "format": "time_series", - "hide": false, - "instant": true, - "intervalFactor": 1, - "range": false, - "refId": "A", - "step": 240 - } - ], - "title": "Sys Load", - "type": "gauge" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Non available RAM memory", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "rgba(50, 172, 45, 0.97)", - "value": null - }, - { - "color": "rgba(237, 129, 40, 0.89)", - "value": 80 - }, - { - "color": "rgba(245, 54, 54, 0.9)", - "value": 90 - } - ] - }, - "unit": "percent" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 3, - "x": 9, - "y": 1 - }, - "hideTimeOverride": false, - "id": 16, - "options": { - "minVizHeight": 75, - "minVizWidth": 75, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showThresholdLabels": false, - "showThresholdMarkers": true, - "sizing": "auto" - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "((node_memory_MemTotal_bytes{instance=\"$node\", job=\"$job\"} - node_memory_MemFree_bytes{instance=\"$node\", job=\"$job\"}) / node_memory_MemTotal_bytes{instance=\"$node\", job=\"$job\"}) * 100", - "format": "time_series", - "hide": true, - "instant": true, - "intervalFactor": 1, - "range": false, - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "(1 - (node_memory_MemAvailable_bytes{instance=\"$node\", job=\"$job\"} / node_memory_MemTotal_bytes{instance=\"$node\", job=\"$job\"})) * 100", - "format": "time_series", - "hide": false, - "instant": true, - "intervalFactor": 1, - "range": false, - "refId": "B", - "step": 240 - } - ], - "title": "RAM Used", - "type": "gauge" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Used Swap", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "rgba(50, 172, 45, 0.97)", - "value": null - }, - { - "color": "rgba(237, 129, 40, 0.89)", - "value": 10 - }, - { - "color": "rgba(245, 54, 54, 0.9)", - "value": 25 - } - ] - }, - "unit": "percent" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 3, - "x": 12, - "y": 1 - }, - "id": 21, - "options": { - "minVizHeight": 75, - "minVizWidth": 75, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showThresholdLabels": false, - "showThresholdMarkers": true, - "sizing": "auto" - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "((node_memory_SwapTotal_bytes{instance=\"$node\",job=\"$job\"} - node_memory_SwapFree_bytes{instance=\"$node\",job=\"$job\"}) / (node_memory_SwapTotal_bytes{instance=\"$node\",job=\"$job\"})) * 100", - "instant": true, - "intervalFactor": 1, - "range": false, - "refId": "A", - "step": 240 - } - ], - "title": "SWAP Used", - "type": "gauge" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Used Root FS", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "rgba(50, 172, 45, 0.97)", - "value": null - }, - { - "color": "rgba(237, 129, 40, 0.89)", - "value": 80 - }, - { - "color": "rgba(245, 54, 54, 0.9)", - "value": 90 - } - ] - }, - "unit": "percent" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 3, - "x": 15, - "y": 1 - }, - "id": 154, - "options": { - "minVizHeight": 75, - "minVizWidth": 75, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showThresholdLabels": false, - "showThresholdMarkers": true, - "sizing": "auto" - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "100 - ((node_filesystem_avail_bytes{instance=\"$node\",job=\"$job\",mountpoint=\"/\",fstype!=\"rootfs\"} * 100) / node_filesystem_size_bytes{instance=\"$node\",job=\"$job\",mountpoint=\"/\",fstype!=\"rootfs\"})", - "format": "time_series", - "instant": true, - "intervalFactor": 1, - "range": false, - "refId": "A", - "step": 240 - } - ], - "title": "Root FS Used", - "type": "gauge" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Total number of CPU cores", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 2, - "w": 2, - "x": 18, - "y": 1 - }, - "id": 14, - "maxDataPoints": 100, - "options": { - "colorMode": "none", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu))", - "instant": true, - "legendFormat": "__auto", - "range": false, - "refId": "A" - } - ], - "title": "CPU Cores", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "System uptime", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { - "h": 2, - "w": 4, - "x": 20, - "y": 1 - }, - "hideTimeOverride": true, - "id": 15, - "maxDataPoints": 100, - "options": { - "colorMode": "none", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "node_time_seconds{instance=\"$node\",job=\"$job\"} - node_boot_time_seconds{instance=\"$node\",job=\"$job\"}", - "instant": true, - "intervalFactor": 1, - "range": false, - "refId": "A", - "step": 240 - } - ], - "title": "Uptime", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Total RootFS", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 0, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "rgba(50, 172, 45, 0.97)", - "value": null - }, - { - "color": "rgba(237, 129, 40, 0.89)", - "value": 70 - }, - { - "color": "rgba(245, 54, 54, 0.9)", - "value": 90 - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 2, - "w": 2, - "x": 18, - "y": 3 - }, - "id": 23, - "maxDataPoints": 100, - "options": { - "colorMode": "none", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "node_filesystem_size_bytes{instance=\"$node\",job=\"$job\",mountpoint=\"/\",fstype!=\"rootfs\"}", - "format": "time_series", - "hide": false, - "instant": true, - "intervalFactor": 1, - "range": false, - "refId": "A", - "step": 240 - } - ], - "title": "RootFS Total", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Total RAM", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 0, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 2, - "w": 2, - "x": 20, - "y": 3 - }, - "id": 75, - "maxDataPoints": 100, - "options": { - "colorMode": "none", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "node_memory_MemTotal_bytes{instance=\"$node\",job=\"$job\"}", - "instant": true, - "intervalFactor": 1, - "range": false, - "refId": "A", - "step": 240 - } - ], - "title": "RAM Total", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Total SWAP", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 0, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 2, - "w": 2, - "x": 22, - "y": 3 - }, - "id": 18, - "maxDataPoints": 100, - "options": { - "colorMode": "none", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "node_memory_SwapTotal_bytes{instance=\"$node\",job=\"$job\"}", - "instant": true, - "intervalFactor": 1, - "range": false, - "refId": "A", - "step": 240 - } - ], - "title": "SWAP Total", - "type": "stat" - }, - { - "collapsed": false, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 5 - }, - "id": 263, - "panels": [], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "Basic CPU / Mem / Net / Disk", - "type": "row" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Basic CPU info", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 40, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "smooth", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "percent" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "percentunit" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Busy Iowait" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Idle" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Busy Iowait" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Idle" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Busy System" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Busy User" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A437C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Busy Other" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 7, - "w": 12, - "x": 0, - "y": 6 - }, - "id": 77, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true, - "width": 250 - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"system\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "hide": false, - "instant": false, - "intervalFactor": 1, - "legendFormat": "Busy System", - "range": true, - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"user\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Busy User", - "range": true, - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"iowait\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Busy Iowait", - "range": true, - "refId": "C", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=~\".*irq\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Busy IRQs", - "range": true, - "refId": "D", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode!='idle',mode!='user',mode!='system',mode!='iowait',mode!='irq',mode!='softirq'}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Busy Other", - "range": true, - "refId": "E", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"idle\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Idle", - "range": true, - "refId": "F", - "step": 240 - } - ], - "title": "CPU Basic", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Basic memory usage", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 40, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Apps" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#629E51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A437C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#CFFAFF", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "SWAP Used" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap Used" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#2F575E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Unused" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM Total" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - }, - { - "id": "custom.stacking", - "value": { - "group": false, - "mode": "normal" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM Cache + Buffer" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Available" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#DEDAF7", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - }, - { - "id": "custom.stacking", - "value": { - "group": false, - "mode": "normal" - } - } - ] - } - ] - }, - "gridPos": { - "h": 7, - "w": 12, - "x": 12, - "y": 6 - }, - "id": 78, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true, - "width": 350 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_MemTotal_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "RAM Total", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_MemTotal_bytes{instance=\"$node\",job=\"$job\"} - node_memory_MemFree_bytes{instance=\"$node\",job=\"$job\"} - (node_memory_Cached_bytes{instance=\"$node\",job=\"$job\"} + node_memory_Buffers_bytes{instance=\"$node\",job=\"$job\"} + node_memory_SReclaimable_bytes{instance=\"$node\",job=\"$job\"})", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "RAM Used", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Cached_bytes{instance=\"$node\",job=\"$job\"} + node_memory_Buffers_bytes{instance=\"$node\",job=\"$job\"} + node_memory_SReclaimable_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "RAM Cache + Buffer", - "refId": "C", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_MemFree_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "RAM Free", - "refId": "D", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "(node_memory_SwapTotal_bytes{instance=\"$node\",job=\"$job\"} - node_memory_SwapFree_bytes{instance=\"$node\",job=\"$job\"})", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "SWAP Used", - "refId": "E", - "step": 240 - } - ], - "title": "Memory Basic", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Basic network info per interface", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 40, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bps" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Recv_bytes_eth2" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Recv_bytes_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Recv_drop_eth2" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Recv_drop_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Recv_errs_eth2" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Recv_errs_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#CCA300", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Trans_bytes_eth2" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Trans_bytes_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Trans_drop_eth2" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Trans_drop_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Trans_errs_eth2" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Trans_errs_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#CCA300", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "recv_bytes_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "recv_drop_eth0" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#99440A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "recv_drop_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#967302", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "recv_errs_eth0" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "recv_errs_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "trans_bytes_eth0" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "trans_bytes_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "trans_drop_eth0" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#99440A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "trans_drop_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#967302", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "trans_errs_eth0" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "trans_errs_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*trans.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 7, - "w": 12, - "x": 0, - "y": 13 - }, - "id": 74, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_receive_bytes_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])*8", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "recv {{device}}", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_transmit_bytes_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])*8", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "trans {{device}} ", - "refId": "B", - "step": 240 - } - ], - "title": "Network Traffic Basic", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Disk space used of all filesystems mounted", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 40, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "percent" - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 12, - "x": 12, - "y": 13 - }, - "id": 152, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "100 - ((node_filesystem_avail_bytes{instance=\"$node\",job=\"$job\",device!~'rootfs'} * 100) / node_filesystem_size_bytes{instance=\"$node\",job=\"$job\",device!~'rootfs'})", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{mountpoint}}", - "refId": "A", - "step": 240 - } - ], - "title": "Disk Space Used Basic", - "type": "timeseries" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 20 - }, - "id": 265, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "percentage", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 70, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "smooth", - "lineWidth": 2, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "percent" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "percentunit" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Idle - Waiting for something to happen" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Iowait - Waiting for I/O to complete" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Irq - Servicing interrupts" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Nice - Niced processes executing in user mode" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Softirq - Servicing softirqs" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Steal - Time spent in other operating systems when running in a virtualized environment" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FCE2DE", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "System - Processes executing in kernel mode" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "User - Normal processes executing in user mode" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#5195CE", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 12, - "w": 12, - "x": 0, - "y": 21 - }, - "id": 3, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 250 - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"system\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "System - Processes executing in kernel mode", - "range": true, - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"user\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "User - Normal processes executing in user mode", - "range": true, - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"nice\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Nice - Niced processes executing in user mode", - "range": true, - "refId": "C", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum by(instance) (irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"iowait\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Iowait - Waiting for I/O to complete", - "range": true, - "refId": "E", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"irq\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Irq - Servicing interrupts", - "range": true, - "refId": "F", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"softirq\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Softirq - Servicing softirqs", - "range": true, - "refId": "G", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"steal\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Steal - Time spent in other operating systems when running in a virtualized environment", - "range": true, - "refId": "H", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"idle\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Idle - Waiting for something to happen", - "range": true, - "refId": "J", - "step": 240 - } - ], - "title": "CPU", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 40, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Apps" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#629E51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A437C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#CFFAFF", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap - Swap memory usage" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#2F575E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Unused" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Unused - Free memory unassigned" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*Hardware Corrupted - *./" - }, - "properties": [ - { - "id": "custom.stacking", - "value": { - "group": false, - "mode": "normal" - } - } - ] - } - ] - }, - "gridPos": { - "h": 12, - "w": 12, - "x": 12, - "y": 21 - }, - "id": 24, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 350 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_MemTotal_bytes{instance=\"$node\",job=\"$job\"} - node_memory_MemFree_bytes{instance=\"$node\",job=\"$job\"} - node_memory_Buffers_bytes{instance=\"$node\",job=\"$job\"} - node_memory_Cached_bytes{instance=\"$node\",job=\"$job\"} - node_memory_Slab_bytes{instance=\"$node\",job=\"$job\"} - node_memory_PageTables_bytes{instance=\"$node\",job=\"$job\"} - node_memory_SwapCached_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Apps - Memory used by user-space applications", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_PageTables_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "PageTables - Memory used to map between virtual and physical memory addresses", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_SwapCached_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "SwapCache - Memory that keeps track of pages that have been fetched from swap but not yet been modified", - "refId": "C", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Slab_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Slab - Memory used by the kernel to cache data structures for its own use (caches like inode, dentry, etc)", - "refId": "D", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Cached_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Cache - Parked file data (file content) cache", - "refId": "E", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Buffers_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Buffers - Block device (e.g. harddisk) cache", - "refId": "F", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_MemFree_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Unused - Free memory unassigned", - "refId": "G", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "(node_memory_SwapTotal_bytes{instance=\"$node\",job=\"$job\"} - node_memory_SwapFree_bytes{instance=\"$node\",job=\"$job\"})", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Swap - Swap space used", - "refId": "H", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_HardwareCorrupted_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working", - "refId": "I", - "step": 240 - } - ], - "title": "Memory Stack", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bits out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 40, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bps" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "receive_packets_eth0" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "receive_packets_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "transmit_packets_eth0" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "transmit_packets_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*Trans.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 12, - "w": 12, - "x": 0, - "y": 33 - }, - "id": 84, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_receive_bytes_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])*8", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Receive", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_transmit_bytes_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])*8", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Transmit", - "refId": "B", - "step": 240 - } - ], - "title": "Network Traffic", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 40, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 12, - "w": 12, - "x": 12, - "y": 33 - }, - "id": 156, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_filesystem_size_bytes{instance=\"$node\",job=\"$job\",device!~'rootfs'} - node_filesystem_avail_bytes{instance=\"$node\",job=\"$job\",device!~'rootfs'}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{mountpoint}}", - "refId": "A", - "step": 240 - } - ], - "title": "Disk Space Used", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "IO read (-) / write (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "iops" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Read.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda2_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BA43A9", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda3_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F4D598", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#962D82", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#9AC48A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#65C5DB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9934E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FCEACA", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9E2D2", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 12, - "w": 12, - "x": 0, - "y": 45 - }, - "id": 229, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_reads_completed_total{instance=\"$node\",job=\"$job\",device=~\"$diskdevices\"}[$__rate_interval])", - "intervalFactor": 4, - "legendFormat": "{{device}} - Reads completed", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_writes_completed_total{instance=\"$node\",job=\"$job\",device=~\"$diskdevices\"}[$__rate_interval])", - "intervalFactor": 1, - "legendFormat": "{{device}} - Writes completed", - "refId": "B", - "step": 240 - } - ], - "title": "Disk IOps", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes read (-) / write (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 40, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "Bps" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "io time" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*read*./" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byType", - "options": "time" - }, - "properties": [ - { - "id": "custom.axisPlacement", - "value": "hidden" - } - ] - } - ] - }, - "gridPos": { - "h": 12, - "w": 12, - "x": 12, - "y": 45 - }, - "id": 42, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_read_bytes_total{instance=\"$node\",job=\"$job\",device=~\"$diskdevices\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "{{device}} - Successfully read bytes", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_written_bytes_total{instance=\"$node\",job=\"$job\",device=~\"$diskdevices\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "{{device}} - Successfully written bytes", - "refId": "B", - "step": 240 - } - ], - "title": "I/O Usage Read / Write", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "%util", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 40, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "percentunit" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "io time" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byType", - "options": "time" - }, - "properties": [ - { - "id": "custom.axisPlacement", - "value": "hidden" - } - ] - } - ] - }, - "gridPos": { - "h": 12, - "w": 12, - "x": 0, - "y": 57 - }, - "id": 127, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_io_time_seconds_total{instance=\"$node\",job=\"$job\",device=~\"$diskdevices\"} [$__rate_interval])", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{device}}", - "refId": "A", - "step": 240 - } - ], - "title": "I/O Utilization", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "percentage", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "bars", - "fillOpacity": 70, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineWidth": 2, - "pointSize": 3, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "max": 1, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "percentunit" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/^Guest - /" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#5195ce", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/^GuestNice - /" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#c15c17", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 12, - "w": 12, - "x": 12, - "y": 57 - }, - "id": 319, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum by(instance) (irate(node_cpu_guest_seconds_total{instance=\"$node\",job=\"$job\", mode=\"user\"}[1m])) / on(instance) group_left sum by (instance)((irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}[1m])))", - "hide": false, - "legendFormat": "Guest - Time spent running a virtual CPU for a guest operating system", - "range": true, - "refId": "A" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum by(instance) (irate(node_cpu_guest_seconds_total{instance=\"$node\",job=\"$job\", mode=\"nice\"}[1m])) / on(instance) group_left sum by (instance)((irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}[1m])))", - "hide": false, - "legendFormat": "GuestNice - Time spent running a niced guest (virtual CPU for guest operating system)", - "range": true, - "refId": "B" - } - ], - "title": "CPU spent seconds in guests (VMs)", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "CPU / Memory / Net / Disk", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 21 - }, - "id": 266, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Apps" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#629E51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A437C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#CFFAFF", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#2F575E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Unused" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 22 - }, - "id": 136, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 350 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Inactive_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Inactive - Memory which has been less recently used. It is more eligible to be reclaimed for other purposes", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Active_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Active - Memory that has been used more recently and usually not reclaimed unless absolutely necessary", - "refId": "B", - "step": 240 - } - ], - "title": "Memory Active / Inactive", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Apps" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#629E51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A437C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#CFFAFF", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#2F575E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Unused" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*CommitLimit - *./" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 22 - }, - "id": 135, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 350 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Committed_AS_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Committed_AS - Amount of memory presently allocated on the system", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_CommitLimit_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "CommitLimit - Amount of memory currently available to be allocated on the system", - "refId": "B", - "step": 240 - } - ], - "title": "Memory Committed", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Apps" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#629E51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A437C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#CFFAFF", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#2F575E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Unused" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 32 - }, - "id": 191, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 350 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Inactive_file_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Inactive_file - File-backed memory on inactive LRU list", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Inactive_anon_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Inactive_anon - Anonymous and swap cache on inactive LRU list, including tmpfs (shmem)", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Active_file_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Active_file - File-backed memory on active LRU list", - "refId": "C", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Active_anon_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Active_anon - Anonymous and swap cache on active least-recently-used (LRU) list, including tmpfs", - "refId": "D", - "step": 240 - } - ], - "title": "Memory Active / Inactive Detail", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Active" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#99440A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#58140C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Dirty" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#B7DBAB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Mapped" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM + Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "VmallocUsed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 32 - }, - "id": 130, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Writeback_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Writeback - Memory which is actively being written back to disk", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_WritebackTmp_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "WritebackTmp - Memory used by FUSE for temporary writeback buffers", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Dirty_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Dirty - Memory which is waiting to get written back to the disk", - "refId": "C", - "step": 240 - } - ], - "title": "Memory Writeback and Dirty", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Apps" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#629E51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A437C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#CFFAFF", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#2F575E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Unused" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "ShmemHugePages - Memory used by shared memory (shmem) and tmpfs allocated with huge pages" - }, - "properties": [ - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "ShmemHugePages - Memory used by shared memory (shmem) and tmpfs allocated with huge pages" - }, - "properties": [ - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 42 - }, - "id": 138, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 350 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Mapped_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Mapped - Used memory in mapped pages files which have been mapped, such as libraries", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Shmem_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Shmem - Used shared memory (shared between several processes, thus including RAM disks)", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_ShmemHugePages_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "ShmemHugePages - Memory used by shared memory (shmem) and tmpfs allocated with huge pages", - "refId": "C", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_ShmemPmdMapped_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "ShmemPmdMapped - Amount of shared (shmem/tmpfs) memory backed by huge pages", - "refId": "D", - "step": 240 - } - ], - "title": "Memory Shared and Mapped", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Active" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#99440A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#58140C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Dirty" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#B7DBAB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Mapped" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM + Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "VmallocUsed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 42 - }, - "id": 131, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_SUnreclaim_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "SUnreclaim - Part of Slab, that cannot be reclaimed on memory pressure", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_SReclaimable_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "SReclaimable - Part of Slab, that might be reclaimed, such as caches", - "refId": "B", - "step": 240 - } - ], - "title": "Memory Slab", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Active" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#99440A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#58140C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Dirty" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#B7DBAB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Mapped" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM + Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "VmallocUsed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 52 - }, - "id": 70, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_VmallocChunk_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "VmallocChunk - Largest contiguous block of vmalloc area which is free", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_VmallocTotal_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "VmallocTotal - Total size of vmalloc memory area", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_VmallocUsed_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "VmallocUsed - Amount of vmalloc area which is used", - "refId": "C", - "step": 240 - } - ], - "title": "Memory Vmalloc", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Apps" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#629E51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A437C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#CFFAFF", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#2F575E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Unused" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 52 - }, - "id": 159, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 350 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Bounce_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Bounce - Memory used for block device bounce buffers", - "refId": "A", - "step": 240 - } - ], - "title": "Memory Bounce", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Active" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#99440A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#58140C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Dirty" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#B7DBAB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Mapped" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM + Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "VmallocUsed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*Inactive *./" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 62 - }, - "id": 129, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_AnonHugePages_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "AnonHugePages - Memory in anonymous huge pages", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_AnonPages_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "AnonPages - Memory in user pages not backed by files", - "refId": "B", - "step": 240 - } - ], - "title": "Memory Anonymous", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Apps" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#629E51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A437C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#CFFAFF", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#2F575E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Unused" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 62 - }, - "id": 160, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 350 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_KernelStack_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "KernelStack - Kernel memory stack. This is not reclaimable", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Percpu_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "PerCPU - Per CPU memory allocated dynamically by loadable modules", - "refId": "B", - "step": 240 - } - ], - "title": "Memory Kernel / CPU", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "pages", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Active" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#99440A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#58140C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Dirty" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#B7DBAB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Mapped" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM + Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "VmallocUsed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 72 - }, - "id": 140, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_HugePages_Free{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "HugePages_Free - Huge pages in the pool that are not yet allocated", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_HugePages_Rsvd{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "HugePages_Rsvd - Huge pages for which a commitment to allocate from the pool has been made, but no allocation has yet been made", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_HugePages_Surp{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "HugePages_Surp - Huge pages in the pool above the value in /proc/sys/vm/nr_hugepages", - "refId": "C", - "step": 240 - } - ], - "title": "Memory HugePages Counter", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Active" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#99440A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#58140C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Dirty" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#B7DBAB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Mapped" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM + Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "VmallocUsed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 72 - }, - "id": 71, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_HugePages_Total{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "HugePages - Total size of the pool of huge pages", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Hugepagesize_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Hugepagesize - Huge Page size", - "refId": "B", - "step": 240 - } - ], - "title": "Memory HugePages Size", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Active" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#99440A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#58140C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Dirty" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#B7DBAB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Mapped" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM + Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "VmallocUsed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 82 - }, - "id": 128, - "options": { - "legend": { - "calcs": [ - "mean", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_DirectMap1G_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "DirectMap1G - Amount of pages mapped as this size", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_DirectMap2M_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "DirectMap2M - Amount of pages mapped as this size", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_DirectMap4k_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "DirectMap4K - Amount of pages mapped as this size", - "refId": "C", - "step": 240 - } - ], - "title": "Memory DirectMap", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Apps" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#629E51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A437C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#CFFAFF", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#2F575E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Unused" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 82 - }, - "id": 137, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 350 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Unevictable_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Unevictable - Amount of unevictable memory that can't be swapped out for a variety of reasons", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Mlocked_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "MLocked - Size of pages locked to memory using the mlock() system call", - "refId": "B", - "step": 240 - } - ], - "title": "Memory Unevictable and MLocked", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Active" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#99440A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#58140C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Dirty" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#B7DBAB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Mapped" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM + Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "VmallocUsed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 92 - }, - "id": 132, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_NFS_Unstable_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "NFS Unstable - Memory in NFS pages sent to the server, but not yet committed to the storage", - "refId": "A", - "step": 240 - } - ], - "title": "Memory NFS", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "Memory Meminfo", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 22 - }, - "id": 267, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "pages out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*out/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 23 - }, - "id": 176, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_vmstat_pgpgin{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Pagesin - Page in operations", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_vmstat_pgpgout{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Pagesout - Page out operations", - "refId": "B", - "step": 240 - } - ], - "title": "Memory Pages In / Out", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "pages out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*out/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 23 - }, - "id": 22, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_vmstat_pswpin{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Pswpin - Pages swapped in", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_vmstat_pswpout{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Pswpout - Pages swapped out", - "refId": "B", - "step": 240 - } - ], - "title": "Memory Pages Swap In / Out", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "faults", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Apps" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#629E51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A437C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#CFFAFF", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#2F575E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Unused" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Pgfault - Page major and minor fault operations" - }, - "properties": [ - { - "id": "custom.fillOpacity", - "value": 0 - }, - { - "id": "custom.stacking", - "value": { - "group": false, - "mode": "normal" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 33 - }, - "id": 175, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 350 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_vmstat_pgfault{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Pgfault - Page major and minor fault operations", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_vmstat_pgmajfault{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Pgmajfault - Major page fault operations", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_vmstat_pgfault{instance=\"$node\",job=\"$job\"}[$__rate_interval]) - irate(node_vmstat_pgmajfault{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Pgminfault - Minor page fault operations", - "refId": "C", - "step": 240 - } - ], - "title": "Memory Page Faults", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Active" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#99440A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#58140C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Dirty" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#B7DBAB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Mapped" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM + Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "VmallocUsed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 33 - }, - "id": 307, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_vmstat_oom_kill{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "oom killer invocations ", - "refId": "A", - "step": 240 - } - ], - "title": "OOM Killer", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "Memory Vmstat", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 23 - }, - "id": 293, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "seconds", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "s" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Variation*./" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 24 - }, - "id": 260, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_timex_estimated_error_seconds{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "Estimated error in seconds", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_timex_offset_seconds{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "Time offset in between local system and reference clock", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_timex_maxerror_seconds{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "Maximum error in seconds", - "refId": "C", - "step": 240 - } - ], - "title": "Time Synchronized Drift", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 24 - }, - "id": 291, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_timex_loop_time_constant{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Phase-locked loop time adjust", - "refId": "A", - "step": 240 - } - ], - "title": "Time PLL Adjust", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Variation*./" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 34 - }, - "id": 168, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_timex_sync_status{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Is clock synchronized to a reliable server (1 = yes, 0 = no)", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_timex_frequency_adjustment_ratio{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Local clock frequency adjustment", - "refId": "B", - "step": 240 - } - ], - "title": "Time Synchronized Status", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "seconds", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 34 - }, - "id": 294, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_timex_tick_seconds{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Seconds between clock ticks", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_timex_tai_offset_seconds{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "International Atomic Time (TAI) offset", - "refId": "B", - "step": 240 - } - ], - "title": "Time Misc", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "System Timesync", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 24 - }, - "id": 312, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 73 - }, - "id": 62, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_procs_blocked{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Processes blocked waiting for I/O to complete", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_procs_running{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Processes in runnable state", - "refId": "B", - "step": 240 - } - ], - "title": "Processes Status", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Enable with --collector.processes argument on node-exporter", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 73 - }, - "id": 315, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_processes_state{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{ state }}", - "refId": "A", - "step": 240 - } - ], - "title": "Processes State", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "forks / sec", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 83 - }, - "id": 148, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_forks_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Processes forks second", - "refId": "A", - "step": 240 - } - ], - "title": "Processes Forks", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "decbytes" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Max.*/" - }, - "properties": [ - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 83 - }, - "id": 149, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(process_virtual_memory_bytes{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "Processes virtual memory size in bytes", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "process_resident_memory_max_bytes{instance=\"$node\",job=\"$job\"}", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "Maximum amount of virtual memory available in bytes", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(process_virtual_memory_bytes{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "Processes virtual memory size in bytes", - "refId": "C", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(process_virtual_memory_max_bytes{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "Maximum amount of virtual memory available in bytes", - "refId": "D", - "step": 240 - } - ], - "title": "Processes Memory", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Enable with --collector.processes argument on node-exporter", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "PIDs limit" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F2495C", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 93 - }, - "id": 313, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_processes_pids{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Number of PIDs", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_processes_max_processes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "PIDs limit", - "refId": "B", - "step": 240 - } - ], - "title": "PIDs Number and Limit", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "seconds", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "s" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*waiting.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 93 - }, - "id": 305, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_schedstat_running_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "CPU {{ cpu }} - seconds spent running a process", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_schedstat_waiting_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "CPU {{ cpu }} - seconds spent by processing waiting for this CPU", - "refId": "B", - "step": 240 - } - ], - "title": "Process schedule stats Running / Waiting", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Enable with --collector.processes argument on node-exporter", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Threads limit" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F2495C", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 103 - }, - "id": 314, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_processes_threads{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Allocated threads", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_processes_max_threads{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Threads limit", - "refId": "B", - "step": 240 - } - ], - "title": "Threads Number and Limit", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "System Processes", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 25 - }, - "id": 269, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 26 - }, - "id": 8, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_context_switches_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Context switches", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_intr_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Interrupts", - "refId": "B", - "step": 240 - } - ], - "title": "Context Switches / Interrupts", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 26 - }, - "id": 7, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_load1{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 4, - "legendFormat": "Load 1m", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_load5{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 4, - "legendFormat": "Load 5m", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_load15{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 4, - "legendFormat": "Load 15m", - "refId": "C", - "step": 240 - } - ], - "title": "System Load", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "hertz" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Max" - }, - "properties": [ - { - "id": "custom.lineStyle", - "value": { - "dash": [ - 10, - 10 - ], - "fill": "dash" - } - }, - { - "id": "color", - "value": { - "fixedColor": "blue", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 10 - }, - { - "id": "custom.hideFrom", - "value": { - "legend": true, - "tooltip": false, - "viz": false - } - }, - { - "id": "custom.fillBelowTo", - "value": "Min" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Min" - }, - "properties": [ - { - "id": "custom.lineStyle", - "value": { - "dash": [ - 10, - 10 - ], - "fill": "dash" - } - }, - { - "id": "color", - "value": { - "fixedColor": "blue", - "mode": "fixed" - } - }, - { - "id": "custom.hideFrom", - "value": { - "legend": true, - "tooltip": false, - "viz": false - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 36 - }, - "id": 321, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "node_cpu_scaling_frequency_hertz{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "CPU {{ cpu }}", - "range": true, - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "avg(node_cpu_scaling_frequency_max_hertz{instance=\"$node\",job=\"$job\"})", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "Max", - "range": true, - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "avg(node_cpu_scaling_frequency_min_hertz{instance=\"$node\",job=\"$job\"})", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "Min", - "range": true, - "refId": "C", - "step": 240 - } - ], - "title": "CPU Frequency Scaling", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "https://docs.kernel.org/accounting/psi.html", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "percentunit" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Memory some" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-red", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Memory full" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "light-red", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "I/O some" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-blue", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "I/O full" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "light-blue", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 36 - }, - "id": 322, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "rate(node_pressure_cpu_waiting_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "CPU some", - "range": true, - "refId": "CPU some", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "rate(node_pressure_memory_waiting_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Memory some", - "range": true, - "refId": "Memory some", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "rate(node_pressure_memory_stalled_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Memory full", - "range": true, - "refId": "Memory full", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "rate(node_pressure_io_waiting_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "I/O some", - "range": true, - "refId": "I/O some", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "rate(node_pressure_io_stalled_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "I/O full", - "range": true, - "refId": "I/O full", - "step": 240 - } - ], - "title": "Pressure Stall Information", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Enable with --collector.interrupts argument on node-exporter", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Critical*./" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*Max*./" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 46 - }, - "id": 259, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_interrupts_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{ type }} - {{ info }}", - "refId": "A", - "step": 240 - } - ], - "title": "Interrupts Detail", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 46 - }, - "id": 306, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_schedstat_timeslices_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "CPU {{ cpu }}", - "refId": "A", - "step": 240 - } - ], - "title": "Schedule timeslices executed by each cpu", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 56 - }, - "id": 151, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_entropy_available_bits{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Entropy available to random number generators", - "refId": "A", - "step": 240 - } - ], - "title": "Entropy", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "seconds", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 56 - }, - "id": 308, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(process_cpu_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Time spent", - "refId": "A", - "step": 240 - } - ], - "title": "CPU time spent in user and system contexts", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Max*./" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 66 - }, - "id": 64, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "process_max_fds{instance=\"$node\",job=\"$job\"}", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Maximum open file descriptors", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "process_open_fds{instance=\"$node\",job=\"$job\"}", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Open file descriptors", - "refId": "B", - "step": 240 - } - ], - "title": "File Descriptors", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "System Misc", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 26 - }, - "id": 304, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "temperature", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "celsius" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Critical*./" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*Max*./" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 59 - }, - "id": 158, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_hwmon_temp_celsius{instance=\"$node\",job=\"$job\"} * on(chip) group_left(chip_name) node_hwmon_chip_names{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{ chip_name }} {{ sensor }} temp", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_hwmon_temp_crit_alarm_celsius{instance=\"$node\",job=\"$job\"} * on(chip) group_left(chip_name) node_hwmon_chip_names{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": true, - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{ chip_name }} {{ sensor }} Critical Alarm", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_hwmon_temp_crit_celsius{instance=\"$node\",job=\"$job\"} * on(chip) group_left(chip_name) node_hwmon_chip_names{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{ chip_name }} {{ sensor }} Critical", - "refId": "C", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_hwmon_temp_crit_hyst_celsius{instance=\"$node\",job=\"$job\"} * on(chip) group_left(chip_name) node_hwmon_chip_names{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": true, - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{ chip_name }} {{ sensor }} Critical Historical", - "refId": "D", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_hwmon_temp_max_celsius{instance=\"$node\",job=\"$job\"} * on(chip) group_left(chip_name) node_hwmon_chip_names{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": true, - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{ chip_name }} {{ sensor }} Max", - "refId": "E", - "step": 240 - } - ], - "title": "Hardware temperature monitor", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Max*./" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 59 - }, - "id": 300, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_cooling_device_cur_state{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "Current {{ name }} in {{ type }}", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_cooling_device_max_state{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Max {{ name }} in {{ type }}", - "refId": "B", - "step": 240 - } - ], - "title": "Throttle cooling device", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 69 - }, - "id": 302, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_power_supply_online{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{ power_supply }} online", - "refId": "A", - "step": 240 - } - ], - "title": "Power supply", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "Hardware Misc", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 27 - }, - "id": 296, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 46 - }, - "id": 297, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_systemd_socket_accepted_connections_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{ name }} Connections", - "refId": "A", - "step": 240 - } - ], - "title": "Systemd Sockets", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Failed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F2495C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FF9830", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Active" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#73BF69", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Deactivating" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FFCB7D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Activating" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C8F2C2", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 46 - }, - "id": 298, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_systemd_units{instance=\"$node\",job=\"$job\",state=\"activating\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Activating", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_systemd_units{instance=\"$node\",job=\"$job\",state=\"active\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Active", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_systemd_units{instance=\"$node\",job=\"$job\",state=\"deactivating\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Deactivating", - "refId": "C", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_systemd_units{instance=\"$node\",job=\"$job\",state=\"failed\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Failed", - "refId": "D", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_systemd_units{instance=\"$node\",job=\"$job\",state=\"inactive\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Inactive", - "refId": "E", - "step": 240 - } - ], - "title": "Systemd Units State", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "Systemd", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 28 - }, - "id": 270, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "The number (after merges) of I/O requests completed per second for the device", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "IO read (-) / write (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "iops" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Read.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda2_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BA43A9", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda3_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F4D598", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#962D82", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#9AC48A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#65C5DB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9934E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FCEACA", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9E2D2", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 47 - }, - "id": 9, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_reads_completed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "intervalFactor": 4, - "legendFormat": "{{device}} - Reads completed", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_writes_completed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "intervalFactor": 1, - "legendFormat": "{{device}} - Writes completed", - "refId": "B", - "step": 240 - } - ], - "title": "Disk IOps Completed", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "The number of bytes read from or written to the device per second", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes read (-) / write (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "Bps" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Read.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda2_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BA43A9", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda3_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F4D598", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#962D82", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#9AC48A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#65C5DB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9934E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FCEACA", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9E2D2", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 47 - }, - "id": 33, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_read_bytes_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 4, - "legendFormat": "{{device}} - Read bytes", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_written_bytes_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Written bytes", - "refId": "B", - "step": 240 - } - ], - "title": "Disk R/W Data", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "The average time for requests issued to the device to be served. This includes the time spent by the requests in queue and the time spent servicing them.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "time. read (-) / write (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 30, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "s" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Read.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda2_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BA43A9", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda3_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F4D598", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#962D82", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#9AC48A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#65C5DB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9934E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FCEACA", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9E2D2", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 57 - }, - "id": 37, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_read_time_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval]) / irate(node_disk_reads_completed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "hide": false, - "interval": "", - "intervalFactor": 4, - "legendFormat": "{{device}} - Read wait time avg", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_write_time_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval]) / irate(node_disk_writes_completed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{device}} - Write wait time avg", - "refId": "B", - "step": 240 - } - ], - "title": "Disk Average Wait Time", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "The average queue length of the requests that were issued to the device", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "aqu-sz", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda2_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BA43A9", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda3_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F4D598", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#962D82", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#9AC48A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#65C5DB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9934E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FCEACA", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9E2D2", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 57 - }, - "id": 35, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_io_time_weighted_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "interval": "", - "intervalFactor": 4, - "legendFormat": "{{device}}", - "refId": "A", - "step": 240 - } - ], - "title": "Average Queue Size", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "The number of read and write requests merged per second that were queued to the device", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "I/Os", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "iops" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Read.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda2_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BA43A9", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda3_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F4D598", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#962D82", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#9AC48A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#65C5DB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9934E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FCEACA", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9E2D2", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 67 - }, - "id": 133, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_reads_merged_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "intervalFactor": 1, - "legendFormat": "{{device}} - Read merged", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_writes_merged_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "intervalFactor": 1, - "legendFormat": "{{device}} - Write merged", - "refId": "B", - "step": 240 - } - ], - "title": "Disk R/W Merged", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Percentage of elapsed time during which I/O requests were issued to the device (bandwidth utilization for the device). Device saturation occurs when this value is close to 100% for devices serving requests serially. But for devices serving requests in parallel, such as RAID arrays and modern SSDs, this number does not reflect their performance limits.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "%util", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 30, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "percentunit" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda2_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BA43A9", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda3_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F4D598", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#962D82", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#9AC48A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#65C5DB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9934E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FCEACA", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9E2D2", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 67 - }, - "id": 36, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_io_time_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "interval": "", - "intervalFactor": 4, - "legendFormat": "{{device}} - IO", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_discard_time_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "interval": "", - "intervalFactor": 4, - "legendFormat": "{{device}} - discard", - "refId": "B", - "step": 240 - } - ], - "title": "Time Spent Doing I/Os", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "The number of outstanding requests at the instant the sample was taken. Incremented as requests are given to appropriate struct request_queue and decremented as they finish.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "Outstanding req.", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda2_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BA43A9", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda3_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F4D598", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#962D82", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#9AC48A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#65C5DB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9934E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FCEACA", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9E2D2", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 77 - }, - "id": 34, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_disk_io_now{instance=\"$node\",job=\"$job\"}", - "interval": "", - "intervalFactor": 4, - "legendFormat": "{{device}} - IO now", - "refId": "A", - "step": 240 - } - ], - "title": "Instantaneous Queue Size", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "IOs", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "iops" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda2_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BA43A9", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda3_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F4D598", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#962D82", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#9AC48A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#65C5DB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9934E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FCEACA", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9E2D2", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 77 - }, - "id": 301, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_discards_completed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "interval": "", - "intervalFactor": 4, - "legendFormat": "{{device}} - Discards completed", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_discards_merged_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{device}} - Discards merged", - "refId": "B", - "step": 240 - } - ], - "title": "Disk IOps Discards completed / merged", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "Storage Disk", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 29 - }, - "id": 271, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 62 - }, - "id": 43, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_filesystem_avail_bytes{instance=\"$node\",job=\"$job\",device!~'rootfs'}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "{{mountpoint}} - Available", - "metric": "", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_filesystem_free_bytes{instance=\"$node\",job=\"$job\",device!~'rootfs'}", - "format": "time_series", - "hide": true, - "intervalFactor": 1, - "legendFormat": "{{mountpoint}} - Free", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_filesystem_size_bytes{instance=\"$node\",job=\"$job\",device!~'rootfs'}", - "format": "time_series", - "hide": true, - "intervalFactor": 1, - "legendFormat": "{{mountpoint}} - Size", - "refId": "C", - "step": 240 - } - ], - "title": "Filesystem space available", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "file nodes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 62 - }, - "id": 41, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_filesystem_files_free{instance=\"$node\",job=\"$job\",device!~'rootfs'}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "{{mountpoint}} - Free file nodes", - "refId": "A", - "step": 240 - } - ], - "title": "File Nodes Free", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "files", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 72 - }, - "id": 28, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_filefd_maximum{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 4, - "legendFormat": "Max open files", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_filefd_allocated{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Open files", - "refId": "B", - "step": 240 - } - ], - "title": "File Descriptor", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "file Nodes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 72 - }, - "id": 219, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_filesystem_files{instance=\"$node\",job=\"$job\",device!~'rootfs'}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "{{mountpoint}} - File nodes total", - "refId": "A", - "step": 240 - } - ], - "title": "File Nodes Size", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "max": 1, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "/ ReadOnly" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 82 - }, - "id": 44, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_filesystem_readonly{instance=\"$node\",job=\"$job\",device!~'rootfs'}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{mountpoint}} - ReadOnly", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_filesystem_device_error{instance=\"$node\",job=\"$job\",device!~'rootfs',fstype!~'tmpfs'}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{mountpoint}} - Device error", - "refId": "B", - "step": 240 - } - ], - "title": "Filesystem in ReadOnly / Error", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "Storage Filesystem", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 30 - }, - "id": 272, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "packets out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "pps" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "receive_packets_eth0" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "receive_packets_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "transmit_packets_eth0" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "transmit_packets_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*Trans.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 47 - }, - "id": 60, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_receive_packets_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{device}} - Receive", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_transmit_packets_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{device}} - Transmit", - "refId": "B", - "step": 240 - } - ], - "title": "Network Traffic by Packets", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "packets out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "pps" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Trans.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 47 - }, - "id": 142, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_receive_errs_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Receive errors", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_transmit_errs_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Transmit errors", - "refId": "B", - "step": 240 - } - ], - "title": "Network Traffic Errors", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "packets out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "pps" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Trans.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 57 - }, - "id": 143, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_receive_drop_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Receive drop", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_transmit_drop_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Transmit drop", - "refId": "B", - "step": 240 - } - ], - "title": "Network Traffic Drop", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "packets out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "pps" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Trans.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 57 - }, - "id": 141, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_receive_compressed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Receive compressed", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_transmit_compressed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Transmit compressed", - "refId": "B", - "step": 240 - } - ], - "title": "Network Traffic Compressed", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "packets out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "pps" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Trans.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 67 - }, - "id": 146, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_receive_multicast_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Receive multicast", - "refId": "A", - "step": 240 - } - ], - "title": "Network Traffic Multicast", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "packets out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "pps" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Trans.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 67 - }, - "id": 144, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_receive_fifo_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Receive fifo", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_transmit_fifo_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Transmit fifo", - "refId": "B", - "step": 240 - } - ], - "title": "Network Traffic Fifo", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "packets out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "pps" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Trans.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 77 - }, - "id": 145, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_receive_frame_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "{{device}} - Receive frame", - "refId": "A", - "step": 240 - } - ], - "title": "Network Traffic Frame", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 77 - }, - "id": 231, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_transmit_carrier_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Statistic transmit_carrier", - "refId": "A", - "step": 240 - } - ], - "title": "Network Traffic Carrier", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Trans.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 87 - }, - "id": 232, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_transmit_colls_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Transmit colls", - "refId": "A", - "step": 240 - } - ], - "title": "Network Traffic Colls", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "entries", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "NF conntrack limit" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 87 - }, - "id": 61, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_nf_conntrack_entries{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "NF conntrack entries", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_nf_conntrack_entries_limit{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "NF conntrack limit", - "refId": "B", - "step": 240 - } - ], - "title": "NF Conntrack", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "Entries", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 97 - }, - "id": 230, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_arp_entries{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{ device }} - ARP entries", - "refId": "A", - "step": 240 - } - ], - "title": "ARP Entries", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "decimals": 0, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 97 - }, - "id": 288, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_network_mtu_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{ device }} - Bytes", - "refId": "A", - "step": 240 - } - ], - "title": "MTU", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "decimals": 0, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 107 - }, - "id": 280, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_network_speed_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{ device }} - Speed", - "refId": "A", - "step": 240 - } - ], - "title": "Speed", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "packets", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "decimals": 0, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 107 - }, - "id": 289, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_network_transmit_queue_length{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{ device }} - Interface transmit queue length", - "refId": "A", - "step": 240 - } - ], - "title": "Queue Length", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "packetes drop (-) / process (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Dropped.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 117 - }, - "id": 290, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_softnet_processed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "CPU {{cpu}} - Processed", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_softnet_dropped_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "CPU {{cpu}} - Dropped", - "refId": "B", - "step": 240 - } - ], - "title": "Softnet Packets", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 117 - }, - "id": 310, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_softnet_times_squeezed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "CPU {{cpu}} - Squeezed", - "refId": "A", - "step": 240 - } - ], - "title": "Softnet Out of Quota", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 127 - }, - "id": 309, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_network_up{operstate=\"up\",instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{interface}} - Operational state UP", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_network_carrier{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "instant": false, - "legendFormat": "{{device}} - Physical link state", - "refId": "B" - } - ], - "title": "Network Operational Status", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "Network Traffic", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 31 - }, - "id": 273, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 48 - }, - "id": 63, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_TCP_alloc{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "TCP_alloc - Allocated sockets", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_TCP_inuse{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "TCP_inuse - Tcp sockets currently in use", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_TCP_mem{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": true, - "interval": "", - "intervalFactor": 1, - "legendFormat": "TCP_mem - Used memory for tcp", - "refId": "C", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_TCP_orphan{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "TCP_orphan - Orphan sockets", - "refId": "D", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_TCP_tw{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "TCP_tw - Sockets waiting close", - "refId": "E", - "step": 240 - } - ], - "title": "Sockstat TCP", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 48 - }, - "id": 124, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_UDPLITE_inuse{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "UDPLITE_inuse - Udplite sockets currently in use", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_UDP_inuse{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "UDP_inuse - Udp sockets currently in use", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_UDP_mem{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "UDP_mem - Used memory for udp", - "refId": "C", - "step": 240 - } - ], - "title": "Sockstat UDP", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 58 - }, - "id": 125, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_FRAG_inuse{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "FRAG_inuse - Frag sockets currently in use", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_RAW_inuse{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "RAW_inuse - Raw sockets currently in use", - "refId": "C", - "step": 240 - } - ], - "title": "Sockstat FRAG / RAW", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 58 - }, - "id": 220, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_TCP_mem_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "mem_bytes - TCP sockets in that state", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_UDP_mem_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "mem_bytes - UDP sockets in that state", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_FRAG_memory{instance=\"$node\",job=\"$job\"}", - "interval": "", - "intervalFactor": 1, - "legendFormat": "FRAG_memory - Used memory for frag", - "refId": "C" - } - ], - "title": "Sockstat Memory Size", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "sockets", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 68 - }, - "id": 126, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_sockets_used{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Sockets_used - Sockets currently in use", - "refId": "A", - "step": 240 - } - ], - "title": "Sockstat Used", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "Network Sockstat", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 32 - }, - "id": 274, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "octets out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Out.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 33 - }, - "id": 221, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_IpExt_InOctets{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "InOctets - Received octets", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_IpExt_OutOctets{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "OutOctets - Sent octets", - "refId": "B", - "step": 240 - } - ], - "title": "Netstat IP In / Out Octets", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "datagrams", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 33 - }, - "id": 81, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Ip_Forwarding{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Forwarding - IP forwarding", - "refId": "A", - "step": 240 - } - ], - "title": "Netstat IP Forwarding", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "messages out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Out.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 43 - }, - "id": 115, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Icmp_InMsgs{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "InMsgs - Messages which the entity received. Note that this counter includes all those counted by icmpInErrors", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Icmp_OutMsgs{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "OutMsgs - Messages which this entity attempted to send. Note that this counter includes all those counted by icmpOutErrors", - "refId": "B", - "step": 240 - } - ], - "title": "ICMP In / Out", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "messages out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Out.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 43 - }, - "id": 50, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Icmp_InErrors{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "InErrors - Messages which the entity received but determined as having ICMP-specific errors (bad ICMP checksums, bad length, etc.)", - "refId": "A", - "step": 240 - } - ], - "title": "ICMP Errors", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "datagrams out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Out.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*Snd.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 53 - }, - "id": 55, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Udp_InDatagrams{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "InDatagrams - Datagrams received", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Udp_OutDatagrams{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "OutDatagrams - Datagrams sent", - "refId": "B", - "step": 240 - } - ], - "title": "UDP In / Out", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "datagrams", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 53 - }, - "id": 109, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Udp_InErrors{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "InErrors - UDP Datagrams that could not be delivered to an application", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Udp_NoPorts{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "NoPorts - UDP Datagrams received on a port with no listener", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_UdpLite_InErrors{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "interval": "", - "legendFormat": "InErrors Lite - UDPLite Datagrams that could not be delivered to an application", - "refId": "C" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Udp_RcvbufErrors{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "RcvbufErrors - UDP buffer errors received", - "refId": "D", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Udp_SndbufErrors{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "SndbufErrors - UDP buffer errors send", - "refId": "E", - "step": 240 - } - ], - "title": "UDP Errors", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "datagrams out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Out.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*Snd.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 63 - }, - "id": 299, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Tcp_InSegs{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "instant": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "InSegs - Segments received, including those received in error. This count includes segments received on currently established connections", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Tcp_OutSegs{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "OutSegs - Segments sent, including those on current connections but excluding those containing only retransmitted octets", - "refId": "B", - "step": 240 - } - ], - "title": "TCP In / Out", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 63 - }, - "id": 104, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_TcpExt_ListenOverflows{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "ListenOverflows - Times the listen queue of a socket overflowed", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_TcpExt_ListenDrops{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "ListenDrops - SYNs to LISTEN sockets ignored", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_TcpExt_TCPSynRetrans{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "TCPSynRetrans - SYN-SYN/ACK retransmits to break down retransmissions in SYN, fast/timeout retransmits", - "refId": "C", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Tcp_RetransSegs{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "interval": "", - "legendFormat": "RetransSegs - Segments retransmitted - that is, the number of TCP segments transmitted containing one or more previously transmitted octets", - "refId": "D" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Tcp_InErrs{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "interval": "", - "legendFormat": "InErrs - Segments received in error (e.g., bad TCP checksums)", - "refId": "E" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Tcp_OutRsts{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "interval": "", - "legendFormat": "OutRsts - Segments sent with RST flag", - "refId": "F" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "irate(node_netstat_TcpExt_TCPRcvQDrop{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "hide": false, - "interval": "", - "legendFormat": "TCPRcvQDrop - Packets meant to be queued in rcv queue but dropped because socket rcvbuf limit hit", - "range": true, - "refId": "G" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "irate(node_netstat_TcpExt_TCPOFOQueue{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "hide": false, - "interval": "", - "legendFormat": "TCPOFOQueue - TCP layer receives an out of order packet and has enough memory to queue it", - "range": true, - "refId": "H" - } - ], - "title": "TCP Errors", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "connections", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*MaxConn *./" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 73 - }, - "id": 85, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_netstat_Tcp_CurrEstab{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "CurrEstab - TCP connections for which the current state is either ESTABLISHED or CLOSE- WAIT", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_netstat_Tcp_MaxConn{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "MaxConn - Limit on the total number of TCP connections the entity can support (Dynamic is \"-1\")", - "refId": "B", - "step": 240 - } - ], - "title": "TCP Connections", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Sent.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 73 - }, - "id": 91, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_TcpExt_SyncookiesFailed{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "SyncookiesFailed - Invalid SYN cookies received", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_TcpExt_SyncookiesRecv{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "SyncookiesRecv - SYN cookies received", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_TcpExt_SyncookiesSent{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "SyncookiesSent - SYN cookies sent", - "refId": "C", - "step": 240 - } - ], - "title": "TCP SynCookie", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "connections", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 83 - }, - "id": 82, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Tcp_ActiveOpens{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "ActiveOpens - TCP connections that have made a direct transition to the SYN-SENT state from the CLOSED state", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Tcp_PassiveOpens{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "PassiveOpens - TCP connections that have made a direct transition to the SYN-RCVD state from the LISTEN state", - "refId": "B", - "step": 240 - } - ], - "title": "TCP Direct Transition", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Enable with --collector.tcpstat argument on node-exporter", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "connections", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 83 - }, - "id": 320, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "node_tcp_connection_states{state=\"established\",instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "established - TCP sockets in established state", - "range": true, - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "node_tcp_connection_states{state=\"fin_wait2\",instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "fin_wait2 - TCP sockets in fin_wait2 state", - "range": true, - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "node_tcp_connection_states{state=\"listen\",instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "listen - TCP sockets in listen state", - "range": true, - "refId": "C", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "node_tcp_connection_states{state=\"time_wait\",instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "time_wait - TCP sockets in time_wait state", - "range": true, - "refId": "D", - "step": 240 - } - ], - "title": "TCP Stat", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "Network Netstat", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 33 - }, - "id": 279, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "seconds", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 66 - }, - "id": 40, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_scrape_collector_duration_seconds{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{collector}} - Scrape duration", - "refId": "A", - "step": 240 - } - ], - "title": "Node Exporter Scrape Time", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineStyle": { - "fill": "solid" - }, - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*error.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F2495C", - "mode": "fixed" - } - }, - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 66 - }, - "id": 157, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_scrape_collector_success{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{collector}} - Scrape success", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_textfile_scrape_error{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{collector}} - Scrape textfile error (1 = true)", - "refId": "B", - "step": 240 - } - ], - "title": "Node Exporter Scrape", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "Node Exporter", - "type": "row" - } - ], - "refresh": "1m", - "revision": 1, - "schemaVersion": 39, - "tags": [ - "linux" - ], - "templating": { - "list": [ - { - "current": { - "selected": false, - "text": "default", - "value": "default" - }, - "hide": 0, - "includeAll": false, - "label": "Datasource", - "multi": false, - "name": "datasource", - "options": [], - "query": "prometheus", - "queryValue": "", - "refresh": 1, - "regex": "", - "skipUrlSync": false, - "type": "datasource" - }, - { - "current": { - "selected": false, - "text": "node-exporter", - "value": "node-exporter" - }, - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "definition": "", - "hide": 0, - "includeAll": false, - "label": "Job", - "multi": false, - "name": "job", - "options": [], - "query": { - "query": "label_values(node_uname_info, job)", - "refId": "Prometheus-job-Variable-Query" - }, - "refresh": 1, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "tagValuesQuery": "", - "tagsQuery": "", - "type": "query", - "useTags": false - }, - { - "current": { - "selected": false, - "text": "node_exporter:9100", - "value": "node_exporter:9100" - }, - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "definition": "label_values(node_uname_info{job=\"$job\"}, instance)", - "hide": 0, - "includeAll": false, - "label": "Host", - "multi": false, - "name": "node", - "options": [], - "query": { - "query": "label_values(node_uname_info{job=\"$job\"}, instance)", - "refId": "Prometheus-node-Variable-Query" - }, - "refresh": 1, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "tagValuesQuery": "", - "tagsQuery": "", - "type": "query", - "useTags": false - }, - { - "current": { - "selected": false, - "text": "[a-z]+|nvme[0-9]+n[0-9]+|mmcblk[0-9]+", - "value": "[a-z]+|nvme[0-9]+n[0-9]+|mmcblk[0-9]+" - }, - "hide": 2, - "includeAll": false, - "multi": false, - "name": "diskdevices", - "options": [ - { - "selected": true, - "text": "[a-z]+|nvme[0-9]+n[0-9]+|mmcblk[0-9]+", - "value": "[a-z]+|nvme[0-9]+n[0-9]+|mmcblk[0-9]+" - } - ], - "query": "[a-z]+|nvme[0-9]+n[0-9]+|mmcblk[0-9]+", - "skipUrlSync": false, - "type": "custom" - } - ] - }, - "time": { - "from": "now-15m", - "to": "now" - }, - "timepicker": { - "refresh_intervals": [ - "5s", - "10s", - "30s", - "1m", - "5m", - "15m", - "30m", - "1h", - "2h", - "1d" - ], - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ] - }, - "timezone": "browser", - "title": "Node Exporter Full", - "uid": "rYdddlPWk", - "version": 3, - "weekStart": "" -} \ No newline at end of file diff --git a/compose/jaeger/jaeger-ui.json b/compose/jaeger/jaeger-ui.json deleted file mode 100644 index 0f06f2fcda..0000000000 --- a/compose/jaeger/jaeger-ui.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "monitor": { - "menuEnabled": true - }, - "dependencies": { - "menuEnabled": true - } - } \ No newline at end of file diff --git a/compose/loki/loki.yml b/compose/loki/loki.yml deleted file mode 100644 index a63d16c7ff..0000000000 --- a/compose/loki/loki.yml +++ /dev/null @@ -1,44 +0,0 @@ -auth_enabled: false - -limits_config: - allow_structured_metadata: true - -server: - http_listen_port: 3100 - grpc_listen_port: 9096 - -common: - instance_addr: localhost - path_prefix: /tmp/loki - storage: - filesystem: - chunks_directory: /tmp/loki/chunks - rules_directory: /tmp/loki/rules - replication_factor: 1 - ring: - kvstore: - store: inmemory - -query_range: - results_cache: - cache: - embedded_cache: - enabled: true - max_size_mb: 100 - -schema_config: - configs: - - from: 2020-10-24 - store: tsdb - object_store: filesystem - schema: v13 - index: - prefix: index_ - period: 24h - -storage_config: - boltdb: - directory: /tmp/loki/index - - filesystem: - directory: /tmp/loki/chunks diff --git a/compose/otel-collector/otel-config.yaml b/compose/otel-collector/otel-config.yaml deleted file mode 100644 index 191edae04c..0000000000 --- a/compose/otel-collector/otel-config.yaml +++ /dev/null @@ -1,78 +0,0 @@ -extensions: - health_check: - zpages: - endpoint: 0.0.0.0:55679 - -receivers: - otlp: - protocols: - grpc: - endpoint: 0.0.0.0:4317 - http: - endpoint: 0.0.0.0:4318 - zipkin: - endpoint: 0.0.0.0:9411 - -processors: - batch: - - resource: - attributes: - - action: insert - key: service_name - from_attribute: service.name - - action: insert - key: loki.resource.labels - value: service_name - -exporters: - debug: - verbosity: detailed - file/traces: - path: /log/otel/traces.log - file/metrics: - path: /log/otel/metrics.log - file/logs: - path: /log/otel/logs.log - otlp: - endpoint: "${JAEGER_ENDPOINT}" - tls: - insecure: true - prometheus: - endpoint: "0.0.0.0:8889" - otlphttp: - endpoint: "http://loki:3100/otlp" - -service: - telemetry: - logs: - level: debug - pipelines: - traces: - receivers: - - otlp - - zipkin - processors: [batch] - exporters: - - debug - - file/traces - - otlp - metrics: - receivers: - - otlp - processors: [batch] - exporters: - - debug - - file/metrics - - prometheus - logs: - receivers: - - otlp - processors: [batch, resource] - exporters: - - debug - - file/logs - - otlphttp - extensions: - - health_check - - zpages \ No newline at end of file diff --git a/compose/prometheus/prometheus.yml b/compose/prometheus/prometheus.yml deleted file mode 100644 index 647cfda1af..0000000000 --- a/compose/prometheus/prometheus.yml +++ /dev/null @@ -1,24 +0,0 @@ -global: - scrape_interval: 10s - -scrape_configs: - - job_name: 'fullstackhero.api' - static_configs: - - targets: ['host.docker.internal:5000'] - - - job_name: otel - static_configs: - - targets: - - 'otel-collector:8889' - - - job_name: otel-collector - static_configs: - - targets: - - 'otel-collector:8888' - - - job_name: 'node-exporter' - # Override the global default and scrape targets from this job every 5 seconds. - scrape_interval: 5s - static_configs: - - targets: - - 'node_exporter:9100' \ No newline at end of file diff --git a/coverage/report/Summary.txt b/coverage/report/Summary.txt new file mode 100644 index 0000000000..041c4f5e7a --- /dev/null +++ b/coverage/report/Summary.txt @@ -0,0 +1,485 @@ +Summary + Generated on: 08-Jan-26 - 3:06:51 PM + Coverage date: 08-Jan-26 - 2:12:06 PM - 08-Jan-26 - 3:05:50 PM + Parser: MultiReport (10x Cobertura) + Assemblies: 16 + Classes: 433 + Files: 413 + Line coverage: 3.2% + Covered lines: 520 + Uncovered lines: 15635 + Coverable lines: 16155 + Total lines: 25523 + Branch coverage: 3.6% (127 of 3475) + Covered branches: 127 + Total branches: 3475 + Method coverage: 11.4% (194 of 1695) + Full method coverage: 11.3% (192 of 1695) + Covered methods: 194 + Fully covered methods: 192 + Total methods: 1695 + +FSH.Framework.Caching 0% + FSH.Framework.Caching.CacheServiceExtensions 0% + FSH.Framework.Caching.CachingOptions 0% + FSH.Framework.Caching.DistributedCacheService 0% + FSH.Framework.Caching.Extensions 0% + FSH.Framework.Caching.HybridCacheService 0% + +FSH.Framework.Core 13.3% + FSH.Framework.Core.Domain.BaseEntity 40% + FSH.Framework.Core.Domain.DomainEvent 0% + FSH.Framework.Core.Exceptions.CustomException 33.3% + FSH.Framework.Core.Exceptions.ForbiddenException 0% + FSH.Framework.Core.Exceptions.NotFoundException 0% + FSH.Framework.Core.Exceptions.UnauthorizedException 0% + +FSH.Framework.Eventing 0% + FSH.Framework.Eventing.EventingOptions 0% + FSH.Framework.Eventing.Inbox.EfCoreInboxStore 0% + FSH.Framework.Eventing.Inbox.InboxMessage 0% + FSH.Framework.Eventing.Inbox.InboxMessageConfiguration 0% + FSH.Framework.Eventing.InMemory.InMemoryEventBus 0% + FSH.Framework.Eventing.Outbox.EfCoreOutboxStore 0% + FSH.Framework.Eventing.Outbox.OutboxDispatcher 0% + FSH.Framework.Eventing.Outbox.OutboxMessage 0% + FSH.Framework.Eventing.Outbox.OutboxMessageConfiguration 0% + FSH.Framework.Eventing.Serialization.JsonEventSerializer 0% + FSH.Framework.Eventing.ServiceCollectionExtensions 0% + +FSH.Framework.Jobs 0% + FSH.Framework.Jobs.BasicAuthenticationTokens 0% + FSH.Framework.Jobs.Extensions 0% + FSH.Framework.Jobs.FshJobActivator 0% + FSH.Framework.Jobs.FshJobFilter 0% + FSH.Framework.Jobs.HangfireCustomBasicAuthenticationFilter 0% + FSH.Framework.Jobs.HangfireOptions 0% + FSH.Framework.Jobs.HangfireTelemetryFilter 0% + FSH.Framework.Jobs.LogJobFilter 0% + FSH.Framework.Jobs.Services.HangfireService 0% + +FSH.Framework.Mailing 0% + FSH.Framework.Mailing.Extensions 0% + FSH.Framework.Mailing.MailOptions 0% + FSH.Framework.Mailing.MailRequest 0% + FSH.Framework.Mailing.Services.SmtpMailService 0% + +FSH.Framework.Persistence 0% + FSH.Framework.Persistence.ConnectionStringValidator 0% + FSH.Framework.Persistence.Context.BaseDbContext 0% + FSH.Framework.Persistence.DatabaseOptionsStartupLogger 0% + FSH.Framework.Persistence.Extensions 0% + FSH.Framework.Persistence.Inteceptors.DomainEventsInterceptor 0% + FSH.Framework.Persistence.ModelBuilderExtensions 0% + FSH.Framework.Persistence.OptionsBuilderExtensions 0% + FSH.Framework.Persistence.OrderExpression 0% + FSH.Framework.Persistence.PaginationExtensions 0% + FSH.Framework.Persistence.Specification 0% + FSH.Framework.Persistence.SpecificationEvaluator 0% + FSH.Framework.Persistence.SpecificationExtensions 0% + FSH.Framework.Persistence.Specifications.Specification 0% + +FSH.Framework.Shared 2.6% + FSH.Framework.Shared.Auditing.AuditSensitiveAttribute 0% + FSH.Framework.Shared.Constants.FshPermission 0% + FSH.Framework.Shared.Constants.PermissionConstants 0% + FSH.Framework.Shared.Constants.RoleConstants 0% + FSH.Framework.Shared.Identity.Authorization.EndpointExtensions 0% + FSH.Framework.Shared.Identity.Authorization.RequiredPermissionAttribute 0% + FSH.Framework.Shared.Identity.Claims.ClaimsPrincipalExtensions 22.2% + FSH.Framework.Shared.Multitenancy.AppTenantInfo 0% + FSH.Framework.Shared.Persistence.DatabaseOptions 0% + FSH.Framework.Shared.Persistence.PagedResponse 0% + +FSH.Framework.Storage 0% + FSH.Framework.Storage.DTOs.FileUploadRequest 0% + FSH.Framework.Storage.Extensions 0% + FSH.Framework.Storage.FileTypeMetadata 0% + FSH.Framework.Storage.FileValidationRules 0% + FSH.Framework.Storage.Local.LocalStorageService 0% + FSH.Framework.Storage.S3.S3StorageOptions 0% + FSH.Framework.Storage.S3.S3StorageService 0% + +FSH.Framework.Web 0.3% + FSH.Framework.Web.Auth.CurrentUserMiddleware 0% + FSH.Framework.Web.Cors.CorsOptions 0% + FSH.Framework.Web.Cors.Extensions 0% + FSH.Framework.Web.Exceptions.GlobalExceptionHandler 0% + FSH.Framework.Web.Extensions 0% + FSH.Framework.Web.FshPipelineOptions 0% + FSH.Framework.Web.FshPlatformOptions 0% + FSH.Framework.Web.Health.HealthEndpoints 0% + FSH.Framework.Web.Mediator.Behaviors.ValidationBehavior 0% + FSH.Framework.Web.Mediator.Extensions 0% + FSH.Framework.Web.Modules.FshModuleAttribute 71.4% + FSH.Framework.Web.Modules.ModuleLoader 0% + FSH.Framework.Web.Observability.Logging.Serilog.Extensions 0% + FSH.Framework.Web.Observability.Logging.Serilog.HttpRequestContextEnricher 0% + FSH.Framework.Web.Observability.Logging.Serilog.StaticLogger 0% + FSH.Framework.Web.Observability.OpenTelemetry.Extensions 0% + FSH.Framework.Web.Observability.OpenTelemetry.MediatorTracingBehavior 0% + FSH.Framework.Web.Observability.OpenTelemetry.OpenTelemetryOptions 0% + FSH.Framework.Web.OpenApi.BearerSecuritySchemeTransformer 0% + FSH.Framework.Web.OpenApi.Extensions 0% + FSH.Framework.Web.OpenApi.OpenApiOptions 0% + FSH.Framework.Web.Origin.OriginOptions 0% + FSH.Framework.Web.RateLimiting.Extensions 0% + FSH.Framework.Web.RateLimiting.FixedWindowPolicyOptions 0% + FSH.Framework.Web.RateLimiting.RateLimitingOptions 0% + FSH.Framework.Web.Security.SecurityExtensions 0% + FSH.Framework.Web.Security.SecurityHeadersMiddleware 0% + FSH.Framework.Web.Security.SecurityHeadersOptions 0% + FSH.Framework.Web.Versioning.Extensions 0% + Microsoft.AspNetCore.OpenApi.Generated 0% + System.Runtime.CompilerServices 0% + +FSH.Modules.Auditing 4.9% + FSH.Modules.Auditing.Audit 0% + FSH.Modules.Auditing.AuditBackgroundWorker 0% + FSH.Modules.Auditing.AuditHttpMiddleware 0% + FSH.Modules.Auditing.AuditingConfigurator 0% + FSH.Modules.Auditing.AuditingModule 0% + FSH.Modules.Auditing.AuditRecord 0% + FSH.Modules.Auditing.ChannelAuditPublisher 0% + FSH.Modules.Auditing.ContentTypeHelper 62.5% + FSH.Modules.Auditing.DefaultAuditClient 0% + FSH.Modules.Auditing.DefaultAuditScope 0% + FSH.Modules.Auditing.Features.v1.GetAuditById.GetAuditByIdEndpoint 0% + FSH.Modules.Auditing.Features.v1.GetAuditById.GetAuditByIdQueryHandler 0% + FSH.Modules.Auditing.Features.v1.GetAudits.GetAuditsEndpoint 0% + FSH.Modules.Auditing.Features.v1.GetAudits.GetAuditsQueryHandler 0% + FSH.Modules.Auditing.Features.v1.GetAudits.GetAuditsQueryValidator 100% + FSH.Modules.Auditing.Features.v1.GetAuditsByCorrelation.GetAuditsByCorrelationEndpoint 0% + FSH.Modules.Auditing.Features.v1.GetAuditsByCorrelation.GetAuditsByCorrelationQueryHandler 0% + FSH.Modules.Auditing.Features.v1.GetAuditsByCorrelation.GetAuditsByCorrelationQueryValidator 100% + FSH.Modules.Auditing.Features.v1.GetAuditsByTrace.GetAuditsByTraceEndpoint 0% + FSH.Modules.Auditing.Features.v1.GetAuditsByTrace.GetAuditsByTraceQueryHandler 0% + FSH.Modules.Auditing.Features.v1.GetAuditsByTrace.GetAuditsByTraceQueryValidator 100% + FSH.Modules.Auditing.Features.v1.GetAuditSummary.GetAuditSummaryEndpoint 0% + FSH.Modules.Auditing.Features.v1.GetAuditSummary.GetAuditSummaryQueryHandler 0% + FSH.Modules.Auditing.Features.v1.GetAuditSummary.GetAuditSummaryQueryValidator 100% + FSH.Modules.Auditing.Features.v1.GetExceptionAudits.GetExceptionAuditsEndpoint 0% + FSH.Modules.Auditing.Features.v1.GetExceptionAudits.GetExceptionAuditsQueryHandler 0% + FSH.Modules.Auditing.Features.v1.GetExceptionAudits.GetExceptionAuditsQueryValidator 100% + FSH.Modules.Auditing.Features.v1.GetSecurityAudits.GetSecurityAuditsEndpoint 0% + FSH.Modules.Auditing.Features.v1.GetSecurityAudits.GetSecurityAuditsQueryHandler 0% + FSH.Modules.Auditing.Features.v1.GetSecurityAudits.GetSecurityAuditsQueryValidator 100% + FSH.Modules.Auditing.HttpAuditScope 0% + FSH.Modules.Auditing.HttpBodyReader 0% + FSH.Modules.Auditing.HttpContextRoutingExtensions 0% + FSH.Modules.Auditing.JsonMaskingService 91.6% + FSH.Modules.Auditing.Persistence.AuditDbContext 0% + FSH.Modules.Auditing.Persistence.AuditDbInitializer 0% + FSH.Modules.Auditing.Persistence.AuditingSaveChangesInterceptor 0% + FSH.Modules.Auditing.Persistence.AuditRecordConfiguration 0% + FSH.Modules.Auditing.Persistence.EntityDiffBuilder 0% + FSH.Modules.Auditing.Persistence.SqlAuditSink 0% + FSH.Modules.Auditing.SecurityAudit 0% + FSH.Modules.Auditing.ServiceCollectionExtensions 0% + FSH.Modules.Auditing.SystemTextJsonAuditSerializer 0% + Microsoft.AspNetCore.OpenApi.Generated 0% + System.Runtime.CompilerServices 0% + +FSH.Modules.Auditing.Contracts 13.4% + FSH.Modules.Auditing.Contracts.ActivityEventPayload 0% + FSH.Modules.Auditing.Contracts.AuditEnvelope 100% + FSH.Modules.Auditing.Contracts.AuditHttpOptions 0% + FSH.Modules.Auditing.Contracts.Dtos.AuditDetailDto 0% + FSH.Modules.Auditing.Contracts.Dtos.AuditSummaryAggregateDto 0% + FSH.Modules.Auditing.Contracts.Dtos.AuditSummaryDto 0% + FSH.Modules.Auditing.Contracts.EntityChangeEventPayload 0% + FSH.Modules.Auditing.Contracts.ExceptionEventPayload 0% + FSH.Modules.Auditing.Contracts.ExceptionSeverityClassifier 100% + FSH.Modules.Auditing.Contracts.PropertyChange 0% + FSH.Modules.Auditing.Contracts.SecurityEventPayload 0% + FSH.Modules.Auditing.Contracts.v1.GetAuditById.GetAuditByIdQuery 0% + FSH.Modules.Auditing.Contracts.v1.GetAudits.GetAuditsQuery 100% + FSH.Modules.Auditing.Contracts.v1.GetAuditsByCorrelation.GetAuditsByCorrelationQuery 100% + FSH.Modules.Auditing.Contracts.v1.GetAuditsByTrace.GetAuditsByTraceQuery 100% + FSH.Modules.Auditing.Contracts.v1.GetAuditSummary.GetAuditSummaryQuery 66.6% + FSH.Modules.Auditing.Contracts.v1.GetExceptionAudits.GetExceptionAuditsQuery 33.3% + FSH.Modules.Auditing.Contracts.v1.GetSecurityAudits.GetSecurityAuditsQuery 40% + Microsoft.AspNetCore.OpenApi.Generated 0% + System.Runtime.CompilerServices 0% + +FSH.Modules.Identity 4.5% + FSH.Framework.Identity.v1.Tokens.RefreshToken.RefreshTokenEndpoint 0% + FSH.Framework.Identity.v1.Tokens.TokenGeneration.GenerateTokenEndpoint 0% + FSH.Framework.Infrastructure.Identity.Users.Endpoints.SelfRegisterUserEndpoint 0% + FSH.Framework.Infrastructure.Identity.Users.Services.UserService 0% + FSH.Modules.Identity.Authorization.Jwt.ConfigureJwtBearerOptions 0% + FSH.Modules.Identity.Authorization.Jwt.Extensions 0% + FSH.Modules.Identity.Authorization.Jwt.JwtOptions 100% + FSH.Modules.Identity.Authorization.PathAwareAuthorizationHandler 0% + FSH.Modules.Identity.Authorization.RequiredPermissionAuthorizationExtensions 0% + FSH.Modules.Identity.Authorization.RequiredPermissionAuthorizationHandler 0% + FSH.Modules.Identity.Data.ApplicationRoleClaimConfig 0% + FSH.Modules.Identity.Data.ApplicationRoleConfig 0% + FSH.Modules.Identity.Data.ApplicationUserConfig 0% + FSH.Modules.Identity.Data.Configurations.GroupConfiguration 0% + FSH.Modules.Identity.Data.Configurations.GroupRoleConfiguration 0% + FSH.Modules.Identity.Data.Configurations.PasswordHistoryConfiguration 0% + FSH.Modules.Identity.Data.Configurations.UserGroupConfiguration 0% + FSH.Modules.Identity.Data.Configurations.UserSessionConfiguration 0% + FSH.Modules.Identity.Data.IdentityDbContext 0% + FSH.Modules.Identity.Data.IdentityDbInitializer 0% + FSH.Modules.Identity.Data.IdentityUserClaimConfig 0% + FSH.Modules.Identity.Data.IdentityUserLoginConfig 0% + FSH.Modules.Identity.Data.IdentityUserRoleConfig 0% + FSH.Modules.Identity.Data.IdentityUserTokenConfig 0% + FSH.Modules.Identity.Data.PasswordPolicyOptions 100% + FSH.Modules.Identity.Events.TokenGeneratedLogHandler 0% + FSH.Modules.Identity.Events.UserRegisteredEmailHandler 0% + FSH.Modules.Identity.Features.v1.Groups.AddUsersToGroup.AddUsersRequest 0% + FSH.Modules.Identity.Features.v1.Groups.AddUsersToGroup.AddUsersToGroupCommandHandler 0% + FSH.Modules.Identity.Features.v1.Groups.AddUsersToGroup.AddUsersToGroupEndpoint 0% + FSH.Modules.Identity.Features.v1.Groups.CreateGroup.CreateGroupCommandHandler 0% + FSH.Modules.Identity.Features.v1.Groups.CreateGroup.CreateGroupCommandValidator 100% + FSH.Modules.Identity.Features.v1.Groups.CreateGroup.CreateGroupEndpoint 0% + FSH.Modules.Identity.Features.v1.Groups.DeleteGroup.DeleteGroupCommandHandler 0% + FSH.Modules.Identity.Features.v1.Groups.DeleteGroup.DeleteGroupEndpoint 0% + FSH.Modules.Identity.Features.v1.Groups.GetGroupById.GetGroupByIdEndpoint 0% + FSH.Modules.Identity.Features.v1.Groups.GetGroupById.GetGroupByIdQueryHandler 0% + FSH.Modules.Identity.Features.v1.Groups.GetGroupMembers.GetGroupMembersEndpoint 0% + FSH.Modules.Identity.Features.v1.Groups.GetGroupMembers.GetGroupMembersQueryHandler 0% + FSH.Modules.Identity.Features.v1.Groups.GetGroups.GetGroupsEndpoint 0% + FSH.Modules.Identity.Features.v1.Groups.GetGroups.GetGroupsQueryHandler 0% + FSH.Modules.Identity.Features.v1.Groups.Group 0% + FSH.Modules.Identity.Features.v1.Groups.GroupRole 0% + FSH.Modules.Identity.Features.v1.Groups.RemoveUserFromGroup.RemoveUserFromGroupCommandHandler 0% + FSH.Modules.Identity.Features.v1.Groups.RemoveUserFromGroup.RemoveUserFromGroupEndpoint 0% + FSH.Modules.Identity.Features.v1.Groups.UpdateGroup.UpdateGroupCommandHandler 0% + FSH.Modules.Identity.Features.v1.Groups.UpdateGroup.UpdateGroupCommandValidator 100% + FSH.Modules.Identity.Features.v1.Groups.UpdateGroup.UpdateGroupEndpoint 0% + FSH.Modules.Identity.Features.v1.Groups.UpdateGroup.UpdateGroupRequest 0% + FSH.Modules.Identity.Features.v1.Groups.UserGroup 0% + FSH.Modules.Identity.Features.v1.RoleClaims.FshRoleClaim 0% + FSH.Modules.Identity.Features.v1.Roles.DeleteRole.DeleteRoleCommandHandler 0% + FSH.Modules.Identity.Features.v1.Roles.DeleteRole.DeleteRoleEndpoint 0% + FSH.Modules.Identity.Features.v1.Roles.FshRole 0% + FSH.Modules.Identity.Features.v1.Roles.GetRoleById.GetRoleByIdEndpoint 0% + FSH.Modules.Identity.Features.v1.Roles.GetRoleById.GetRoleByIdQueryHandler 0% + FSH.Modules.Identity.Features.v1.Roles.GetRoles.GetRolesEndpoint 0% + FSH.Modules.Identity.Features.v1.Roles.GetRoles.GetRolesQueryHandler 0% + FSH.Modules.Identity.Features.v1.Roles.GetRoleWithPermissions.GetRolePermissionsEndpoint 0% + FSH.Modules.Identity.Features.v1.Roles.GetRoleWithPermissions.GetRoleWithPermissionsQueryHandler 0% + FSH.Modules.Identity.Features.v1.Roles.RoleService 0% + FSH.Modules.Identity.Features.v1.Roles.UpdateRolePermissions.UpdatePermissionsCommandHandler 0% + FSH.Modules.Identity.Features.v1.Roles.UpdateRolePermissions.UpdatePermissionsCommandValidator 100% + FSH.Modules.Identity.Features.v1.Roles.UpdateRolePermissions.UpdateRolePermissionsEndpoint 0% + FSH.Modules.Identity.Features.v1.Roles.UpsertRole.CreateOrUpdateRoleEndpoint 0% + FSH.Modules.Identity.Features.v1.Roles.UpsertRole.UpsertRoleCommandHandler 0% + FSH.Modules.Identity.Features.v1.Roles.UpsertRole.UpsertRoleCommandValidator 100% + FSH.Modules.Identity.Features.v1.Sessions.AdminRevokeAllSessions.AdminRevokeAllSessionsCommandHandler 0% + FSH.Modules.Identity.Features.v1.Sessions.AdminRevokeAllSessions.AdminRevokeAllSessionsEndpoint 0% + FSH.Modules.Identity.Features.v1.Sessions.AdminRevokeSession.AdminRevokeSessionCommandHandler 0% + FSH.Modules.Identity.Features.v1.Sessions.AdminRevokeSession.AdminRevokeSessionEndpoint 0% + FSH.Modules.Identity.Features.v1.Sessions.GetMySessions.GetMySessionsEndpoint 0% + FSH.Modules.Identity.Features.v1.Sessions.GetMySessions.GetMySessionsQueryHandler 0% + FSH.Modules.Identity.Features.v1.Sessions.GetUserSessions.GetUserSessionsEndpoint 0% + FSH.Modules.Identity.Features.v1.Sessions.GetUserSessions.GetUserSessionsQueryHandler 0% + FSH.Modules.Identity.Features.v1.Sessions.RevokeAllSessions.RevokeAllSessionsCommandHandler 0% + FSH.Modules.Identity.Features.v1.Sessions.RevokeAllSessions.RevokeAllSessionsEndpoint 0% + FSH.Modules.Identity.Features.v1.Sessions.RevokeSession.RevokeSessionCommandHandler 0% + FSH.Modules.Identity.Features.v1.Sessions.RevokeSession.RevokeSessionEndpoint 0% + FSH.Modules.Identity.Features.v1.Sessions.UserSession 0% + FSH.Modules.Identity.Features.v1.Tokens.RefreshToken.RefreshTokenCommandHandler 0% + FSH.Modules.Identity.Features.v1.Tokens.RefreshToken.RefreshTokenCommandValidator 100% + FSH.Modules.Identity.Features.v1.Tokens.TokenGeneration.GenerateTokenCommandHandler 0% + FSH.Modules.Identity.Features.v1.Tokens.TokenGeneration.GenerateTokenCommandValidator 100% + FSH.Modules.Identity.Features.v1.Users.AssignUserRoles.AssignUserRolesCommandHandler 0% + FSH.Modules.Identity.Features.v1.Users.AssignUserRoles.AssignUserRolesEndpoint 0% + FSH.Modules.Identity.Features.v1.Users.ChangePassword.ChangePasswordCommandHandler 0% + FSH.Modules.Identity.Features.v1.Users.ChangePassword.ChangePasswordEndpoint 0% + FSH.Modules.Identity.Features.v1.Users.ChangePassword.ChangePasswordValidator 0% + FSH.Modules.Identity.Features.v1.Users.ConfirmEmail.ConfirmEmailCommandHandler 0% + FSH.Modules.Identity.Features.v1.Users.ConfirmEmail.ConfirmEmailEndpoint 0% + FSH.Modules.Identity.Features.v1.Users.DeleteUser.DeleteUserCommandHandler 0% + FSH.Modules.Identity.Features.v1.Users.DeleteUser.DeleteUserEndpoint 0% + FSH.Modules.Identity.Features.v1.Users.ForgotPassword.ForgotPasswordCommandHandler 0% + FSH.Modules.Identity.Features.v1.Users.ForgotPassword.ForgotPasswordCommandValidator 0% + FSH.Modules.Identity.Features.v1.Users.ForgotPassword.ForgotPasswordEndpoint 0% + FSH.Modules.Identity.Features.v1.Users.FshUser 22.2% + FSH.Modules.Identity.Features.v1.Users.GetUserById.GetUserByIdEndpoint 0% + FSH.Modules.Identity.Features.v1.Users.GetUserById.GetUserByIdQueryHandler 0% + FSH.Modules.Identity.Features.v1.Users.GetUserGroups.GetUserGroupsEndpoint 0% + FSH.Modules.Identity.Features.v1.Users.GetUserGroups.GetUserGroupsQueryHandler 0% + FSH.Modules.Identity.Features.v1.Users.GetUserPermissions.GetCurrentUserPermissionsQueryHandler 0% + FSH.Modules.Identity.Features.v1.Users.GetUserPermissions.GetUserPermissionsEndpoint 0% + FSH.Modules.Identity.Features.v1.Users.GetUserProfile.GetCurrentUserProfileQueryHandler 0% + FSH.Modules.Identity.Features.v1.Users.GetUserProfile.GetUserProfileEndpoint 0% + FSH.Modules.Identity.Features.v1.Users.GetUserRoles.GetUserRolesEndpoint 0% + FSH.Modules.Identity.Features.v1.Users.GetUserRoles.GetUserRolesQueryHandler 0% + FSH.Modules.Identity.Features.v1.Users.GetUsers.GetUsersListEndpoint 0% + FSH.Modules.Identity.Features.v1.Users.GetUsers.GetUsersQueryHandler 0% + FSH.Modules.Identity.Features.v1.Users.PasswordHistory.PasswordHistory 0% + FSH.Modules.Identity.Features.v1.Users.RegisterUser.RegisterUserCommandHandler 0% + FSH.Modules.Identity.Features.v1.Users.RegisterUser.RegisterUserEndpoint 0% + FSH.Modules.Identity.Features.v1.Users.ResetPassword.ResetPasswordCommandHandler 0% + FSH.Modules.Identity.Features.v1.Users.ResetPassword.ResetPasswordCommandValidator 0% + FSH.Modules.Identity.Features.v1.Users.ResetPassword.ResetPasswordEndpoint 0% + FSH.Modules.Identity.Features.v1.Users.SearchUsers.SearchUsersEndpoint 0% + FSH.Modules.Identity.Features.v1.Users.SearchUsers.SearchUsersQueryHandler 0% + FSH.Modules.Identity.Features.v1.Users.SearchUsers.SearchUsersQueryValidator 100% + FSH.Modules.Identity.Features.v1.Users.ToggleUserStatus.ToggleUserStatusCommandHandler 0% + FSH.Modules.Identity.Features.v1.Users.ToggleUserStatus.ToggleUserStatusEndpoint 0% + FSH.Modules.Identity.Features.v1.Users.UpdateUser.UpdateUserCommandHandler 0% + FSH.Modules.Identity.Features.v1.Users.UpdateUser.UpdateUserCommandValidator 0% + FSH.Modules.Identity.Features.v1.Users.UpdateUser.UpdateUserEndpoint 0% + FSH.Modules.Identity.Features.v1.Users.UserImageValidator 0% + FSH.Modules.Identity.IdentityMetrics 0% + FSH.Modules.Identity.IdentityModule 0% + FSH.Modules.Identity.IdentityModuleConstants 0% + FSH.Modules.Identity.Services.CurrentUserService 100% + FSH.Modules.Identity.Services.GroupRoleService 0% + FSH.Modules.Identity.Services.IdentityService 0% + FSH.Modules.Identity.Services.PasswordExpiryService 100% + FSH.Modules.Identity.Services.PasswordExpiryStatus 100% + FSH.Modules.Identity.Services.PasswordHistoryService 0% + FSH.Modules.Identity.Services.SessionService 0% + FSH.Modules.Identity.Services.TokenService 0% + Microsoft.AspNetCore.OpenApi.Generated 0% + System.Runtime.CompilerServices 0% + +FSH.Modules.Identity.Contracts 15.8% + FSH.Modules.Identity.Contracts.DTOs.GroupDto 0% + FSH.Modules.Identity.Contracts.DTOs.GroupMemberDto 0% + FSH.Modules.Identity.Contracts.DTOs.RoleDto 0% + FSH.Modules.Identity.Contracts.DTOs.TokenDto 0% + FSH.Modules.Identity.Contracts.DTOs.TokenResponse 0% + FSH.Modules.Identity.Contracts.DTOs.UserDto 0% + FSH.Modules.Identity.Contracts.DTOs.UserRoleDto 0% + FSH.Modules.Identity.Contracts.DTOs.UserSessionDto 0% + FSH.Modules.Identity.Contracts.Events.TokenGeneratedIntegrationEvent 0% + FSH.Modules.Identity.Contracts.Events.UserRegisteredIntegrationEvent 0% + FSH.Modules.Identity.Contracts.v1.Groups.AddUsersToGroup.AddUsersToGroupCommand 0% + FSH.Modules.Identity.Contracts.v1.Groups.AddUsersToGroup.AddUsersToGroupResponse 0% + FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup.CreateGroupCommand 80% + FSH.Modules.Identity.Contracts.v1.Groups.DeleteGroup.DeleteGroupCommand 0% + FSH.Modules.Identity.Contracts.v1.Groups.GetGroupById.GetGroupByIdQuery 0% + FSH.Modules.Identity.Contracts.v1.Groups.GetGroupMembers.GetGroupMembersQuery 0% + FSH.Modules.Identity.Contracts.v1.Groups.GetGroups.GetGroupsQuery 0% + FSH.Modules.Identity.Contracts.v1.Groups.RemoveUserFromGroup.RemoveUserFromGroupCommand 0% + FSH.Modules.Identity.Contracts.v1.Groups.UpdateGroup.UpdateGroupCommand 83.3% + FSH.Modules.Identity.Contracts.v1.Roles.DeleteRole.DeleteRoleCommand 0% + FSH.Modules.Identity.Contracts.v1.Roles.GetRole.GetRoleQuery 0% + FSH.Modules.Identity.Contracts.v1.Roles.GetRoleWithPermissions.GetRoleWithPermissionsQuery 0% + FSH.Modules.Identity.Contracts.v1.Roles.UpdatePermissions.UpdatePermissionsCommand 100% + FSH.Modules.Identity.Contracts.v1.Roles.UpsertRole.UpsertRoleCommand 100% + FSH.Modules.Identity.Contracts.v1.Sessions.AdminRevokeAllSessions.AdminRevokeAllSessionsCommand 0% + FSH.Modules.Identity.Contracts.v1.Sessions.AdminRevokeSession.AdminRevokeSessionCommand 0% + FSH.Modules.Identity.Contracts.v1.Sessions.GetUserSessions.GetUserSessionsQuery 0% + FSH.Modules.Identity.Contracts.v1.Sessions.RevokeAllSessions.RevokeAllSessionsCommand 0% + FSH.Modules.Identity.Contracts.v1.Sessions.RevokeSession.RevokeSessionCommand 0% + FSH.Modules.Identity.Contracts.v1.Tokens.RefreshToken.RefreshTokenCommand 100% + FSH.Modules.Identity.Contracts.v1.Tokens.RefreshToken.RefreshTokenCommandResponse 0% + FSH.Modules.Identity.Contracts.v1.Tokens.TokenGeneration.GenerateTokenCommand 100% + FSH.Modules.Identity.Contracts.v1.Users.AssignUserRoles.AssignUserRolesCommand 0% + FSH.Modules.Identity.Contracts.v1.Users.AssignUserRoles.AssignUserRolesCommandResponse 0% + FSH.Modules.Identity.Contracts.v1.Users.ChangePassword.ChangePasswordCommand 0% + FSH.Modules.Identity.Contracts.v1.Users.ConfirmEmail.ConfirmEmailCommand 0% + FSH.Modules.Identity.Contracts.v1.Users.DeleteUser.DeleteUserCommand 0% + FSH.Modules.Identity.Contracts.v1.Users.ForgotPassword.ForgotPasswordCommand 0% + FSH.Modules.Identity.Contracts.v1.Users.GetUser.GetUserQuery 0% + FSH.Modules.Identity.Contracts.v1.Users.GetUserGroups.GetUserGroupsQuery 0% + FSH.Modules.Identity.Contracts.v1.Users.GetUserPermissions.GetCurrentUserPermissionsQuery 0% + FSH.Modules.Identity.Contracts.v1.Users.GetUserProfile.GetCurrentUserProfileQuery 0% + FSH.Modules.Identity.Contracts.v1.Users.GetUserRoles.GetUserRolesQuery 0% + FSH.Modules.Identity.Contracts.v1.Users.RegisterUser.RegisterUserCommand 0% + FSH.Modules.Identity.Contracts.v1.Users.RegisterUser.RegisterUserResponse 0% + FSH.Modules.Identity.Contracts.v1.Users.ResetPassword.ResetPasswordCommand 0% + FSH.Modules.Identity.Contracts.v1.Users.SearchUsers.SearchUsersQuery 100% + FSH.Modules.Identity.Contracts.v1.Users.ToggleUserStatus.ToggleUserStatusCommand 0% + FSH.Modules.Identity.Contracts.v1.Users.UpdateUser.UpdateUserCommand 0% + +FSH.Modules.Multitenancy 6.9% + FSH.Modules.Multitenancy.Data.Configurations.AppTenantInfoConfiguration 0% + FSH.Modules.Multitenancy.Data.Configurations.TenantProvisioningConfiguration 0% + FSH.Modules.Multitenancy.Data.Configurations.TenantProvisioningStepConfiguration 0% + FSH.Modules.Multitenancy.Data.Configurations.TenantThemeConfiguration 0% + FSH.Modules.Multitenancy.Data.TenantDbContext 0% + FSH.Modules.Multitenancy.Data.TenantDbContextFactory 0% + FSH.Modules.Multitenancy.Domain.TenantTheme 100% + FSH.Modules.Multitenancy.Extensions 0% + FSH.Modules.Multitenancy.Features.v1.ChangeTenantActivation.ChangeTenantActivationCommandHandler 0% + FSH.Modules.Multitenancy.Features.v1.ChangeTenantActivation.ChangeTenantActivationCommandValidator 0% + FSH.Modules.Multitenancy.Features.v1.ChangeTenantActivation.ChangeTenantActivationEndpoint 0% + FSH.Modules.Multitenancy.Features.v1.CreateTenant.CreateTenantCommandHandler 0% + FSH.Modules.Multitenancy.Features.v1.CreateTenant.CreateTenantCommandValidator 0% + FSH.Modules.Multitenancy.Features.v1.CreateTenant.CreateTenantEndpoint 0% + FSH.Modules.Multitenancy.Features.v1.GetTenantMigrations.GetTenantMigrationsQueryHandler 0% + FSH.Modules.Multitenancy.Features.v1.GetTenantMigrations.TenantMigrationsEndpoint 0% + FSH.Modules.Multitenancy.Features.v1.GetTenants.GetTenantsEndpoint 0% + FSH.Modules.Multitenancy.Features.v1.GetTenants.GetTenantsQueryHandler 0% + FSH.Modules.Multitenancy.Features.v1.GetTenants.GetTenantsSpecification 0% + FSH.Modules.Multitenancy.Features.v1.GetTenantStatus.GetTenantStatusEndpoint 0% + FSH.Modules.Multitenancy.Features.v1.GetTenantStatus.GetTenantStatusQueryHandler 0% + FSH.Modules.Multitenancy.Features.v1.GetTenantTheme.GetTenantThemeEndpoint 0% + FSH.Modules.Multitenancy.Features.v1.GetTenantTheme.GetTenantThemeQueryHandler 0% + FSH.Modules.Multitenancy.Features.v1.ResetTenantTheme.ResetTenantThemeCommandHandler 0% + FSH.Modules.Multitenancy.Features.v1.ResetTenantTheme.ResetTenantThemeEndpoint 0% + FSH.Modules.Multitenancy.Features.v1.TenantProvisioning.GetTenantProvisioningStatus.GetTenantProvisioningStatusEndpoint 0% + FSH.Modules.Multitenancy.Features.v1.TenantProvisioning.GetTenantProvisioningStatus.GetTenantProvisioningStatusQueryHandler 0% + FSH.Modules.Multitenancy.Features.v1.TenantProvisioning.RetryTenantProvisioning.RetryTenantProvisioningCommandHandler 0% + FSH.Modules.Multitenancy.Features.v1.TenantProvisioning.RetryTenantProvisioning.RetryTenantProvisioningEndpoint 0% + FSH.Modules.Multitenancy.Features.v1.UpdateTenantTheme.UpdateTenantThemeCommandHandler 0% + FSH.Modules.Multitenancy.Features.v1.UpdateTenantTheme.UpdateTenantThemeCommandValidator 0% + FSH.Modules.Multitenancy.Features.v1.UpdateTenantTheme.UpdateTenantThemeEndpoint 0% + FSH.Modules.Multitenancy.Features.v1.UpgradeTenant.UpgradeTenantCommandHandler 0% + FSH.Modules.Multitenancy.Features.v1.UpgradeTenant.UpgradeTenantCommandValidator 0% + FSH.Modules.Multitenancy.Features.v1.UpgradeTenant.UpgradeTenantEndpoint 0% + FSH.Modules.Multitenancy.MultitenancyModule 0% + FSH.Modules.Multitenancy.MultitenancyOptions 100% + FSH.Modules.Multitenancy.Provisioning.TenantAutoProvisioningHostedService 0% + FSH.Modules.Multitenancy.Provisioning.TenantProvisioning 92.1% + FSH.Modules.Multitenancy.Provisioning.TenantProvisioningJob 0% + FSH.Modules.Multitenancy.Provisioning.TenantProvisioningService 0% + FSH.Modules.Multitenancy.Provisioning.TenantProvisioningStep 86.2% + FSH.Modules.Multitenancy.Provisioning.TenantStoreInitializerHostedService 0% + FSH.Modules.Multitenancy.Services.TenantService 0% + FSH.Modules.Multitenancy.Services.TenantThemeService 0% + FSH.Modules.Multitenancy.TenantMigrationsHealthCheck 0% + Microsoft.AspNetCore.OpenApi.Generated 0% + System.Runtime.CompilerServices 0% + System.Text.RegularExpressions.Generated 0% + System.Text.RegularExpressions.Generated.F6CAC05BAADEF2F452E05A0705C3AD94010A6C5722F8C4C1F0FDFC4AF0EBCDC6B__HexColorRegex_0 0% + +FSH.Modules.Multitenancy.Contracts 0% + FSH.Modules.Multitenancy.Contracts.Dtos.BrandAssetsDto 0% + FSH.Modules.Multitenancy.Contracts.Dtos.LayoutDto 0% + FSH.Modules.Multitenancy.Contracts.Dtos.PaletteDto 0% + FSH.Modules.Multitenancy.Contracts.Dtos.TenantDto 0% + FSH.Modules.Multitenancy.Contracts.Dtos.TenantLifecycleResultDto 0% + FSH.Modules.Multitenancy.Contracts.Dtos.TenantMigrationStatusDto 0% + FSH.Modules.Multitenancy.Contracts.Dtos.TenantProvisioningStatusDto 0% + FSH.Modules.Multitenancy.Contracts.Dtos.TenantProvisioningStepDto 0% + FSH.Modules.Multitenancy.Contracts.Dtos.TenantStatusDto 0% + FSH.Modules.Multitenancy.Contracts.Dtos.TenantThemeDto 0% + FSH.Modules.Multitenancy.Contracts.Dtos.TypographyDto 0% + FSH.Modules.Multitenancy.Contracts.Options.BrandAssetsReadDto 0% + FSH.Modules.Multitenancy.Contracts.Options.TenantThemeOptions 0% + FSH.Modules.Multitenancy.Contracts.v1.ChangeTenantActivation.ChangeTenantActivationCommand 0% + FSH.Modules.Multitenancy.Contracts.v1.CreateTenant.CreateTenantCommand 0% + FSH.Modules.Multitenancy.Contracts.v1.CreateTenant.CreateTenantCommandResponse 0% + FSH.Modules.Multitenancy.Contracts.v1.GetTenants.GetTenantsQuery 0% + FSH.Modules.Multitenancy.Contracts.v1.GetTenantStatus.GetTenantStatusQuery 0% + FSH.Modules.Multitenancy.Contracts.v1.TenantProvisioning.GetTenantProvisioningStatusQuery 0% + FSH.Modules.Multitenancy.Contracts.v1.TenantProvisioning.RetryTenantProvisioningCommand 0% + FSH.Modules.Multitenancy.Contracts.v1.UpdateTenantTheme.UpdateTenantThemeCommand 0% + FSH.Modules.Multitenancy.Contracts.v1.UpgradeTenant.UpgradeTenantCommand 0% + FSH.Modules.Multitenancy.Contracts.v1.UpgradeTenant.UpgradeTenantCommandResponse 0% + +FSH.Playground.Migrations.PostgreSQL 0% + FSH.Playground.Migrations.PostgreSQL.Audit.AddAudits 0% + FSH.Playground.Migrations.PostgreSQL.Audit.AuditDbContextModelSnapshot 0% + FSH.Playground.Migrations.PostgreSQL.Identity.IdentityDbContextModelSnapshot 0% + FSH.Playground.Migrations.PostgreSQL.Identity.Initial 0% + FSH.Playground.Migrations.PostgreSQL.Identity.SessionManagement 0% + FSH.Playground.Migrations.PostgreSQL.Identity.UserGroups 0% + FSH.Playground.Migrations.PostgreSQL.MultiTenancy.AddMultitenancy 0% + FSH.Playground.Migrations.PostgreSQL.MultiTenancy.AddTenantTheme 0% + FSH.Playground.Migrations.PostgreSQL.MultiTenancy.IncreaseTenantThemeUrlLength 0% + FSH.Playground.Migrations.PostgreSQL.MultiTenancy.TenantDbContextModelSnapshot 0% + FSH.Playground.Migrations.PostgreSQL.MultiTenancy.UpdateMultitenancy 0% + Microsoft.AspNetCore.OpenApi.Generated 0% + System.Runtime.CompilerServices 0% diff --git a/icon.png b/icon.png deleted file mode 100644 index fd9fa41873..0000000000 Binary files a/icon.png and /dev/null differ diff --git a/scripts/openapi/README.md b/scripts/openapi/README.md new file mode 100644 index 0000000000..2a55eb4649 --- /dev/null +++ b/scripts/openapi/README.md @@ -0,0 +1,29 @@ +# OpenAPI Client Generation + +Use NSwag (local dotnet tool) to generate typed C# clients + DTOs from the Playground API spec. + +## Prereqs +- .NET SDK (repo already uses net10.0) +- Local tool manifest at `.config/dotnet-tools.json` (created) with `nswag.consolecore` + +## One-liner +```powershell +./scripts/openapi/generate-api-clients.ps1 -SpecUrl "https://localhost:7030/openapi/v1.json" +``` + +This restores the local tool, ensures the output directory exists, and runs NSwag with the spec URL you provide. + +## Output +- Clients + DTOs: `src/Playground/Playground.Blazor/ApiClient/Generated.cs` (single file; multiple client types grouped by first path segment after the base path, e.g., `/api/v1/identity/*` -> `IdentityClient`). +- Namespace: `FSH.Playground.Blazor.ApiClient` +- Client grouping: `MultipleClientsFromPathSegments`; ensure Minimal API routes keep module-specific first segments. +- Bearer auth: configure `HttpClient` (via DI) with the bearer token; generated clients use injected `HttpClient`. Base URLs are not baked into the generated code (`useBaseUrl: false`), so `HttpClient.BaseAddress` must be set by the app (see `Program.cs`). + +## Drift Check (manual) +Use `./scripts/openapi/check-openapi-drift.ps1 -SpecUrl ""` to regenerate the clients and fail if `ApiClient/Generated.cs` changes. This is useful in PRs to ensure the spec and generated clients stay in sync even before CI enforcement. + +> Note: The spec endpoint must be reachable when running the generation scripts. If the API is not running locally, point `-SpecUrl` to an accessible environment or start the Playground API first. + +## Tips +- If the API changes, rerun the script with the updated spec URL (e.g., staging/prod). +- Commit regenerated clients alongside related API changes to keep UI consumers in sync. diff --git a/scripts/openapi/check-openapi-drift.ps1 b/scripts/openapi/check-openapi-drift.ps1 new file mode 100644 index 0000000000..03fc0f9456 --- /dev/null +++ b/scripts/openapi/check-openapi-drift.ps1 @@ -0,0 +1,21 @@ +#!/usr/bin/env pwsh +param( + [Parameter(Mandatory = $false)] + [string] $SpecUrl = "https://localhost:7030/openapi/v1.json" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path "$PSScriptRoot/../.." +Set-Location $repoRoot + +Write-Host "Running NSwag generation against $SpecUrl..." +./scripts/openapi/generate-api-clients.ps1 -SpecUrl $SpecUrl + +$targetFile = "src/Playground/Playground.Blazor/ApiClient/Generated.cs" + +Write-Host "Checking for drift in $targetFile..." +git diff --exit-code -- $targetFile + +Write-Host "No drift detected." diff --git a/scripts/openapi/generate-api-clients.ps1 b/scripts/openapi/generate-api-clients.ps1 new file mode 100644 index 0000000000..da9e8a9964 --- /dev/null +++ b/scripts/openapi/generate-api-clients.ps1 @@ -0,0 +1,21 @@ +param( + [string]$SpecUrl = "https://localhost:7030/openapi/v1.json" +) + +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Resolve-Path (Join-Path $scriptDir ".." "..") +$configPath = Join-Path $scriptDir "nswag-playground.json" +$outputDir = Join-Path $repoRoot "src/Playground/Playground.Blazor/ApiClient" + +Write-Host "Ensuring dotnet local tools are restored..." -ForegroundColor Cyan +dotnet tool restore | Out-Host + +Write-Host "Ensuring output directory exists: $outputDir" -ForegroundColor Cyan +New-Item -ItemType Directory -Force -Path $outputDir | Out-Null + +Write-Host "Generating API client from spec: $SpecUrl" -ForegroundColor Cyan +dotnet nswag run $configPath /variables:SpecUrl=$SpecUrl + +Write-Host "Done. Generated clients are in $outputDir" -ForegroundColor Green diff --git a/scripts/openapi/nswag-playground.json b/scripts/openapi/nswag-playground.json new file mode 100644 index 0000000000..05f1dbd86b --- /dev/null +++ b/scripts/openapi/nswag-playground.json @@ -0,0 +1,106 @@ +{ + "runtime": "Net100", + "defaultVariables": null, + "documentGenerator": { + "fromDocument": { + "url": "$(SpecUrl)", + "output": null, + "newLineBehavior": "Auto" + } + }, + "codeGenerators": { + "openApiToCSharpClient": { + "clientBaseClass": null, + "configurationClass": null, + "generateClientClasses": true, + "suppressClientClassesOutput": false, + "generateClientInterfaces": true, + "suppressClientInterfacesOutput": false, + "clientBaseInterface": null, + "injectHttpClient": true, + "disposeHttpClient": false, + "protectedMethods": [], + "generateExceptionClasses": true, + "exceptionClass": "ApiException", + "wrapDtoExceptions": false, + "useHttpClientCreationMethod": false, + "httpClientType": "System.Net.Http.HttpClient", + "useHttpRequestMessageCreationMethod": false, + "useBaseUrl": false, + "generateBaseUrlProperty": false, + "generateSyncMethods": false, + "generatePrepareRequestAndProcessResponseAsAsyncMethods": false, + "exposeJsonSerializerSettings": false, + "clientClassAccessModifier": "public", + "typeAccessModifier": "public", + "generateContractsOutput": false, + "contractsNamespace": null, + "contractsOutputFilePath": null, + "parameterDateTimeFormat": "o", + "parameterDateFormat": "yyyy-MM-dd", + "generateUpdateJsonSerializerSettingsMethod": true, + "useRequestAndResponseSerializationSettings": false, + "serializeTypeInformation": false, + "queryNullValue": "", + "className": "{controller}Client", + "operationGenerationMode": "MultipleClientsFromPathSegments", + "includedOperationIds": [], + "excludedOperationIds": [], + "additionalNamespaceUsages": [], + "additionalContractNamespaceUsages": [], + "generateOptionalParameters": true, + "generateJsonMethods": false, + "enforceFlagEnums": false, + "parameterArrayType": "System.Collections.Generic.IEnumerable", + "parameterDictionaryType": "System.Collections.Generic.IDictionary", + "responseArrayType": "System.Collections.Generic.ICollection", + "responseDictionaryType": "System.Collections.Generic.IDictionary", + "wrapResponses": false, + "wrapResponseMethods": [], + "generateResponseClasses": true, + "responseClass": "SwaggerResponse", + "namespace": "FSH.Playground.Blazor.ApiClient", + "requiredPropertiesMustBeDefined": true, + "dateType": "System.DateTimeOffset", + "jsonConverters": null, + "anyType": "object", + "dateTimeType": "System.DateTimeOffset", + "timeType": "System.TimeSpan", + "timeSpanType": "System.TimeSpan", + "arrayType": "System.Collections.Generic.ICollection", + "arrayInstanceType": "System.Collections.ObjectModel.Collection", + "dictionaryType": "System.Collections.Generic.IDictionary", + "dictionaryInstanceType": "System.Collections.Generic.Dictionary", + "arrayBaseType": "System.Collections.ObjectModel.Collection", + "dictionaryBaseType": "System.Collections.Generic.Dictionary", + "classStyle": "Poco", + "jsonLibrary": "SystemTextJson", + "jsonPolymorphicSerializationStyle": "NJsonSchema", + "jsonLibraryVersion": 8.0, + "generateDefaultValues": true, + "generateDataAnnotations": true, + "excludedTypeNames": [], + "excludedParameterNames": [], + "handleReferences": false, + "generateImmutableArrayProperties": false, + "generateImmutableDictionaryProperties": false, + "jsonSerializerSettingsTransformationMethod": null, + "inlineNamedArrays": false, + "inlineNamedDictionaries": false, + "inlineNamedTuples": true, + "inlineNamedAny": false, + "propertySetterAccessModifier": "", + "generateNativeRecords": false, + "useRequiredKeyword": false, + "writeAccessor": "set", + "generateDtoTypes": true, + "generateOptionalPropertiesAsNullable": false, + "generateNullableReferenceTypes": false, + "templateDirectory": null, + "serviceHost": null, + "serviceSchemes": null, + "output": "../../src/Playground/Playground.Blazor/ApiClient/Generated.cs", + "newLineBehavior": "Auto" + } + } +} diff --git a/scripts/test-cli.ps1 b/scripts/test-cli.ps1 new file mode 100644 index 0000000000..09caa87cf4 --- /dev/null +++ b/scripts/test-cli.ps1 @@ -0,0 +1,119 @@ +#!/usr/bin/env pwsh +# Test CLI locally without publishing to NuGet +# Usage: ./scripts/test-cli.ps1 [-Version "10.0.0-rc.1"] [-Uninstall] + +param( + [string]$Version = "10.0.0-local", + [switch]$Uninstall, + [switch]$SkipBuild +) + +$ErrorActionPreference = "Stop" +$ScriptDir = $PSScriptRoot +if (-not $ScriptDir) { $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path } +$RepoRoot = Split-Path -Parent $ScriptDir +$CliProject = Join-Path $RepoRoot "src\Tools\CLI\FSH.CLI.csproj" +$NupkgsDir = Join-Path $RepoRoot "artifacts\nupkgs" + +Write-Host "" +Write-Host "========================================" -ForegroundColor Cyan +Write-Host " FSH CLI Local Test Script" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" +Write-Host "Repo Root: $RepoRoot" -ForegroundColor Gray +Write-Host "CLI Project: $CliProject" -ForegroundColor Gray +Write-Host "" + +# Uninstall existing CLI +Write-Host "[1/4] Uninstalling existing fsh tool..." -ForegroundColor Yellow +dotnet tool uninstall -g FullStackHero.CLI 2>$null +if ($LASTEXITCODE -eq 0) { + Write-Host " Uninstalled successfully" -ForegroundColor Green +} else { + Write-Host " Not installed (skipping)" -ForegroundColor Gray +} + +if ($Uninstall) { + Write-Host "" + Write-Host "Uninstall complete." -ForegroundColor Green + exit 0 +} + +# Build and pack +if (-not $SkipBuild) { + Write-Host "" + Write-Host "[2/4] Building and packing CLI (Version: $Version)..." -ForegroundColor Yellow + + # Clean artifacts + if (Test-Path $NupkgsDir) { + Remove-Item -Recurse -Force $NupkgsDir + } + New-Item -ItemType Directory -Force -Path $NupkgsDir | Out-Null + + # Build with version + dotnet build $CliProject -c Release -p:Version=$Version + if ($LASTEXITCODE -ne 0) { + Write-Host "Build failed!" -ForegroundColor Red + exit 1 + } + + # Pack with version + dotnet pack $CliProject -c Release --no-build -o $NupkgsDir -p:PackageVersion=$Version + if ($LASTEXITCODE -ne 0) { + Write-Host "Pack failed!" -ForegroundColor Red + exit 1 + } + Write-Host " Package created successfully" -ForegroundColor Green +} else { + Write-Host "" + Write-Host "[2/4] Skipping build (using existing package)..." -ForegroundColor Gray +} + +# Install from local package +Write-Host "" +Write-Host "[3/4] Installing CLI from local package..." -ForegroundColor Yellow +$PackagePath = Get-ChildItem -Path $NupkgsDir -Filter "FullStackHero.CLI.*.nupkg" | Select-Object -First 1 + +if (-not $PackagePath) { + Write-Host "No package found in $NupkgsDir" -ForegroundColor Red + exit 1 +} + +Write-Host " Package: $($PackagePath.Name)" -ForegroundColor Gray +dotnet tool install -g FullStackHero.CLI --add-source $NupkgsDir --version $Version +if ($LASTEXITCODE -ne 0) { + Write-Host "Install failed!" -ForegroundColor Red + exit 1 +} +Write-Host " Installed successfully" -ForegroundColor Green + +# Verify installation +Write-Host "" +Write-Host "[4/4] Verifying installation..." -ForegroundColor Yellow +Write-Host "" + +$fshPath = Get-Command fsh -ErrorAction SilentlyContinue +if ($fshPath) { + Write-Host " fsh location: $($fshPath.Source)" -ForegroundColor Gray + Write-Host "" + Write-Host "----------------------------------------" -ForegroundColor Cyan + fsh --version + Write-Host "----------------------------------------" -ForegroundColor Cyan +} else { + Write-Host " Warning: 'fsh' command not found in PATH" -ForegroundColor Yellow + Write-Host " You may need to restart your terminal" -ForegroundColor Yellow +} + +Write-Host "" +Write-Host "========================================" -ForegroundColor Green +Write-Host " CLI installed successfully!" -ForegroundColor Green +Write-Host "========================================" -ForegroundColor Green +Write-Host "" +Write-Host "Test commands:" -ForegroundColor Cyan +Write-Host " fsh --help" -ForegroundColor White +Write-Host " fsh new --help" -ForegroundColor White +Write-Host " fsh new MyApp" -ForegroundColor White +Write-Host "" +Write-Host "To uninstall:" -ForegroundColor Cyan +Write-Host " ./scripts/test-cli.ps1 -Uninstall" -ForegroundColor White +Write-Host "" diff --git a/src/.dockerignore b/src/.dockerignore deleted file mode 100644 index 3aae53927b..0000000000 --- a/src/.dockerignore +++ /dev/null @@ -1,32 +0,0 @@ -# Include any files or directories that you don't want to be copied to your -# container here (e.g., local build artifacts, temporary files, etc.). -# -# For more help, visit the .dockerignore file reference guide at -# https://docs.docker.com/engine/reference/builder/#dockerignore-file - -**/.DS_Store -**/.classpath -**/.dockerignore -**/.env -**/.git -**/.gitignore -**/.project -**/.settings -**/.toolstarget -**/.vs -**/.vscode -**/*.*proj.user -**/*.dbmdl -**/*.jfm -**/bin -**/charts -**/docker-compose* -**/compose* -**/Dockerfile* -**/node_modules -**/npm-debug.log -**/obj -**/secrets.dev.yaml -**/values.dev.yaml -LICENSE -README.md diff --git a/src/.editorconfig b/src/.editorconfig index b3fa9a701e..a0e607577a 100644 --- a/src/.editorconfig +++ b/src/.editorconfig @@ -1,17 +1,6 @@ +# Remove the line below if you want to inherit .editorconfig settings from higher directories root = true -# All files -[*] -indent_style = space - -# Xml files -[*.{xml,csproj,props,targets,ruleset,nuspec,resx}] -indent_size = 2 - -# Json files -[*.{json,config,nswag}] -indent_size = 2 - # C# files [*.cs] @@ -19,115 +8,154 @@ indent_size = 2 # Indentation and spacing indent_size = 4 +indent_style = space tab_width = 4 # New line preferences -end_of_line = lf -insert_final_newline = true +end_of_line = crlf +insert_final_newline = false + +#### .NET Code Actions #### + +# Type members +dotnet_hide_advanced_members = false +dotnet_member_insertion_location = with_other_members_of_the_same_kind +dotnet_property_generation_behavior = prefer_throwing_properties + +# Symbol search +dotnet_search_reference_assemblies = true #### .NET Coding Conventions #### -[*.{cs,vb}] # Organize usings dotnet_separate_import_directive_groups = false -dotnet_sort_system_directives_first = true +dotnet_sort_system_directives_first = false file_header_template = unset # this. and Me. preferences -dotnet_style_qualification_for_event = false:silent -dotnet_style_qualification_for_field = false:silent -dotnet_style_qualification_for_method = false:silent -dotnet_style_qualification_for_property = false:silent +dotnet_style_qualification_for_event = false +dotnet_style_qualification_for_field = false +dotnet_style_qualification_for_method = false +dotnet_style_qualification_for_property = false # Language keywords vs BCL types preferences -dotnet_style_predefined_type_for_locals_parameters_members = true:silent -dotnet_style_predefined_type_for_member_access = true:silent +dotnet_style_predefined_type_for_locals_parameters_members = true +dotnet_style_predefined_type_for_member_access = true # Parentheses preferences -dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent -dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent -dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent -dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_operators = never_if_unnecessary +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity # Modifier preferences -dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent +dotnet_style_require_accessibility_modifiers = for_non_interface_members # Expression-level preferences -dotnet_style_coalesce_expression = true:suggestion -dotnet_style_collection_initializer = true:suggestion -dotnet_style_explicit_tuple_names = true:suggestion -dotnet_style_null_propagation = true:suggestion -dotnet_style_object_initializer = true:suggestion +dotnet_prefer_system_hash_code = true +dotnet_style_coalesce_expression = true +dotnet_style_collection_initializer = true +dotnet_style_explicit_tuple_names = true +dotnet_style_namespace_match_folder = true +dotnet_style_null_propagation = true +dotnet_style_object_initializer = true dotnet_style_operator_placement_when_wrapping = beginning_of_line -dotnet_style_prefer_auto_properties = true:suggestion -dotnet_style_prefer_compound_assignment = true:suggestion -dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion -dotnet_style_prefer_conditional_expression_over_return = true:suggestion -dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion -dotnet_style_prefer_inferred_tuple_names = true:suggestion -dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion -dotnet_style_prefer_simplified_boolean_expressions = true:suggestion -dotnet_style_prefer_simplified_interpolation = true:suggestion +dotnet_style_prefer_auto_properties = true +dotnet_style_prefer_collection_expression = when_types_loosely_match +dotnet_style_prefer_compound_assignment = true +dotnet_style_prefer_conditional_expression_over_assignment = true +dotnet_style_prefer_conditional_expression_over_return = true +dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed +dotnet_style_prefer_inferred_anonymous_type_member_names = true +dotnet_style_prefer_inferred_tuple_names = true +dotnet_style_prefer_is_null_check_over_reference_equality_method = true +dotnet_style_prefer_simplified_boolean_expressions = true +dotnet_style_prefer_simplified_interpolation = true # Field preferences -dotnet_style_readonly_field = true:warning +dotnet_style_readonly_field = true # Parameter preferences -dotnet_code_quality_unused_parameters = all:suggestion +dotnet_code_quality_unused_parameters = all # Suppression preferences dotnet_remove_unnecessary_suppression_exclusions = none +# New line preferences +dotnet_style_allow_multiple_blank_lines_experimental = true +dotnet_style_allow_statement_immediately_after_block_experimental = true + #### C# Coding Conventions #### -[*.cs] # var preferences -csharp_style_var_elsewhere = false:silent -csharp_style_var_for_built_in_types = false:silent -csharp_style_var_when_type_is_apparent = false:silent +csharp_style_var_elsewhere = false +csharp_style_var_for_built_in_types = false +csharp_style_var_when_type_is_apparent = false # Expression-bodied members csharp_style_expression_bodied_accessors = true:silent csharp_style_expression_bodied_constructors = false:silent csharp_style_expression_bodied_indexers = true:silent -csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_lambdas = true csharp_style_expression_bodied_local_functions = false:silent csharp_style_expression_bodied_methods = false:silent csharp_style_expression_bodied_operators = false:silent csharp_style_expression_bodied_properties = true:silent # Pattern matching preferences -csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion -csharp_style_prefer_not_pattern = true:suggestion -csharp_style_prefer_pattern_matching = true:silent -csharp_style_prefer_switch_expression = true:suggestion +csharp_style_prefer_extended_property_pattern = true +csharp_style_prefer_not_pattern = true +csharp_style_prefer_pattern_matching = true +csharp_style_prefer_switch_expression = true:warning # Null-checking preferences csharp_style_conditional_delegate_call = true:suggestion # Modifier preferences -csharp_prefer_static_local_function = true:warning -csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent +csharp_prefer_static_anonymous_function = true +csharp_prefer_static_local_function = true:suggestion +csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async +csharp_style_prefer_readonly_struct = true +csharp_style_prefer_readonly_struct_member = true # Code-block preferences csharp_prefer_braces = true:silent csharp_prefer_simple_using_statement = true:suggestion +csharp_prefer_system_threading_lock = true:suggestion +csharp_style_namespace_declarations = file_scoped +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_top_level_statements = true:silent # Expression-level preferences -csharp_prefer_simple_default_expression = true:suggestion -csharp_style_deconstructed_variable_declaration = true:suggestion -csharp_style_inlined_variable_declaration = true:suggestion -csharp_style_pattern_local_over_anonymous_function = true:suggestion -csharp_style_prefer_index_operator = true:suggestion -csharp_style_prefer_range_operator = true:suggestion -csharp_style_throw_expression = true:suggestion -csharp_style_unused_value_assignment_preference = discard_variable:suggestion -csharp_style_unused_value_expression_statement_preference = discard_variable:silent +csharp_prefer_simple_default_expression = true +csharp_style_deconstructed_variable_declaration = true +csharp_style_implicit_object_creation_when_type_is_apparent = true +csharp_style_inlined_variable_declaration = true +csharp_style_prefer_implicitly_typed_lambda_expression = true +csharp_style_prefer_index_operator = true +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_prefer_null_check_over_type_check = true +csharp_style_prefer_range_operator = true +csharp_style_prefer_tuple_swap = true +csharp_style_prefer_unbound_generic_type_in_nameof = true +csharp_style_prefer_utf8_string_literals = true +csharp_style_throw_expression = true +csharp_style_unused_value_assignment_preference = discard_variable +csharp_style_unused_value_expression_statement_preference = discard_variable # 'using' directive preferences csharp_using_directive_placement = outside_namespace:silent +# New line preferences +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true +csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true +csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true +csharp_style_allow_embedded_statements_on_same_line_experimental = true + #### C# Formatting Rules #### # New line preferences @@ -174,224 +202,67 @@ csharp_space_between_square_brackets = false # Wrapping preferences csharp_preserve_single_line_blocks = true csharp_preserve_single_line_statements = true -csharp_style_namespace_declarations = file_scoped:silent -csharp_style_prefer_method_group_conversion = true:silent -csharp_style_prefer_top_level_statements = true:silent -csharp_style_prefer_primary_constructors = true:suggestion -csharp_style_prefer_null_check_over_type_check = true:suggestion -csharp_style_prefer_local_over_anonymous_function = true:suggestion -csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion -csharp_style_prefer_tuple_swap = true:suggestion -csharp_style_prefer_utf8_string_literals = true:suggestion -dotnet_diagnostic.CA1032.severity = none -dotnet_diagnostic.CA1812.severity = none -dotnet_diagnostic.S6667.severity = none #### Naming styles #### -[*.{cs,vb}] # Naming rules -dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces -dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion -dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces -dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase - -dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion -dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters -dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase - -dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods -dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties -dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.events_should_be_pascalcase.symbols = events -dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion -dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables -dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase - -dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion -dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants -dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase - -dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion -dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters -dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase - -dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields -dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion -dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields -dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase - -dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion -dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields -dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i -dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields -dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case -dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields -dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields -dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields -dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums -dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions -dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members -dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case # Symbol specifications -dotnet_naming_symbols.interfaces.applicable_kinds = interface -dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.interfaces.required_modifiers = - -dotnet_naming_symbols.enums.applicable_kinds = enum -dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.enums.required_modifiers = - -dotnet_naming_symbols.events.applicable_kinds = event -dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.events.required_modifiers = - -dotnet_naming_symbols.methods.applicable_kinds = method -dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.methods.required_modifiers = - -dotnet_naming_symbols.properties.applicable_kinds = property -dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.properties.required_modifiers = +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = -dotnet_naming_symbols.public_fields.applicable_kinds = field -dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal -dotnet_naming_symbols.public_fields.required_modifiers = - -dotnet_naming_symbols.private_fields.applicable_kinds = field -dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected -dotnet_naming_symbols.private_fields.required_modifiers = - -dotnet_naming_symbols.private_static_fields.applicable_kinds = field -dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected -dotnet_naming_symbols.private_static_fields.required_modifiers = static - -dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum -dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.types_and_namespaces.required_modifiers = +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected dotnet_naming_symbols.non_field_members.required_modifiers = -dotnet_naming_symbols.type_parameters.applicable_kinds = namespace -dotnet_naming_symbols.type_parameters.applicable_accessibilities = * -dotnet_naming_symbols.type_parameters.required_modifiers = - -dotnet_naming_symbols.private_constant_fields.applicable_kinds = field -dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected -dotnet_naming_symbols.private_constant_fields.required_modifiers = const - -dotnet_naming_symbols.local_variables.applicable_kinds = local -dotnet_naming_symbols.local_variables.applicable_accessibilities = local -dotnet_naming_symbols.local_variables.required_modifiers = - -dotnet_naming_symbols.local_constants.applicable_kinds = local -dotnet_naming_symbols.local_constants.applicable_accessibilities = local -dotnet_naming_symbols.local_constants.required_modifiers = const - -dotnet_naming_symbols.parameters.applicable_kinds = parameter -dotnet_naming_symbols.parameters.applicable_accessibilities = * -dotnet_naming_symbols.parameters.required_modifiers = - -dotnet_naming_symbols.public_constant_fields.applicable_kinds = field -dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal -dotnet_naming_symbols.public_constant_fields.required_modifiers = const - -dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field -dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal -dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static - -dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field -dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected -dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static - -dotnet_naming_symbols.local_functions.applicable_kinds = local_function -dotnet_naming_symbols.local_functions.applicable_accessibilities = * -dotnet_naming_symbols.local_functions.required_modifiers = - # Naming styles -dotnet_naming_style.pascalcase.required_prefix = -dotnet_naming_style.pascalcase.required_suffix = -dotnet_naming_style.pascalcase.word_separator = -dotnet_naming_style.pascalcase.capitalization = pascal_case - -dotnet_naming_style.ipascalcase.required_prefix = I -dotnet_naming_style.ipascalcase.required_suffix = -dotnet_naming_style.ipascalcase.word_separator = -dotnet_naming_style.ipascalcase.capitalization = pascal_case - -dotnet_naming_style.tpascalcase.required_prefix = T -dotnet_naming_style.tpascalcase.required_suffix = -dotnet_naming_style.tpascalcase.word_separator = -dotnet_naming_style.tpascalcase.capitalization = pascal_case +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case -dotnet_naming_style._camelcase.required_prefix = _ -dotnet_naming_style._camelcase.required_suffix = -dotnet_naming_style._camelcase.word_separator = -dotnet_naming_style._camelcase.capitalization = camel_case +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case -dotnet_naming_style.camelcase.required_prefix = -dotnet_naming_style.camelcase.required_suffix = -dotnet_naming_style.camelcase.word_separator = -dotnet_naming_style.camelcase.capitalization = camel_case - -dotnet_naming_style.s_camelcase.required_prefix = s_ -dotnet_naming_style.s_camelcase.required_suffix = -dotnet_naming_style.s_camelcase.word_separator = -dotnet_naming_style.s_camelcase.capitalization = camel_case - -dotnet_style_namespace_match_folder = true:suggestion +# Namespace Preferences +csharp_style_namespace_declarations = file_scoped:silent +dotnet_style_namespace_match_folder = false +dotnet_diagnostic.S3358.severity = error -dotnet_diagnostic.CS1591.severity = none +[*.{cs,vb}] +dotnet_style_operator_placement_when_wrapping = beginning_of_line +tab_width = 4 +indent_size = 4 +end_of_line = crlf +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_diagnostic.CA2007.severity = none +dotnet_diagnostic.CA1034.severity = none dotnet_diagnostic.CA1724.severity = none -dotnet_diagnostic.CA1305.severity = none +dotnet_diagnostic.CA1819.severity = none dotnet_diagnostic.CA1040.severity = none dotnet_diagnostic.CA1848.severity = none -dotnet_diagnostic.CA1034.severity = none -tab_width = 4 -indent_size = 4 -end_of_line = lf -dotnet_diagnostic.CA1711.severity = none -dotnet_diagnostic.CA1716.severity = none + +[**/Migrations.PostgreSQL/**/*.cs] dotnet_diagnostic.CA1062.severity = none -dotnet_diagnostic.CA1031.severity = none dotnet_diagnostic.CA1861.severity = none -dotnet_diagnostic.CA2007.severity = none \ No newline at end of file diff --git a/src/BuildingBlocks/Blazor.UI/Blazor.UI.csproj b/src/BuildingBlocks/Blazor.UI/Blazor.UI.csproj new file mode 100644 index 0000000000..cccdeac8f8 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Blazor.UI.csproj @@ -0,0 +1,23 @@ + + + + FSH.Framework.Blazor.UI + FSH.Framework.Blazor.UI + FullStackHero.Framework.Blazor.UI + + $(NoWarn);MUD0002 + + + + + + + + + + + + + + + diff --git a/src/BuildingBlocks/Blazor.UI/Components/Avatars/FshAvatar.razor b/src/BuildingBlocks/Blazor.UI/Components/Avatars/FshAvatar.razor new file mode 100644 index 0000000000..32df28ca73 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Avatars/FshAvatar.razor @@ -0,0 +1,15 @@ + + +@code { + [Parameter] public string? Src { get; set; } + [Parameter] public string? Text { get; set; } + [Parameter] public Size Size { get; set; } = Size.Medium; + [Parameter] public string? Class { get; set; } + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } + + private string CssClass => string.IsNullOrWhiteSpace(Class) ? "fsh-avatar" : $"fsh-avatar {Class}"; +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Base/FshComponentBase.cs b/src/BuildingBlocks/Blazor.UI/Components/Base/FshComponentBase.cs new file mode 100644 index 0000000000..d7cdb51e3a --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Base/FshComponentBase.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Components; + +namespace FSH.Framework.Blazor.UI.Components.Base; + +public abstract class FshComponentBase : ComponentBase +{ + [Inject] protected ISnackbar Snackbar { get; set; } = default!; + [Inject] protected IDialogService DialogService { get; set; } = default!; + [Inject] protected NavigationManager Navigation { get; set; } = default!; +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Button/FshButton.razor b/src/BuildingBlocks/Blazor.UI/Components/Button/FshButton.razor new file mode 100644 index 0000000000..a59af404f1 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Button/FshButton.razor @@ -0,0 +1,20 @@ +@inherits FshComponentBase + + + @ChildContent + + +@code { + [Parameter] public Color Color { get; set; } = Color.Primary; + [Parameter] public Variant Variant { get; set; } = Variant.Filled; + [Parameter] public string? StartIcon { get; set; } + [Parameter] public bool Disabled { get; set; } + [Parameter] public EventCallback OnClick { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Cards/FshCard.razor b/src/BuildingBlocks/Blazor.UI/Components/Cards/FshCard.razor new file mode 100644 index 0000000000..07f9ab05ef --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Cards/FshCard.razor @@ -0,0 +1,11 @@ + + @ChildContent + + +@code { + [Parameter] public string? Class { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } + + private string CssClass => string.IsNullOrWhiteSpace(Class) ? "fsh-card" : $"fsh-card {Class}"; +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Chips/FshChip.razor b/src/BuildingBlocks/Blazor.UI/Components/Chips/FshChip.razor new file mode 100644 index 0000000000..8bc3d90e43 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Chips/FshChip.razor @@ -0,0 +1,20 @@ + + @ChildContent + + +@code { + [Parameter] public Color Color { get; set; } = Color.Default; + [Parameter] public Variant Variant { get; set; } = Variant.Filled; + [Parameter] public string? Icon { get; set; } + [Parameter] public string? Class { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } + + private string CssClass => string.IsNullOrWhiteSpace(Class) ? "fsh-chip" : $"fsh-chip {Class}"; +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Data/FshTable.razor b/src/BuildingBlocks/Blazor.UI/Components/Data/FshTable.razor new file mode 100644 index 0000000000..d3e5c9f54e --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Data/FshTable.razor @@ -0,0 +1,24 @@ +@typeparam TItem + + + @HeaderContent + + + @RowTemplate + + + +@code { + [Parameter] public IEnumerable Items { get; set; } = Array.Empty(); + [Parameter] public bool Loading { get; set; } + [Parameter] public RenderFragment? HeaderContent { get; set; } + [Parameter] public RenderFragment RowTemplate { get; set; } = default!; + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Data/Pagination/FshPagination.razor b/src/BuildingBlocks/Blazor.UI/Components/Data/Pagination/FshPagination.razor new file mode 100644 index 0000000000..2ce803d5c4 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Data/Pagination/FshPagination.razor @@ -0,0 +1,17 @@ +@inherits FshComponentBase + + +@code { + [Parameter] public int Page { get; set; } = 1; + [Parameter] public int PageSize { get; set; } = 10; + [Parameter] public int[] PageSizeOptions { get; set; } = [10, 20, 50]; + [Parameter] public EventCallback PageChanged { get; set; } + [Parameter] public EventCallback PageSizeChanged { get; set; } +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Dialogs/FshConfirmDialog.razor b/src/BuildingBlocks/Blazor.UI/Components/Dialogs/FshConfirmDialog.razor new file mode 100644 index 0000000000..9365c2f925 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Dialogs/FshConfirmDialog.razor @@ -0,0 +1,74 @@ +@namespace FSH.Framework.Blazor.UI.Components.Dialogs + + + +
+ @if (!string.IsNullOrEmpty(Icon)) + { + + } + @Title +
+
+ +
+ @if (ContentFragment is not null) + { + @ContentFragment + } + else + { + @Message + } +
+
+ +
+ + @CancelText + + + @ConfirmText + +
+
+
+ +@code { + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public string Title { get; set; } = "Confirm"; + + [Parameter] + public string Message { get; set; } = "Are you sure you want to proceed?"; + + [Parameter] + public RenderFragment? ContentFragment { get; set; } + + [Parameter] + public string Icon { get; set; } = Icons.Material.Outlined.Help; + + [Parameter] + public Color IconColor { get; set; } = Color.Primary; + + [Parameter] + public string ConfirmText { get; set; } = "Confirm"; + + [Parameter] + public string CancelText { get; set; } = "Cancel"; + + [Parameter] + public Color ConfirmColor { get; set; } = Color.Primary; + + private void Confirm() => MudDialog.Close(DialogResult.Ok(true)); + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Dialogs/FshConfirmDialog.razor.css b/src/BuildingBlocks/Blazor.UI/Components/Dialogs/FshConfirmDialog.razor.css new file mode 100644 index 0000000000..6994706167 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Dialogs/FshConfirmDialog.razor.css @@ -0,0 +1,57 @@ +::deep .fsh-confirm-dialog { + border-radius: 16px; + overflow: hidden; +} + +::deep .fsh-confirm-dialog .mud-dialog-title { + padding: 24px 24px 0 24px; +} + +::deep .fsh-confirm-dialog .mud-dialog-content { + padding: 16px 24px; +} + +::deep .fsh-confirm-dialog .mud-dialog-actions { + padding: 16px 24px 24px 24px; +} + +.fsh-dialog-header { + display: flex; + align-items: center; + gap: 12px; +} + +.fsh-dialog-icon { + flex-shrink: 0; +} + +.fsh-dialog-title { + font-weight: 600; + color: var(--mud-palette-text-primary); +} + +.fsh-dialog-content { + padding-top: 8px; +} + +.fsh-dialog-message { + color: var(--mud-palette-text-secondary); + line-height: 1.6; +} + +.fsh-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + width: 100%; +} + +.fsh-dialog-btn-cancel { + font-weight: 500; +} + +.fsh-dialog-btn-confirm { + font-weight: 600; + border-radius: 8px; + padding: 8px 20px; +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Dialogs/FshDialogService.cs b/src/BuildingBlocks/Blazor.UI/Components/Dialogs/FshDialogService.cs new file mode 100644 index 0000000000..ef1634c4a0 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Dialogs/FshDialogService.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace FSH.Framework.Blazor.UI.Components.Dialogs; + +public static class FshDialogService +{ + public static async Task ShowConfirmAsync( + this IDialogService dialogService, + string title, + string message, + string confirmText = "Confirm", + string cancelText = "Cancel", + Color confirmColor = Color.Primary, + string icon = Icons.Material.Outlined.Help, + Color iconColor = Color.Primary) + { + ArgumentNullException.ThrowIfNull(dialogService); + + var parameters = new DialogParameters + { + { x => x.Title, title }, + { x => x.Message, message }, + { x => x.ConfirmText, confirmText }, + { x => x.CancelText, cancelText }, + { x => x.ConfirmColor, confirmColor }, + { x => x.Icon, icon }, + { x => x.IconColor, iconColor } + }; + + var options = new DialogOptions + { + CloseButton = false, + MaxWidth = MaxWidth.ExtraSmall, + FullWidth = true, + BackdropClick = false, + CloseOnEscapeKey = true + }; + + var dialog = await dialogService.ShowAsync(title, parameters, options); + var result = await dialog.Result; + + return result is not null && !result.Canceled; + } + + public static Task ShowDeleteConfirmAsync( + this IDialogService dialogService, + string itemName = "this item") + { + return dialogService.ShowConfirmAsync( + title: "Delete Confirmation", + message: $"Are you sure you want to delete {itemName}? This action cannot be undone.", + confirmText: "Delete", + cancelText: "Cancel", + confirmColor: Color.Error, + icon: Icons.Material.Outlined.DeleteForever, + iconColor: Color.Error); + } + + public static Task ShowSignOutConfirmAsync(this IDialogService dialogService) + { + return dialogService.ShowConfirmAsync( + title: "Sign Out", + message: "Are you sure you want to sign out of your account?", + confirmText: "Sign Out", + cancelText: "Cancel", + confirmColor: Color.Error, + icon: Icons.Material.Outlined.Logout, + iconColor: Color.Warning); + } +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Display/FshBadge.razor b/src/BuildingBlocks/Blazor.UI/Components/Display/FshBadge.razor new file mode 100644 index 0000000000..ba7debc0b4 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Display/FshBadge.razor @@ -0,0 +1,10 @@ + + @ChildContent + + +@code { + [Parameter] public Color Color { get; set; } = Color.Primary; + [Parameter] public Variant Variant { get; set; } = Variant.Filled; + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Feedback/FshAlert.razor b/src/BuildingBlocks/Blazor.UI/Components/Feedback/FshAlert.razor new file mode 100644 index 0000000000..dcf1f05a31 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Feedback/FshAlert.razor @@ -0,0 +1,16 @@ +@inherits FshComponentBase + + @ChildContent + + +@code { + [Parameter] public Severity Severity { get; set; } = Severity.Info; + [Parameter] public Variant Variant { get; set; } = Variant.Filled; + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Feedback/Snackbar/FshSnackbar.razor.cs b/src/BuildingBlocks/Blazor.UI/Components/Feedback/Snackbar/FshSnackbar.razor.cs new file mode 100644 index 0000000000..4aa1ffd2da --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Feedback/Snackbar/FshSnackbar.razor.cs @@ -0,0 +1,26 @@ +using MudBlazor; + +namespace FSH.Framework.Blazor.UI.Components.Feedback.Snackbar; + +/// +/// Convenience wrapper for snackbar calls with consistent styling. +/// +public sealed class FshSnackbar +{ + private readonly ISnackbar _snackbar; + + public FshSnackbar(ISnackbar snackbar) + { + _snackbar = snackbar; + } + + public void Success(string message) => Add(message, Severity.Success); + public void Info(string message) => Add(message, Severity.Info); + public void Warning(string message) => Add(message, Severity.Warning); + public void Error(string message) => Add(message, Severity.Error); + + private void Add(string message, Severity severity) + { + _snackbar.Add(message, severity); + } +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/FshHead.razor b/src/BuildingBlocks/Blazor.UI/Components/FshHead.razor new file mode 100644 index 0000000000..7a186a88e8 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/FshHead.razor @@ -0,0 +1,5 @@ + + + + + diff --git a/src/BuildingBlocks/Blazor.UI/Components/FshScripts.razor b/src/BuildingBlocks/Blazor.UI/Components/FshScripts.razor new file mode 100644 index 0000000000..35a0d0386b --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/FshScripts.razor @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/BuildingBlocks/Blazor.UI/Components/Inputs/FshCheckbox.razor b/src/BuildingBlocks/Blazor.UI/Components/Inputs/FshCheckbox.razor new file mode 100644 index 0000000000..110827ef9a --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Inputs/FshCheckbox.razor @@ -0,0 +1,18 @@ +@inherits FshComponentBase + + +@code { + [Parameter] public bool Checked { get; set; } + [Parameter] public EventCallback CheckedChanged { get; set; } + [Parameter] public string? Label { get; set; } + [Parameter] public bool Disabled { get; set; } + [Parameter] public Color Color { get; set; } = Color.Primary; + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Inputs/FshSelect.razor b/src/BuildingBlocks/Blazor.UI/Components/Inputs/FshSelect.razor new file mode 100644 index 0000000000..750c161532 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Inputs/FshSelect.razor @@ -0,0 +1,30 @@ +@typeparam TValue +@inherits FshComponentBase + + @ChildContent + + +@code { + [Parameter] public TValue? Value { get; set; } + [Parameter] public EventCallback ValueChanged { get; set; } + [Parameter] public string? Label { get; set; } + [Parameter] public bool Required { get; set; } + [Parameter] public bool Disabled { get; set; } + [Parameter] public Variant Variant { get; set; } = Variant.Outlined; + [Parameter] public Adornment Adornment { get; set; } = Adornment.None; + [Parameter] public string? AdornmentIcon { get; set; } + [Parameter] public Color AdornmentColor { get; set; } = Color.Default; + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Inputs/FshSwitch.razor b/src/BuildingBlocks/Blazor.UI/Components/Inputs/FshSwitch.razor new file mode 100644 index 0000000000..7b2cc2659d --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Inputs/FshSwitch.razor @@ -0,0 +1,15 @@ +@inherits FshComponentBase + + +@code { + [Parameter] public bool Checked { get; set; } + [Parameter] public EventCallback CheckedChanged { get; set; } + [Parameter] public bool Disabled { get; set; } + [Parameter] public Color Color { get; set; } = Color.Primary; + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Inputs/FshTextField.razor b/src/BuildingBlocks/Blazor.UI/Components/Inputs/FshTextField.razor new file mode 100644 index 0000000000..12bc98d6a3 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Inputs/FshTextField.razor @@ -0,0 +1,38 @@ +@using System.Linq.Expressions +@inherits FshComponentBase + + +@code { + [Parameter] public string? Label { get; set; } + [Parameter] public string? Placeholder { get; set; } + [Parameter] public string? Value { get; set; } + [Parameter] public EventCallback ValueChanged { get; set; } + [Parameter] public Expression>? ValueExpression { get; set; } + [Parameter] public Expression>? For { get; set; } + [Parameter] public Variant Variant { get; set; } = Variant.Outlined; + [Parameter] public bool Disabled { get; set; } + [Parameter] public bool Required { get; set; } + [Parameter] public Adornment Adornment { get; set; } = Adornment.None; + [Parameter] public string? AdornmentIcon { get; set; } + [Parameter] public Color AdornmentColor { get; set; } = Color.Default; + [Parameter] public InputType InputType { get; set; } = InputType.Text; + [Parameter] public int Lines { get; set; } = 1; + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Layouts/FshBaseLayout.razor b/src/BuildingBlocks/Blazor.UI/Components/Layouts/FshBaseLayout.razor new file mode 100644 index 0000000000..b0d1fb9167 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Layouts/FshBaseLayout.razor @@ -0,0 +1,131 @@ +@inherits LayoutComponentBase +@using FSH.Framework.Blazor.UI.Components.Button +@using FSH.Framework.Blazor.UI.Components.Layouts +@using Microsoft.AspNetCore.Components.Authorization + + + + + + + + + + + + + + + + + + + + + + + + + + @Body + + + + +
+ An unhandled error has occurred. + Reload + 🗙 +
+ +@code { + private bool _drawerOpen = true; + private bool _isDarkMode = false; + private MudTheme? _theme = null; + + protected override void OnInitialized() + { + base.OnInitialized(); + + _theme = new() + { + PaletteLight = _lightPalette, + PaletteDark = _darkPalette, + LayoutProperties = new LayoutProperties(), + Typography = new Typography() + { + Default = + { + FontFamily = new[] { "Inter", "system-ui", "-apple-system", "BlinkMacSystemFont", "\"Segoe UI\"", "sans-serif" }, + FontSize = "0.95rem", + FontWeight = "400", + LineHeight = "1.5", + LetterSpacing = "0.02em" + } + } + }; + } + + private void DrawerToggle() + { + _drawerOpen = !_drawerOpen; + } + + private void DarkModeToggle() + { + _isDarkMode = !_isDarkMode; + } + + private readonly PaletteLight _lightPalette = new() + { + Primary = "#111827", + Secondary = "#4B5563", + Surface = "#F9FAFB", + Background = "#F3F4F6", + AppbarText = "#111827", + AppbarBackground = "#F9FAFB", + DrawerBackground = "#FFFFFF", + TextPrimary = "#111827", + TextSecondary = "#4B5563", + GrayLight = "#E5E7EB", + GrayLighter = "#F3F4F6", + }; + + private readonly PaletteDark _darkPalette = new() + { + Primary = "#6366F1", + Secondary = "#9CA3AF", + Surface = "#020617", + Background = "#020617", + BackgroundGray = "#020617", + AppbarText = "#E5E7EB", + AppbarBackground = "#020617", + DrawerBackground = "#020617", + ActionDefault = "#9CA3AF", + ActionDisabled = "#4B5563", + ActionDisabledBackground = "#111827", + TextPrimary = "#F9FAFB", + TextSecondary = "#9CA3AF", + TextDisabled = "#4B5563", + DrawerIcon = "#9CA3AF", + DrawerText = "#E5E7EB", + GrayLight = "#1F2937", + GrayLighter = "#020617", + Info = "#38BDF8", + Success = "#22C55E", + Warning = "#F59E0B", + Error = "#EF4444", + LinesDefault = "#111827", + TableLines = "#111827", + Divider = "#111827", + OverlayLight = "#02061780", + }; + + public string DarkLightModeButtonIcon => _isDarkMode switch + { + true => Icons.Material.Rounded.AutoMode, + false => Icons.Material.Outlined.DarkMode, + }; +} + + diff --git a/src/BuildingBlocks/Blazor.UI/Components/Layouts/FshBaseLayout.razor.cs b/src/BuildingBlocks/Blazor.UI/Components/Layouts/FshBaseLayout.razor.cs new file mode 100644 index 0000000000..3d8beb9730 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Layouts/FshBaseLayout.razor.cs @@ -0,0 +1,5 @@ +namespace FSH.Framework.Blazor.UI.Components.Layouts; + +public partial class FshBaseLayout +{ +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Layouts/FshDrawerHeader.razor b/src/BuildingBlocks/Blazor.UI/Components/Layouts/FshDrawerHeader.razor new file mode 100644 index 0000000000..2e32c61300 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Layouts/FshDrawerHeader.razor @@ -0,0 +1,12 @@ +@inherits FshComponentBase + + + + @Title + + + +@code { + [Parameter] + public string Title { get; set; } = "Fsh Playground"; +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Navigation/FshBreadcrumbs.razor b/src/BuildingBlocks/Blazor.UI/Components/Navigation/FshBreadcrumbs.razor new file mode 100644 index 0000000000..98051a216d --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Navigation/FshBreadcrumbs.razor @@ -0,0 +1,8 @@ + + +@code { + [Parameter] public IEnumerable Items { get; set; } = Array.Empty(); + [Parameter] public int MaxItems { get; set; } = 4; + + private IReadOnlyList GetItemsList() => Items?.ToList() ?? []; +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Page/FshPageHeader.razor b/src/BuildingBlocks/Blazor.UI/Components/Page/FshPageHeader.razor new file mode 100644 index 0000000000..10318c7163 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Page/FshPageHeader.razor @@ -0,0 +1,99 @@ +@using MudBlazor + +@PageTitleText + + + + + @Title + @if (!string.IsNullOrWhiteSpace(Description)) + { + + @Description + + } + @if (DescriptionContent != null) + { + + @DescriptionContent + + } + + @if (ActionContent != null) + { + + @ActionContent + + } + + + +@code { + /// + /// The main title of the page header + /// + [Parameter, EditorRequired] + public string Title { get; set; } = string.Empty; + + /// + /// Optional description text below the title + /// + [Parameter] + public string? Description { get; set; } + + /// + /// Optional render fragment for complex description content + /// + [Parameter] + public RenderFragment? DescriptionContent { get; set; } + + /// + /// Optional action buttons/controls rendered on the right side + /// + [Parameter] + public RenderFragment? ActionContent { get; set; } + + /// + /// Typography style for the title. Default is h4. + /// + [Parameter] + public Typo TitleTypo { get; set; } = Typo.h4; + + /// + /// Elevation of the paper component. Default is 0. + /// + [Parameter] + public int Elevation { get; set; } = 0; + + /// + /// Additional CSS classes to apply + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Optional suffix for the browser page title (e.g., app name). + /// When set, displays as "Title | Suffix". When null, displays only the Title. + /// + [Parameter] + public string? PageTitleSuffix { get; set; } + + /// + /// Allows passing additional attributes + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private string PageTitleText => string.IsNullOrWhiteSpace(PageTitleSuffix) + ? Title + : $"{Title} | {PageTitleSuffix}"; + + private string CombinedClass + { + get + { + var baseClass = "hero-card pa-6 mb-4"; + return string.IsNullOrWhiteSpace(Class) ? baseClass : $"{baseClass} {Class}"; + } + } +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Tabs/FshTabs.razor b/src/BuildingBlocks/Blazor.UI/Components/Tabs/FshTabs.razor new file mode 100644 index 0000000000..5c6eaf7f7e --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Tabs/FshTabs.razor @@ -0,0 +1,12 @@ + + @ChildContent + + +@code { + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Theme/FshBrandAssetsPicker.razor b/src/BuildingBlocks/Blazor.UI/Components/Theme/FshBrandAssetsPicker.razor new file mode 100644 index 0000000000..0dad490cf9 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Theme/FshBrandAssetsPicker.razor @@ -0,0 +1,184 @@ +@using FSH.Framework.Blazor.UI.Theme + + + Brand Assets + + + @* Light Mode Logo *@ + + + Logo (Light Mode) + @if (!string.IsNullOrEmpty(BrandAssets.LogoUrl)) + { +
+ + + Remove + +
+ } + else + { + + + + + Click to upload logo + PNG, JPG, SVG, WebP + + + + } +
+
+ + @* Dark Mode Logo *@ + + + Logo (Dark Mode) + @if (!string.IsNullOrEmpty(BrandAssets.LogoDarkUrl)) + { +
+ + + Remove + +
+ } + else + { + + + + + Click to upload logo + PNG, JPG, SVG, WebP + + + + } +
+
+ + @* Favicon *@ + + + Favicon + @if (!string.IsNullOrEmpty(BrandAssets.FaviconUrl)) + { +
+ + + Remove + +
+ } + else + { + + + + + Click to upload favicon + 16x16 or 32x32 PNG, ICO + + + + } +
+
+
+ + + + Recommended logo size: 200x50px. Favicon: 32x32px. Maximum file size: 2MB. + +
+ +@code { + [Parameter] public BrandAssets BrandAssets { get; set; } = new(); + [Parameter] public EventCallback BrandAssetsChanged { get; set; } + [Parameter] public EventCallback<(IBrowserFile File, string AssetType)> OnFileUpload { get; set; } + + private async Task OnLogoUpload(IBrowserFile file) + { + if (OnFileUpload.HasDelegate) + { + await OnFileUpload.InvokeAsync((file, "logo")); + } + } + + private async Task OnLogoDarkUpload(IBrowserFile file) + { + if (OnFileUpload.HasDelegate) + { + await OnFileUpload.InvokeAsync((file, "logo-dark")); + } + } + + private async Task OnFaviconUpload(IBrowserFile file) + { + if (OnFileUpload.HasDelegate) + { + await OnFileUpload.InvokeAsync((file, "favicon")); + } + } + + private void ClearLogo() + { + BrandAssets.LogoUrl = null; + BrandAssetsChanged.InvokeAsync(BrandAssets); + } + + private void ClearLogoDark() + { + BrandAssets.LogoDarkUrl = null; + BrandAssetsChanged.InvokeAsync(BrandAssets); + } + + private void ClearFavicon() + { + BrandAssets.FaviconUrl = null; + BrandAssetsChanged.InvokeAsync(BrandAssets); + } +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Theme/FshColorPalettePicker.razor b/src/BuildingBlocks/Blazor.UI/Components/Theme/FshColorPalettePicker.razor new file mode 100644 index 0000000000..3fb5f71a19 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Theme/FshColorPalettePicker.razor @@ -0,0 +1,185 @@ +@using FSH.Framework.Blazor.UI.Theme +@using MudBlazor.Utilities + + + @Title + + + + Primary + + + + + Secondary + + + + + Tertiary + + + + + Background + + + + + Surface + + + + + Semantic Colors + + + + Success + + + + + Info + + + + + Warning + + + + + Error + + + + + + +
+ Preview: + @foreach (var color in GetColorSwatches()) + { + + + + } +
+
+ +@code { + [Parameter] public string Title { get; set; } = "Color Palette"; + [Parameter] public PaletteSettings Palette { get; set; } = new(); + [Parameter] public EventCallback PaletteChanged { get; set; } + + private static MudColor GetColor(string hexColor) + { + try + { + return new MudColor(hexColor); + } + catch + { + // Invalid hex color format, return default black + return new MudColor("#000000"); + } + } + + private async Task OnColorChanged(MudColor color, Action setter) + { + var hexValue = color.Value.StartsWith("#", StringComparison.Ordinal) + ? color.Value[..7] // Take only #RRGGBB, ignore alpha + : $"#{color.Value}"[..7]; + setter(hexValue); + await PaletteChanged.InvokeAsync(Palette); + } + + private IEnumerable<(string Name, string Value)> GetColorSwatches() + { + yield return ("Primary", Palette.Primary); + yield return ("Secondary", Palette.Secondary); + yield return ("Tertiary", Palette.Tertiary); + yield return ("Background", Palette.Background); + yield return ("Surface", Palette.Surface); + yield return ("Success", Palette.Success); + yield return ("Info", Palette.Info); + yield return ("Warning", Palette.Warning); + yield return ("Error", Palette.Error); + } +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Theme/FshLayoutPicker.razor b/src/BuildingBlocks/Blazor.UI/Components/Theme/FshLayoutPicker.razor new file mode 100644 index 0000000000..33ef7a0500 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Theme/FshLayoutPicker.razor @@ -0,0 +1,88 @@ +@using FSH.Framework.Blazor.UI.Theme + + + Layout Settings + + + + + @foreach (var radius in LayoutSettings.BorderRadiusOptions) + { + @radius + } + + + + + + Default Elevation: @Layout.DefaultElevation + + + + + + + Preview +
+ + Card with elevation @Layout.DefaultElevation + + + + Button Preview + + + +
+
+ +@code { + [Parameter] public LayoutSettings Layout { get; set; } = new(); + [Parameter] public EventCallback LayoutChanged { get; set; } + + private async Task OnBorderRadiusChanged(string value) + { + Layout.BorderRadius = value; + await LayoutChanged.InvokeAsync(Layout); + } + + private async Task OnElevationChanged(int value) + { + Layout.DefaultElevation = value; + await LayoutChanged.InvokeAsync(Layout); + } + + private string GetCardStyle() + { + return $"border-radius: {Layout.BorderRadius}; min-width: 150px;"; + } + + private string GetButtonStyle() + { + return $"border-radius: {Layout.BorderRadius};"; + } + + private string GetInputStyle() + { + return $"--mud-default-borderradius: {Layout.BorderRadius};"; + } +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Theme/FshThemeCustomizer.razor b/src/BuildingBlocks/Blazor.UI/Components/Theme/FshThemeCustomizer.razor new file mode 100644 index 0000000000..5864847ab7 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Theme/FshThemeCustomizer.razor @@ -0,0 +1,272 @@ +@using FSH.Framework.Blazor.UI.Theme +@using FSH.Framework.Blazor.UI.Components.Base +@inherits FshComponentBase + + + @if (_isLoading) + { + + } + else + { + + @* Left Column - Settings *@ + + + +
+ + + + + + + + +
+
+ + +
+ +
+
+ + +
+ +
+
+ + +
+ +
+
+
+
+ + @* Right Column - Preview *@ + + + + + +
+ } +
+ + + +@code { + /// + /// The theme state service for loading/saving themes. + /// + [Parameter] public ITenantThemeState? ThemeState { get; set; } + + /// + /// Callback when theme is saved successfully. + /// + [Parameter] public EventCallback OnThemeSaved { get; set; } + + /// + /// Callback for handling file uploads (logo, favicon). + /// The consuming application should implement file upload logic. + /// The callback receives (File, AssetType, SetAsset) where SetAsset(url, fileUpload) sets both the preview URL and file data. + /// + [Parameter] public EventCallback<(IBrowserFile File, string AssetType, Action SetAsset)> OnAssetUpload { get; set; } + + private TenantThemeSettings _settings = new(); + private bool _isLoading = true; + private bool _isSaving = false; + + /// + /// Indicates whether the theme is currently being saved. + /// + public bool IsSaving => _isSaving; + + protected override async Task OnInitializedAsync() + { + await LoadTheme(); + } + + private async Task LoadTheme() + { + _isLoading = true; + StateHasChanged(); + + try + { + if (ThemeState != null) + { + await ThemeState.LoadThemeAsync(); + _settings = CloneSettings(ThemeState.Current); + } + } + catch (Exception ex) + { + Snackbar.Add($"Failed to load theme: {ex.Message}", Severity.Error); + } + finally + { + _isLoading = false; + StateHasChanged(); + } + } + + /// + /// Saves the current theme changes. + /// + public async Task SaveChangesAsync() + { + _isSaving = true; + StateHasChanged(); + + try + { + if (ThemeState != null) + { + ThemeState.UpdateSettings(_settings); + await ThemeState.SaveThemeAsync(); + Snackbar.Add("Theme saved successfully!", Severity.Success); + await OnThemeSaved.InvokeAsync(); + } + else + { + Snackbar.Add("Theme state not configured", Severity.Warning); + } + } + catch (Exception ex) + { + Snackbar.Add($"Failed to save theme: {ex.Message}", Severity.Error); + } + finally + { + _isSaving = false; + StateHasChanged(); + } + } + + /// + /// Resets the theme to default settings. + /// + public async Task ResetToDefaultsAsync() + { + var confirmed = await DialogService.ShowMessageBox( + "Reset Theme", + "Are you sure you want to reset all theme settings to defaults? This action cannot be undone.", + yesText: "Reset", + cancelText: "Cancel"); + + if (confirmed == true) + { + try + { + if (ThemeState != null) + { + await ThemeState.ResetThemeAsync(); + _settings = CloneSettings(ThemeState.Current); + Snackbar.Add("Theme reset to defaults", Severity.Success); + StateHasChanged(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Failed to reset theme: {ex.Message}", Severity.Error); + } + } + } + + private async Task HandleFileUpload((IBrowserFile File, string AssetType) args) + { + if (!OnAssetUpload.HasDelegate) + { + Snackbar.Add("File upload not configured", Severity.Warning); + return; + } + + // Callback that sets both URL and file upload data on the internal _settings + Action setAsset = (url, fileUpload) => + { + switch (args.AssetType) + { + case "logo": + _settings.BrandAssets.LogoUrl = url; + _settings.BrandAssets.Logo = fileUpload; + break; + case "logo-dark": + _settings.BrandAssets.LogoDarkUrl = url; + _settings.BrandAssets.LogoDark = fileUpload; + break; + case "favicon": + _settings.BrandAssets.FaviconUrl = url; + _settings.BrandAssets.Favicon = fileUpload; + break; + } + }; + + await OnAssetUpload.InvokeAsync((args.File, args.AssetType, setAsset)); + StateHasChanged(); + } + + private void OnLightPaletteChanged(PaletteSettings palette) + { + _settings.LightPalette = palette; + StateHasChanged(); + } + + private void OnDarkPaletteChanged(PaletteSettings palette) + { + _settings.DarkPalette = palette; + StateHasChanged(); + } + + private void OnBrandAssetsChanged(BrandAssets assets) + { + _settings.BrandAssets = assets; + StateHasChanged(); + } + + private void OnTypographyChanged(TypographySettings typography) + { + _settings.Typography = typography; + StateHasChanged(); + } + + private void OnLayoutChanged(LayoutSettings layout) + { + _settings.Layout = layout; + StateHasChanged(); + } + + private static TenantThemeSettings CloneSettings(TenantThemeSettings source) + { + return new TenantThemeSettings + { + LightPalette = source.LightPalette.Clone(), + DarkPalette = source.DarkPalette.Clone(), + BrandAssets = new BrandAssets + { + LogoUrl = source.BrandAssets.LogoUrl, + LogoDarkUrl = source.BrandAssets.LogoDarkUrl, + FaviconUrl = source.BrandAssets.FaviconUrl + }, + Typography = source.Typography.Clone(), + Layout = source.Layout.Clone(), + IsDefault = source.IsDefault + }; + } +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Theme/FshThemePreview.razor b/src/BuildingBlocks/Blazor.UI/Components/Theme/FshThemePreview.razor new file mode 100644 index 0000000000..e05e942580 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Theme/FshThemePreview.razor @@ -0,0 +1,167 @@ +@using FSH.Framework.Blazor.UI.Theme + + + Live Preview + + + + + Light + + + + Dark + + + + + @* App Bar Preview *@ + +
+ @if (!string.IsNullOrEmpty(GetLogoUrl())) + { + + } + else + { + + } + Brand Name + + + +
+
+ + @* Content Preview *@ + + @* Cards *@ + + + Dashboard Card + + This is how your content cards will look with the selected theme settings. + +
+ + Primary + + + Outlined + +
+
+
+ + @* Alerts *@ + + + Alerts + + Success message + + + Info message + + + Warning message + + + Error message + + + + + @* Form Elements *@ + + + Form Elements + + + + + + + Option 1 + Option 2 + + + + + + + + + +
+
+
+ +@code { + [Parameter] public TenantThemeSettings Settings { get; set; } = new(); + + private bool _previewDarkMode = false; + + private PaletteSettings CurrentPalette => _previewDarkMode ? Settings.DarkPalette : Settings.LightPalette; + + private string? GetLogoUrl() + { + return _previewDarkMode ? Settings.BrandAssets.LogoDarkUrl : Settings.BrandAssets.LogoUrl; + } + + private string GetPreviewContainerStyle() + { + return $"background-color: {CurrentPalette.Background}; border-radius: {Settings.Layout.BorderRadius}; font-family: {Settings.Typography.FontFamily}; font-size: {Settings.Typography.FontSizeBase}px; line-height: {Settings.Typography.LineHeightBase};"; + } + + private string GetAppBarStyle() + { + return $"background-color: {CurrentPalette.Surface}; border-radius: {Settings.Layout.BorderRadius};"; + } + + private string GetAppBarTextStyle() + { + return $"color: {(_previewDarkMode ? "#E2E8F0" : CurrentPalette.Secondary)};"; + } + + private string GetCardStyle() + { + return $"background-color: {CurrentPalette.Surface}; border-radius: {Settings.Layout.BorderRadius};"; + } + + private string GetTextStyle() + { + return $"color: {(_previewDarkMode ? "#E2E8F0" : CurrentPalette.Secondary)}; font-family: {Settings.Typography.HeadingFontFamily};"; + } + + private string GetSecondaryTextStyle() + { + return $"color: {(_previewDarkMode ? "#CBD5E1" : "#475569")};"; + } + + private string GetPrimaryButtonStyle() + { + return $"background-color: {CurrentPalette.Primary}; color: white; border-radius: {Settings.Layout.BorderRadius};"; + } + + private string GetOutlinedButtonStyle() + { + return $"border-color: {CurrentPalette.Primary}; color: {CurrentPalette.Primary}; border-radius: {Settings.Layout.BorderRadius};"; + } + + private string GetAlertStyle() + { + return $"border-radius: {Settings.Layout.BorderRadius};"; + } + + private string GetInputStyle() + { + return $"--mud-palette-primary: {CurrentPalette.Primary};"; + } +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Theme/FshTypographyPicker.razor b/src/BuildingBlocks/Blazor.UI/Components/Theme/FshTypographyPicker.razor new file mode 100644 index 0000000000..6be7bb25fd --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Theme/FshTypographyPicker.razor @@ -0,0 +1,124 @@ +@using FSH.Framework.Blazor.UI.Theme + + + Typography + + + + + @foreach (var font in TypographySettings.WebSafeFonts) + { + + @font.Split(',')[0] + + } + + + + + + @foreach (var font in TypographySettings.WebSafeFonts) + { + + @font.Split(',')[0] + + } + + + + + + + + + + + + + + + Preview + +
+ Heading Text +
+ + This is body text. The quick brown fox jumps over the lazy dog. + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + + + Caption text with secondary color + +
+
+ +@code { + [Parameter] public TypographySettings Typography { get; set; } = new(); + [Parameter] public EventCallback TypographyChanged { get; set; } + + private async Task OnFontFamilyChanged(string value) + { + Typography.FontFamily = value; + await TypographyChanged.InvokeAsync(Typography); + } + + private async Task OnHeadingFontFamilyChanged(string value) + { + Typography.HeadingFontFamily = value; + await TypographyChanged.InvokeAsync(Typography); + } + + private async Task OnFontSizeChanged(double value) + { + Typography.FontSizeBase = value; + await TypographyChanged.InvokeAsync(Typography); + } + + private async Task OnLineHeightChanged(double value) + { + Typography.LineHeightBase = value; + await TypographyChanged.InvokeAsync(Typography); + } + + private string GetPreviewStyle() + { + return $"font-family: {Typography.FontFamily}; font-size: {Typography.FontSizeBase}px; line-height: {Typography.LineHeightBase};"; + } + + private string GetHeadingStyle() + { + return $"font-family: {Typography.HeadingFontFamily};"; + } +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/Title/FshPageTitle.razor b/src/BuildingBlocks/Blazor.UI/Components/Title/FshPageTitle.razor new file mode 100644 index 0000000000..2d99ae9d5d --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/Title/FshPageTitle.razor @@ -0,0 +1,5 @@ +

FshPageTitle

+ +@code { + +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/User/FshAccountMenu.razor b/src/BuildingBlocks/Blazor.UI/Components/User/FshAccountMenu.razor new file mode 100644 index 0000000000..b917d083ca --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/User/FshAccountMenu.razor @@ -0,0 +1,134 @@ +@namespace FSH.Framework.Blazor.UI.Components.User +@inherits FSH.Framework.Blazor.UI.Components.Base.FshComponentBase + + + +@code { + [Parameter, EditorRequired] + public string UserName { get; set; } = "User"; + + [Parameter] + public string? UserEmail { get; set; } + + [Parameter] + public string? UserRole { get; set; } + + [Parameter] + public string? AvatarUrl { get; set; } + + [Parameter] + public EventCallback OnProfileClick { get; set; } + + [Parameter] + public EventCallback OnAuditingClick { get; set; } + + [Parameter] + public EventCallback OnLogoutClick { get; set; } + + private static string GetInitials(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + return "U"; + + var parts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2) + return $"{parts[0][0]}{parts[1][0]}".ToUpperInvariant(); + + return name.Length >= 2 ? name[..2].ToUpperInvariant() : name.ToUpperInvariant(); + } + + private async Task HandleProfileClick() + { + if (OnProfileClick.HasDelegate) + await OnProfileClick.InvokeAsync(); + else + Navigation.NavigateTo("/profile"); + } + + private async Task HandleAuditingClick() + { + if (OnAuditingClick.HasDelegate) + await OnAuditingClick.InvokeAsync(); + else + Navigation.NavigateTo("/audits"); + } + + private async Task HandleLogoutClick() + { + if (OnLogoutClick.HasDelegate) + await OnLogoutClick.InvokeAsync(); + } +} diff --git a/src/BuildingBlocks/Blazor.UI/Components/User/FshAccountMenu.razor.css b/src/BuildingBlocks/Blazor.UI/Components/User/FshAccountMenu.razor.css new file mode 100644 index 0000000000..863640207d --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Components/User/FshAccountMenu.razor.css @@ -0,0 +1,167 @@ +.fsh-account-menu { + display: flex; + align-items: center; + margin-left: 8px; +} + +/* Trigger button */ +::deep .fsh-account-trigger { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 24px; + min-width: auto; + text-transform: none; + transition: background-color 0.2s ease; +} + +::deep .fsh-account-trigger:hover { + background-color: rgba(255, 255, 255, 0.1); +} + +::deep .fsh-trigger-avatar { + width: 32px; + height: 32px; + font-size: 13px; + font-weight: 600; + background: linear-gradient(135deg, var(--mud-palette-primary), var(--mud-palette-secondary)); + color: white; + border: 2px solid rgba(255, 255, 255, 0.2); +} + +::deep .fsh-trigger-arrow { + opacity: 0.7; + margin-left: 2px; +} + +/* Popover container */ +::deep .fsh-account-popover { + border-radius: 12px; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12), 0 4px 12px rgba(0, 0, 0, 0.08); + border: 1px solid rgba(0, 0, 0, 0.08); + overflow: hidden; + min-width: 280px; + margin-top: 8px; +} + +.fsh-account-dropdown { + background: var(--mud-palette-surface); +} + +/* Header section */ +.fsh-dropdown-header { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 20px; + background: linear-gradient(135deg, rgba(var(--mud-palette-primary-rgb), 0.08), rgba(var(--mud-palette-secondary-rgb), 0.04)); +} + +::deep .fsh-header-avatar { + width: 52px; + height: 52px; + font-size: 18px; + font-weight: 600; + background: linear-gradient(135deg, var(--mud-palette-primary), var(--mud-palette-secondary)); + color: white; + flex-shrink: 0; + box-shadow: 0 4px 12px rgba(var(--mud-palette-primary-rgb), 0.3); +} + +.fsh-header-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.fsh-header-name { + font-weight: 600; + font-size: 15px; + color: var(--mud-palette-text-primary); + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.fsh-header-email { + font-size: 13px; + color: var(--mud-palette-text-secondary); + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +::deep .fsh-header-role { + margin-top: 6px; + height: 22px; + font-size: 11px; + font-weight: 500; + align-self: flex-start; +} + +/* Menu sections */ +.fsh-dropdown-menu { + padding: 8px; +} + +.fsh-dropdown-footer { + padding: 8px; + background: rgba(0, 0, 0, 0.02); +} + +/* Menu items */ +::deep .fsh-menu-item { + border-radius: 8px; + margin: 2px 0; + padding: 10px 12px; + transition: all 0.15s ease; +} + +::deep .fsh-menu-item:hover { + background: rgba(var(--mud-palette-primary-rgb), 0.08); +} + +::deep .fsh-menu-item .mud-icon-root { + margin-right: 12px; + color: var(--mud-palette-text-secondary); +} + +::deep .fsh-menu-item:hover .mud-icon-root { + color: var(--mud-palette-primary); +} + +.fsh-menu-item-content { + display: flex; + flex-direction: column; + gap: 2px; +} + +.fsh-menu-item-text { + font-weight: 500; + font-size: 14px; + color: var(--mud-palette-text-primary); +} + +.fsh-menu-item-desc { + font-size: 12px; + color: var(--mud-palette-text-secondary); + line-height: 1.3; +} + +/* Danger item (sign out) */ +::deep .fsh-menu-item-danger { + color: var(--mud-palette-error); +} + +::deep .fsh-menu-item-danger:hover { + background: rgba(var(--mud-palette-error-rgb), 0.08); +} + +::deep .fsh-menu-item-danger .mud-icon-root { + color: var(--mud-palette-error); +} diff --git a/src/BuildingBlocks/Blazor.UI/GlobalUsings.cs b/src/BuildingBlocks/Blazor.UI/GlobalUsings.cs new file mode 100644 index 0000000000..202f057404 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using MudBlazor; +global using MudBlazor.Services; \ No newline at end of file diff --git a/src/BuildingBlocks/Blazor.UI/ServiceCollectionExtensions.cs b/src/BuildingBlocks/Blazor.UI/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..74a56e80cd --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/ServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.DependencyInjection; +namespace FSH.Framework.Blazor.UI; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddHeroUI(this IServiceCollection services) + { + services.AddMudServices(options => + { + options.SnackbarConfiguration.PositionClass = Defaults.Classes.Position.BottomRight; + options.SnackbarConfiguration.ShowCloseIcon = true; + options.SnackbarConfiguration.SnackbarVariant = Variant.Filled; + options.SnackbarConfiguration.MaxDisplayedSnackbars = 3; + }); + + services.AddMudPopoverService(); + services.AddScoped(); + services.AddSingleton(FSH.Framework.Blazor.UI.Theme.FshTheme.Build()); + + return services; + } +} diff --git a/src/BuildingBlocks/Blazor.UI/Theme/FshTheme.cs b/src/BuildingBlocks/Blazor.UI/Theme/FshTheme.cs new file mode 100644 index 0000000000..f4d8410e5c --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Theme/FshTheme.cs @@ -0,0 +1,54 @@ +namespace FSH.Framework.Blazor.UI.Theme; + +public static class FshTheme +{ + public static MudTheme Build() + { + // Shadcn-inspired, subtle palette: neutral surfaces, soft primary. + return new MudTheme + { + PaletteLight = new PaletteLight + { + Primary = "#2563EB", // blue-600 + Secondary = "#0F172A", // slate-900 + Tertiary = "#6366F1", // indigo-500 + Background = "#F8FAFC", // slate-50 + Surface = "#FFFFFF", + AppbarBackground = "#F8FAFC", + AppbarText = "#0F172A", + DrawerBackground = "#FFFFFF", + TextPrimary = "#0F172A", + TextSecondary = "#475569", // slate-600 + Info = "#0284C7", + Success = "#16A34A", + Warning = "#F59E0B", + Error = "#DC2626", + TableLines = "#E2E8F0", + Divider = "#E2E8F0" + }, + PaletteDark = new PaletteDark + { + Primary = "#38BDF8", // sky-400 + Secondary = "#94A3B8", // slate-400 + Tertiary = "#818CF8", // indigo-400 + Background = "#0B1220", + Surface = "#111827", + AppbarBackground = "#0B1220", + AppbarText = "#E2E8F0", + DrawerBackground = "#0B1220", + TextPrimary = "#E2E8F0", + TextSecondary = "#CBD5E1", // slate-300 + Info = "#38BDF8", + Success = "#22C55E", + Warning = "#FBBF24", + Error = "#F87171", + TableLines = "#1F2937", + Divider = "#1F2937" + }, + LayoutProperties = new LayoutProperties + { + DefaultBorderRadius = "4px" + } + }; + } +} diff --git a/src/BuildingBlocks/Blazor.UI/Theme/ITenantThemeState.cs b/src/BuildingBlocks/Blazor.UI/Theme/ITenantThemeState.cs new file mode 100644 index 0000000000..2a266b8b96 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Theme/ITenantThemeState.cs @@ -0,0 +1,55 @@ +namespace FSH.Framework.Blazor.UI.Theme; + +/// +/// Service for managing tenant theme state in Blazor applications. +/// Handles theme loading, caching, and change notifications. +/// +public interface ITenantThemeState +{ + /// + /// Gets the current theme settings. + /// + TenantThemeSettings Current { get; } + + /// + /// Gets the current MudTheme built from settings. + /// + MudTheme Theme { get; } + + /// + /// Gets or sets whether dark mode is enabled. + /// + bool IsDarkMode { get; set; } + + /// + /// Event fired when theme settings change. + /// +#pragma warning disable CA1003 // Action is the idiomatic pattern for Blazor state change events - EventHandler would require EventArgs which adds unnecessary ceremony for simple notifications + event Action? OnThemeChanged; +#pragma warning restore CA1003 + + /// + /// Loads theme settings from the API. + /// + Task LoadThemeAsync(CancellationToken cancellationToken = default); + + /// + /// Saves current theme settings to the API. + /// + Task SaveThemeAsync(CancellationToken cancellationToken = default); + + /// + /// Resets theme to defaults. + /// + Task ResetThemeAsync(CancellationToken cancellationToken = default); + + /// + /// Updates the current theme settings without saving. + /// + void UpdateSettings(TenantThemeSettings settings); + + /// + /// Toggles dark mode. + /// + void ToggleDarkMode(); +} diff --git a/src/BuildingBlocks/Blazor.UI/Theme/TenantThemeSettings.cs b/src/BuildingBlocks/Blazor.UI/Theme/TenantThemeSettings.cs new file mode 100644 index 0000000000..ee74ecd575 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/Theme/TenantThemeSettings.cs @@ -0,0 +1,228 @@ +using System.Globalization; +using MudBlazor; + +namespace FSH.Framework.Blazor.UI.Theme; + +/// +/// Client-side representation of tenant theme settings. +/// Used by Blazor components to build MudTheme dynamically. +/// +public sealed class TenantThemeSettings +{ + public PaletteSettings LightPalette { get; set; } = new(); + public PaletteSettings DarkPalette { get; set; } = PaletteSettings.DefaultDark; + public BrandAssets BrandAssets { get; set; } = new(); + public TypographySettings Typography { get; set; } = new(); + public LayoutSettings Layout { get; set; } = new(); + public bool IsDefault { get; set; } = true; + + public static TenantThemeSettings Default => new(); + + public MudTheme ToMudTheme() + { + var bodyFontFamily = new[] { Typography.FontFamily.Split(',')[0].Trim(), "system-ui", "sans-serif" }; + var headingFontFamily = new[] { Typography.HeadingFontFamily.Split(',')[0].Trim(), "system-ui", "sans-serif" }; + + return new MudTheme + { + PaletteLight = LightPalette.ToPaletteLight(), + PaletteDark = DarkPalette.ToPaletteDark(), + LayoutProperties = new LayoutProperties + { + DefaultBorderRadius = Layout.BorderRadius + }, + Typography = new MudBlazor.Typography + { + Default = + { + FontFamily = bodyFontFamily, + FontSize = $"{Typography.FontSizeBase / 16.0:F4}rem", + LineHeight = Typography.LineHeightBase.ToString("F2", CultureInfo.InvariantCulture) + }, + H1 = { FontFamily = headingFontFamily }, + H2 = { FontFamily = headingFontFamily }, + H3 = { FontFamily = headingFontFamily }, + H4 = { FontFamily = headingFontFamily }, + H5 = { FontFamily = headingFontFamily }, + H6 = { FontFamily = headingFontFamily } + } + }; + } +} + +public sealed class PaletteSettings +{ + public string Primary { get; set; } = "#2563EB"; + public string Secondary { get; set; } = "#0F172A"; + public string Tertiary { get; set; } = "#6366F1"; + public string Background { get; set; } = "#F8FAFC"; + public string Surface { get; set; } = "#FFFFFF"; + public string Error { get; set; } = "#DC2626"; + public string Warning { get; set; } = "#F59E0B"; + public string Success { get; set; } = "#16A34A"; + public string Info { get; set; } = "#0284C7"; + + public static PaletteSettings DefaultLight => new(); + + public static PaletteSettings DefaultDark => new() + { + Primary = "#38BDF8", + Secondary = "#94A3B8", + Tertiary = "#818CF8", + Background = "#0B1220", + Surface = "#111827", + Error = "#F87171", + Warning = "#FBBF24", + Success = "#22C55E", + Info = "#38BDF8" + }; + + public PaletteLight ToPaletteLight() + { + return new PaletteLight + { + Primary = Primary, + Secondary = Secondary, + Tertiary = Tertiary, + Background = Background, + Surface = Surface, + AppbarBackground = Background, + AppbarText = Secondary, + DrawerBackground = Surface, + TextPrimary = Secondary, + TextSecondary = "#475569", + Info = Info, + Success = Success, + Warning = Warning, + Error = Error, + TableLines = "#E2E8F0", + Divider = "#E2E8F0" + }; + } + + public PaletteDark ToPaletteDark() + { + return new PaletteDark + { + Primary = Primary, + Secondary = Secondary, + Tertiary = Tertiary, + Background = Background, + Surface = Surface, + AppbarBackground = Background, + AppbarText = "#E2E8F0", + DrawerBackground = Background, + TextPrimary = "#E2E8F0", + TextSecondary = "#CBD5E1", + Info = Info, + Success = Success, + Warning = Warning, + Error = Error, + TableLines = "#1F2937", + Divider = "#1F2937" + }; + } + + public PaletteSettings Clone() => new() + { + Primary = Primary, + Secondary = Secondary, + Tertiary = Tertiary, + Background = Background, + Surface = Surface, + Error = Error, + Warning = Warning, + Success = Success, + Info = Info + }; +} + +#pragma warning disable CA1056 // URLs are strings from API responses, used directly in HTML img src +public sealed class BrandAssets +{ + // Current URLs (returned from API) + public string? LogoUrl { get; set; } + public string? LogoDarkUrl { get; set; } + public string? FaviconUrl { get; set; } +#pragma warning restore CA1056 + + // Pending file uploads (same pattern as profile picture: FileName, ContentType, Data as byte[]) + public FileUpload? Logo { get; set; } + public FileUpload? LogoDark { get; set; } + public FileUpload? Favicon { get; set; } + + // Delete flags + public bool DeleteLogo { get; set; } + public bool DeleteLogoDark { get; set; } + public bool DeleteFavicon { get; set; } +} + +/// +/// File upload data matching the API's FileUploadRequest pattern. +/// +public sealed class FileUpload +{ + public string FileName { get; set; } = default!; + public string ContentType { get; set; } = default!; + public byte[] Data { get; set; } = []; +} + +public sealed class TypographySettings +{ + public string FontFamily { get; set; } = "Inter, sans-serif"; + public string HeadingFontFamily { get; set; } = "Inter, sans-serif"; + public double FontSizeBase { get; set; } = 14; + public double LineHeightBase { get; set; } = 1.5; + + public static IReadOnlyList WebSafeFonts => + [ + "Inter, sans-serif", + "Arial, sans-serif", + "Helvetica, sans-serif", + "Georgia, serif", + "Times New Roman, serif", + "Verdana, sans-serif", + "Tahoma, sans-serif", + "Trebuchet MS, sans-serif", + "Courier New, monospace", + "Lucida Console, monospace", + "Segoe UI, sans-serif", + "Roboto, sans-serif", + "Open Sans, sans-serif", + "system-ui, sans-serif" + ]; + + public TypographySettings Clone() => new() + { + FontFamily = FontFamily, + HeadingFontFamily = HeadingFontFamily, + FontSizeBase = FontSizeBase, + LineHeightBase = LineHeightBase + }; +} + +public sealed class LayoutSettings +{ + public string BorderRadius { get; set; } = "4px"; + public int DefaultElevation { get; set; } = 1; + + public static IReadOnlyList BorderRadiusOptions => + [ + "0px", + "2px", + "4px", + "6px", + "8px", + "12px", + "16px", + "0.25rem", + "0.5rem", + "1rem" + ]; + + public LayoutSettings Clone() => new() + { + BorderRadius = BorderRadius, + DefaultElevation = DefaultElevation + }; +} diff --git a/src/BuildingBlocks/Blazor.UI/_Imports.razor b/src/BuildingBlocks/Blazor.UI/_Imports.razor new file mode 100644 index 0000000000..75d8a1c116 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/_Imports.razor @@ -0,0 +1,8 @@ +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Forms +@using MudBlazor +@using FSH.Framework.Blazor.UI.Components.Base +@using FSH.Framework.Blazor.UI.Theme +@using FSH.Framework.Blazor.UI.Components.Theme +@using FSH.Framework.Blazor.UI.Components.Page +@using FSH.Framework.Blazor.UI.Components.User \ No newline at end of file diff --git a/src/BuildingBlocks/Blazor.UI/wwwroot/css/fsh-theme.css b/src/BuildingBlocks/Blazor.UI/wwwroot/css/fsh-theme.css new file mode 100644 index 0000000000..3a27ec0845 --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/wwwroot/css/fsh-theme.css @@ -0,0 +1,335 @@ +:root { + --fsh-radius: 10px; + --fsh-shadow: 0 10px 30px rgba(15, 23, 42, 0.05); +} + +.fsh-card { + border-radius: var(--fsh-radius); + box-shadow: var(--fsh-shadow); +} + +.fsh-btn { + border-radius: var(--fsh-radius); + font-weight: 600; +} + +.fsh-avatar { + border-radius: 50%; +} + +.fsh-section { + padding: 1.5rem; +} +.fsh-chip { + border-radius: 9999px; +} + +.hero-card { + border-radius: 16px; + background: linear-gradient(135deg, rgba(var(--mud-palette-primary-rgb), 0.1), rgba(var(--mud-palette-info-rgb), 0.05)); + border-left: 4px solid var(--mud-palette-primary); +} + +.fw-600 { + font-weight: 600; +} + +.fw-700 { + font-weight: 700; +} + +/* ===== FshAccountMenu Styles ===== */ +/* Popover container - needs to be global because MudBlazor renders popovers at document root */ +.fsh-account-popover { + border-radius: 12px !important; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12), 0 4px 12px rgba(0, 0, 0, 0.08) !important; + border: 1px solid rgba(0, 0, 0, 0.08) !important; + overflow: hidden !important; + min-width: 280px !important; + margin-top: 8px !important; +} + +.fsh-account-popover .mud-list { + padding: 0; +} + +/* Account menu trigger */ +.fsh-account-trigger { + display: flex !important; + align-items: center !important; + gap: 4px !important; + padding: 4px 8px !important; + border-radius: 24px !important; + min-width: auto !important; + text-transform: none !important; +} + +.fsh-account-trigger:hover { + background-color: rgba(255, 255, 255, 0.1) !important; +} + +.fsh-trigger-avatar { + width: 32px !important; + height: 32px !important; + font-size: 13px !important; + font-weight: 600 !important; + background: linear-gradient(135deg, var(--mud-palette-primary), var(--mud-palette-secondary)) !important; + color: white !important; + border: 2px solid rgba(255, 255, 255, 0.2) !important; +} + +.fsh-trigger-arrow { + opacity: 0.7; + margin-left: 2px; +} + +/* Dropdown content */ +.fsh-account-dropdown { + background: var(--mud-palette-surface); +} + +.fsh-dropdown-header { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 20px; + background: linear-gradient(135deg, rgba(var(--mud-palette-primary-rgb), 0.08), rgba(var(--mud-palette-secondary-rgb), 0.04)); +} + +.fsh-header-avatar { + width: 52px !important; + height: 52px !important; + font-size: 18px !important; + font-weight: 600 !important; + background: linear-gradient(135deg, var(--mud-palette-primary), var(--mud-palette-secondary)) !important; + color: white !important; + flex-shrink: 0; + box-shadow: 0 4px 12px rgba(var(--mud-palette-primary-rgb), 0.3); +} + +.fsh-header-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.fsh-header-name { + font-weight: 600; + font-size: 15px; + color: var(--mud-palette-text-primary); + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.fsh-header-email { + font-size: 13px; + color: var(--mud-palette-text-secondary); + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.fsh-header-role { + margin-top: 6px !important; + height: 22px !important; + font-size: 11px !important; + font-weight: 500 !important; + align-self: flex-start; +} + +/* Menu sections */ +.fsh-dropdown-menu { + padding: 8px; +} + +.fsh-dropdown-footer { + padding: 8px; + background: rgba(0, 0, 0, 0.02); +} + +/* Menu items */ +.fsh-menu-item { + border-radius: 8px !important; + margin: 2px 0 !important; + padding: 10px 12px !important; + transition: all 0.15s ease !important; +} + +.fsh-menu-item:hover { + background: rgba(var(--mud-palette-primary-rgb), 0.08) !important; +} + +.fsh-menu-item .mud-icon-root { + margin-right: 12px; + color: var(--mud-palette-text-secondary); +} + +.fsh-menu-item:hover .mud-icon-root { + color: var(--mud-palette-primary); +} + +.fsh-menu-item-content { + display: flex; + flex-direction: column; + gap: 2px; +} + +.fsh-menu-item-text { + font-weight: 500; + font-size: 14px; + color: var(--mud-palette-text-primary); +} + +.fsh-menu-item-desc { + font-size: 12px; + color: var(--mud-palette-text-secondary); + line-height: 1.3; +} + +/* Danger item (sign out) */ +.fsh-menu-item-danger { + color: var(--mud-palette-error) !important; +} + +.fsh-menu-item-danger:hover { + background: rgba(var(--mud-palette-error-rgb), 0.08) !important; +} + +.fsh-menu-item-danger .mud-icon-root { + color: var(--mud-palette-error) !important; +} + +/* ===== FshConfirmDialog Styles ===== */ +.fsh-confirm-dialog { + border-radius: 16px !important; + overflow: hidden; +} + +.fsh-confirm-dialog .mud-dialog-title { + padding: 24px 24px 0 24px; +} + +.fsh-confirm-dialog .mud-dialog-content { + padding: 16px 24px; +} + +.fsh-confirm-dialog .mud-dialog-actions { + padding: 16px 24px 24px 24px; +} + +.fsh-dialog-header { + display: flex; + align-items: center; + gap: 12px; +} + +.fsh-dialog-icon { + flex-shrink: 0; +} + +.fsh-dialog-title { + font-weight: 600; + color: var(--mud-palette-text-primary); +} + +.fsh-dialog-content { + padding-top: 8px; +} + +.fsh-dialog-message { + color: var(--mud-palette-text-secondary); + line-height: 1.6; +} + +.fsh-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + width: 100%; +} + +.fsh-dialog-btn-cancel { + font-weight: 500; +} + +.fsh-dialog-btn-confirm { + font-weight: 600 !important; + border-radius: 8px !important; + padding: 8px 20px !important; +} + +/* ===== Navigation Styles ===== */ +.fsh-nav { + display: flex; + flex-direction: column; + gap: 8px; + padding: 0 12px; +} + +.fsh-nav-section { + display: flex; + flex-direction: column; + gap: 4px; +} + +.fsh-nav-section-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--mud-palette-text-secondary); + padding: 12px 12px 6px 12px; + opacity: 0.7; +} + +.fsh-nav-menu { + padding: 0 !important; +} + +.fsh-nav-item { + border-radius: 10px !important; + margin: 2px 0 !important; + padding: 10px 14px !important; + font-weight: 500 !important; + font-size: 14px !important; + transition: all 0.15s ease !important; +} + +.fsh-nav-item:hover { + background-color: rgba(var(--mud-palette-primary-rgb), 0.08) !important; +} + +.fsh-nav-item.active, +.fsh-nav-item.mud-nav-link-active { + background-color: rgba(var(--mud-palette-primary-rgb), 0.12) !important; + color: var(--mud-palette-primary) !important; +} + +.fsh-nav-item .mud-nav-link-icon { + margin-right: 12px; + opacity: 0.8; +} + +.fsh-nav-item:hover .mud-nav-link-icon, +.fsh-nav-item.active .mud-nav-link-icon, +.fsh-nav-item.mud-nav-link-active .mud-nav-link-icon { + opacity: 1; + color: var(--mud-palette-primary); +} + +/* Drawer header styling */ +#nav-drawer .mud-drawer-header { + min-height: 64px; + padding: 0 16px; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); +} + +/* Drawer styling */ +#nav-drawer { + border-right: 1px solid rgba(0, 0, 0, 0.06) !important; +} diff --git a/src/BuildingBlocks/Blazor.UI/wwwroot/fsh.css b/src/BuildingBlocks/Blazor.UI/wwwroot/fsh.css new file mode 100644 index 0000000000..5f282702bb --- /dev/null +++ b/src/BuildingBlocks/Blazor.UI/wwwroot/fsh.css @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/BuildingBlocks/Caching/CacheServiceExtensions.cs b/src/BuildingBlocks/Caching/CacheServiceExtensions.cs new file mode 100644 index 0000000000..e97d66ced6 --- /dev/null +++ b/src/BuildingBlocks/Caching/CacheServiceExtensions.cs @@ -0,0 +1,47 @@ +namespace FSH.Framework.Caching; +public static class CacheServiceExtensions +{ + public static T? GetOrSet(this ICacheService cache, string key, Func getItemCallback, TimeSpan? slidingExpiration = null) + { + ArgumentNullException.ThrowIfNull(cache); + + T? value = cache.GetItem(key); + + if (value is not null) + { + return value; + } + + ArgumentNullException.ThrowIfNull(getItemCallback); + value = getItemCallback(); + + if (value is not null) + { + cache.SetItem(key, value, slidingExpiration); + } + + return value; + } + + public static async Task GetOrSetAsync(this ICacheService cache, string key, Func> task, TimeSpan? slidingExpiration = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(cache); + + T? value = await cache.GetItemAsync(key, cancellationToken); + + if (value is not null) + { + return value; + } + + ArgumentNullException.ThrowIfNull(task); + value = await task(); + + if (value is not null) + { + await cache.SetItemAsync(key, value, slidingExpiration, cancellationToken); + } + + return value; + } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Caching/Caching.csproj b/src/BuildingBlocks/Caching/Caching.csproj new file mode 100644 index 0000000000..3cc234917e --- /dev/null +++ b/src/BuildingBlocks/Caching/Caching.csproj @@ -0,0 +1,24 @@ + + + + FSH.Framework.Caching + FSH.Framework.Caching + FullStackHero.Framework.Caching + + + + + + + + + + + + + + + + + + diff --git a/src/BuildingBlocks/Caching/CachingOptions.cs b/src/BuildingBlocks/Caching/CachingOptions.cs new file mode 100644 index 0000000000..86d44167b9 --- /dev/null +++ b/src/BuildingBlocks/Caching/CachingOptions.cs @@ -0,0 +1,16 @@ +namespace FSH.Framework.Caching; + +public sealed class CachingOptions +{ + /// Redis connection string. If empty, falls back to in-memory. + public string Redis { get; set; } = string.Empty; + + /// Default sliding expiration if caller doesn't specify. + public TimeSpan? DefaultSlidingExpiration { get; set; } = TimeSpan.FromMinutes(5); + + /// Default absolute expiration (cap). + public TimeSpan? DefaultAbsoluteExpiration { get; set; } = TimeSpan.FromMinutes(15); + + /// Optional prefix (env/tenant/app) applied to all keys. + public string? KeyPrefix { get; set; } = "fsh_"; +} diff --git a/src/BuildingBlocks/Caching/DistributedCacheService.cs b/src/BuildingBlocks/Caching/DistributedCacheService.cs new file mode 100644 index 0000000000..1e6fd7f00f --- /dev/null +++ b/src/BuildingBlocks/Caching/DistributedCacheService.cs @@ -0,0 +1,112 @@ +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Text; +using System.Text.Json; + +namespace FSH.Framework.Caching; +public sealed class DistributedCacheService : ICacheService +{ + private static readonly Encoding Utf8 = Encoding.UTF8; + private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + private readonly IDistributedCache _cache; + private readonly ILogger _logger; + private readonly CachingOptions _opts; + + public DistributedCacheService( + IDistributedCache cache, + ILogger logger, + IOptions opts) + { + ArgumentNullException.ThrowIfNull(opts); + + _cache = cache; + _logger = logger; + _opts = opts.Value; + } + + public async Task GetItemAsync(string key, CancellationToken ct = default) + { + key = Normalize(key); + try + { + var bytes = await _cache.GetAsync(key, ct).ConfigureAwait(false); + if (bytes is null || bytes.Length == 0) return default; + return JsonSerializer.Deserialize(Utf8.GetString(bytes), JsonOpts); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Cache get failed for {Key}", key); + return default; + } + } + + public async Task SetItemAsync(string key, T value, TimeSpan? sliding = default, CancellationToken ct = default) + { + key = Normalize(key); + try + { + var bytes = Utf8.GetBytes(JsonSerializer.Serialize(value, JsonOpts)); + await _cache.SetAsync(key, bytes, BuildEntryOptions(sliding), ct).ConfigureAwait(false); + _logger.LogDebug("Cached {Key}", key); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Cache set failed for {Key}", key); + } + } + + public async Task RemoveItemAsync(string key, CancellationToken ct = default) + { + key = Normalize(key); + try { await _cache.RemoveAsync(key, ct).ConfigureAwait(false); } + catch (Exception ex) when (ex is not OperationCanceledException) + { _logger.LogWarning(ex, "Cache remove failed for {Key}", key); } + } + + public async Task RefreshItemAsync(string key, CancellationToken ct = default) + { + key = Normalize(key); + try + { + await _cache.RefreshAsync(key, ct).ConfigureAwait(false); + _logger.LogDebug("Refreshed {Key}", key); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { _logger.LogWarning(ex, "Cache refresh failed for {Key}", key); } + } + public T? GetItem(string key) => GetItemAsync(key).GetAwaiter().GetResult(); + public void SetItem(string key, T value, TimeSpan? sliding = default) => SetItemAsync(key, value, sliding).GetAwaiter().GetResult(); + public void RemoveItem(string key) => RemoveItemAsync(key).GetAwaiter().GetResult(); + public void RefreshItem(string key) => RefreshItemAsync(key).GetAwaiter().GetResult(); + + private DistributedCacheEntryOptions BuildEntryOptions(TimeSpan? sliding) + { + var o = new DistributedCacheEntryOptions(); + + if (sliding.HasValue) + o.SetSlidingExpiration(sliding.Value); + else if (_opts.DefaultSlidingExpiration.HasValue) + o.SetSlidingExpiration(_opts.DefaultSlidingExpiration.Value); + + if (_opts.DefaultAbsoluteExpiration.HasValue) + o.SetAbsoluteExpiration(_opts.DefaultAbsoluteExpiration.Value); + + return o; + } + + private string Normalize(string key) + { + if (string.IsNullOrWhiteSpace(key)) throw new ArgumentNullException(nameof(key)); + var prefix = _opts.KeyPrefix ?? string.Empty; + if (prefix.Length == 0) + { + return key; + } + + return key.StartsWith(prefix, StringComparison.Ordinal) + ? key + : prefix + key; + } +} diff --git a/src/BuildingBlocks/Caching/Extensions.cs b/src/BuildingBlocks/Caching/Extensions.cs new file mode 100644 index 0000000000..0d10d2acd2 --- /dev/null +++ b/src/BuildingBlocks/Caching/Extensions.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using StackExchange.Redis; + +namespace FSH.Framework.Caching; + +public static class Extensions +{ + public static IServiceCollection AddHeroCaching(this IServiceCollection services, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + + services + .AddOptions() + .BindConfiguration(nameof(CachingOptions)); + + // Always add memory cache for L1 + services.AddMemoryCache(); + + var cacheOptions = configuration.GetSection(nameof(CachingOptions)).Get(); + if (cacheOptions == null || string.IsNullOrEmpty(cacheOptions.Redis)) + { + // If no Redis, use memory cache for L2 as well + services.AddDistributedMemoryCache(); + services.AddTransient(); + return services; + } + + // Use Redis for L2 cache + services.AddStackExchangeRedisCache(options => + { + var config = ConfigurationOptions.Parse(cacheOptions.Redis); + config.AbortOnConnectFail = true; + + options.ConfigurationOptions = config; + }); + + // Register hybrid cache service + services.AddTransient(); + + return services; + } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Caching/HybridCacheService.cs b/src/BuildingBlocks/Caching/HybridCacheService.cs new file mode 100644 index 0000000000..ed8eb20a09 --- /dev/null +++ b/src/BuildingBlocks/Caching/HybridCacheService.cs @@ -0,0 +1,163 @@ +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Text; +using System.Text.Json; + +namespace FSH.Framework.Caching; + +public sealed class HybridCacheService : ICacheService +{ + private static readonly Encoding Utf8 = Encoding.UTF8; + private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + private readonly IMemoryCache _memoryCache; + private readonly IDistributedCache _distributedCache; + private readonly ILogger _logger; + private readonly CachingOptions _opts; + + public HybridCacheService( + IMemoryCache memoryCache, + IDistributedCache distributedCache, + ILogger logger, + IOptions opts) + { + ArgumentNullException.ThrowIfNull(opts); + + _memoryCache = memoryCache; + _distributedCache = distributedCache; + _logger = logger; + _opts = opts.Value; + } + + public async Task GetItemAsync(string key, CancellationToken ct = default) + { + key = Normalize(key); + try + { + // Check L1 cache first (memory) + if (_memoryCache.TryGetValue(key, out T? memoryValue)) + { + _logger.LogDebug("Cache hit in memory for {Key}", key); + return memoryValue; + } + + // Fall back to L2 cache (distributed) + var bytes = await _distributedCache.GetAsync(key, ct).ConfigureAwait(false); + if (bytes is null || bytes.Length == 0) return default; + + var value = JsonSerializer.Deserialize(Utf8.GetString(bytes), JsonOpts); + + // Populate L1 cache from L2 + if (value is not null) + { + var expiration = GetMemoryCacheExpiration(); + _memoryCache.Set(key, value, expiration); + _logger.LogDebug("Populated memory cache from distributed cache for {Key}", key); + } + + return value; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Cache get failed for {Key}", key); + return default; + } + } + + public async Task SetItemAsync(string key, T value, TimeSpan? sliding = default, CancellationToken ct = default) + { + key = Normalize(key); + try + { + var bytes = Utf8.GetBytes(JsonSerializer.Serialize(value, JsonOpts)); + await _distributedCache.SetAsync(key, bytes, BuildDistributedEntryOptions(sliding), ct).ConfigureAwait(false); + + // Also set in memory cache + var expiration = GetMemoryCacheExpiration(); + _memoryCache.Set(key, value, expiration); + + _logger.LogDebug("Cached {Key} in both memory and distributed caches", key); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Cache set failed for {Key}", key); + } + } + + public async Task RemoveItemAsync(string key, CancellationToken ct = default) + { + key = Normalize(key); + try + { + // Remove from both caches + _memoryCache.Remove(key); + await _distributedCache.RemoveAsync(key, ct).ConfigureAwait(false); + _logger.LogDebug("Removed {Key} from both caches", key); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Cache remove failed for {Key}", key); + } + } + + public async Task RefreshItemAsync(string key, CancellationToken ct = default) + { + key = Normalize(key); + try + { + await _distributedCache.RefreshAsync(key, ct).ConfigureAwait(false); + _logger.LogDebug("Refreshed {Key}", key); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Cache refresh failed for {Key}", key); + } + } + + public T? GetItem(string key) => GetItemAsync(key).GetAwaiter().GetResult(); + public void SetItem(string key, T value, TimeSpan? sliding = default) => SetItemAsync(key, value, sliding).GetAwaiter().GetResult(); + public void RemoveItem(string key) => RemoveItemAsync(key).GetAwaiter().GetResult(); + public void RefreshItem(string key) => RefreshItemAsync(key).GetAwaiter().GetResult(); + + private DistributedCacheEntryOptions BuildDistributedEntryOptions(TimeSpan? sliding) + { + var o = new DistributedCacheEntryOptions(); + + if (sliding.HasValue) + o.SetSlidingExpiration(sliding.Value); + else if (_opts.DefaultSlidingExpiration.HasValue) + o.SetSlidingExpiration(_opts.DefaultSlidingExpiration.Value); + + if (_opts.DefaultAbsoluteExpiration.HasValue) + o.SetAbsoluteExpiration(_opts.DefaultAbsoluteExpiration.Value); + + return o; + } + + private MemoryCacheEntryOptions GetMemoryCacheExpiration() + { + var options = new MemoryCacheEntryOptions(); + + // Use shorter expiration for memory cache (faster refresh from distributed cache) + var slidingExpiration = _opts.DefaultSlidingExpiration ?? TimeSpan.FromMinutes(1); + options.SetSlidingExpiration(TimeSpan.FromSeconds(slidingExpiration.TotalSeconds * 0.8)); // 80% of distributed cache expiration + + return options; + } + + private string Normalize(string key) + { + if (string.IsNullOrWhiteSpace(key)) throw new ArgumentNullException(nameof(key)); + var prefix = _opts.KeyPrefix ?? string.Empty; + if (prefix.Length == 0) + { + return key; + } + + return key.StartsWith(prefix, StringComparison.Ordinal) + ? key + : prefix + key; + } +} diff --git a/src/BuildingBlocks/Caching/ICacheService.cs b/src/BuildingBlocks/Caching/ICacheService.cs new file mode 100644 index 0000000000..fbc00dfadc --- /dev/null +++ b/src/BuildingBlocks/Caching/ICacheService.cs @@ -0,0 +1,12 @@ +namespace FSH.Framework.Caching; +public interface ICacheService +{ + Task GetItemAsync(string key, CancellationToken ct = default); + Task SetItemAsync(string key, T value, TimeSpan? sliding = default, CancellationToken ct = default); + Task RemoveItemAsync(string key, CancellationToken ct = default); + Task RefreshItemAsync(string key, CancellationToken ct = default); + T? GetItem(string key); + void SetItem(string key, T value, TimeSpan? sliding = default); + void RemoveItem(string key); + void RefreshItem(string key); +} \ No newline at end of file diff --git a/src/BuildingBlocks/Core/Abstractions/IAppUser.cs b/src/BuildingBlocks/Core/Abstractions/IAppUser.cs new file mode 100644 index 0000000000..d50318d64c --- /dev/null +++ b/src/BuildingBlocks/Core/Abstractions/IAppUser.cs @@ -0,0 +1,10 @@ +namespace FSH.Framework.Core.Abstractions; +public interface IAppUser +{ + string? FirstName { get; } + string? LastName { get; } + Uri? ImageUrl { get; } + bool IsActive { get; } + string? RefreshToken { get; } + DateTime RefreshTokenExpiryTime { get; } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Core/Common/QueryStringKeys.cs b/src/BuildingBlocks/Core/Common/QueryStringKeys.cs new file mode 100644 index 0000000000..8252eb5949 --- /dev/null +++ b/src/BuildingBlocks/Core/Common/QueryStringKeys.cs @@ -0,0 +1,6 @@ +namespace FSH.Framework.Core.Common; +public static class QueryStringKeys +{ + public const string Code = "code"; + public const string UserId = "userId"; +} \ No newline at end of file diff --git a/src/api/framework/Core/Identity/Users/Abstractions/ICurrentUser.cs b/src/BuildingBlocks/Core/Context/ICurrentUser.cs similarity index 82% rename from src/api/framework/Core/Identity/Users/Abstractions/ICurrentUser.cs rename to src/BuildingBlocks/Core/Context/ICurrentUser.cs index aa5314b007..2784294c1f 100644 --- a/src/api/framework/Core/Identity/Users/Abstractions/ICurrentUser.cs +++ b/src/BuildingBlocks/Core/Context/ICurrentUser.cs @@ -1,6 +1,6 @@ using System.Security.Claims; -namespace FSH.Framework.Core.Identity.Users.Abstractions; +namespace FSH.Framework.Core.Context; public interface ICurrentUser { string? Name { get; } @@ -16,4 +16,4 @@ public interface ICurrentUser bool IsInRole(string role); IEnumerable? GetUserClaims(); -} +} \ No newline at end of file diff --git a/src/BuildingBlocks/Core/Context/ICurrentUserInitializer.cs b/src/BuildingBlocks/Core/Context/ICurrentUserInitializer.cs new file mode 100644 index 0000000000..9e94530a0f --- /dev/null +++ b/src/BuildingBlocks/Core/Context/ICurrentUserInitializer.cs @@ -0,0 +1,9 @@ +using System.Security.Claims; + +namespace FSH.Framework.Core.Context; +public interface ICurrentUserInitializer +{ + void SetCurrentUser(ClaimsPrincipal user); + + void SetCurrentUserId(string userId); +} \ No newline at end of file diff --git a/src/BuildingBlocks/Core/Core.csproj b/src/BuildingBlocks/Core/Core.csproj new file mode 100644 index 0000000000..802f8e8684 --- /dev/null +++ b/src/BuildingBlocks/Core/Core.csproj @@ -0,0 +1,14 @@ + + + + FSH.Framework.Core + FSH.Framework.Core + FullStackHero.Framework.Core + + + + + + + + diff --git a/src/BuildingBlocks/Core/Domain/AggregateRoot.cs b/src/BuildingBlocks/Core/Domain/AggregateRoot.cs new file mode 100644 index 0000000000..8f0d2f8349 --- /dev/null +++ b/src/BuildingBlocks/Core/Domain/AggregateRoot.cs @@ -0,0 +1,5 @@ +namespace FSH.Framework.Core.Domain; +public abstract class AggregateRoot : BaseEntity +{ + // Put aggregate-wide behaviors/helpers here if needed +} diff --git a/src/BuildingBlocks/Core/Domain/BaseEntity.cs b/src/BuildingBlocks/Core/Domain/BaseEntity.cs new file mode 100644 index 0000000000..18c5b3d2bc --- /dev/null +++ b/src/BuildingBlocks/Core/Domain/BaseEntity.cs @@ -0,0 +1,15 @@ +namespace FSH.Framework.Core.Domain; +public abstract class BaseEntity : IEntity, IHasDomainEvents +{ + private readonly List _domainEvents = []; + + public TId Id { get; protected set; } = default!; + + public IReadOnlyCollection DomainEvents => _domainEvents; + + /// Raise and record a domain event for later dispatch. + protected void AddDomainEvent(IDomainEvent @event) + => _domainEvents.Add(@event); + + public void ClearDomainEvents() => _domainEvents.Clear(); +} diff --git a/src/BuildingBlocks/Core/Domain/DomainEvent.cs b/src/BuildingBlocks/Core/Domain/DomainEvent.cs new file mode 100644 index 0000000000..0553965f9b --- /dev/null +++ b/src/BuildingBlocks/Core/Domain/DomainEvent.cs @@ -0,0 +1,16 @@ +namespace FSH.Framework.Core.Domain; +/// Base domain event with correlation and tenant context. +public abstract record DomainEvent( + Guid EventId, + DateTimeOffset OccurredOnUtc, + string? CorrelationId = null, + string? TenantId = null +) : IDomainEvent +{ + public static T Create(Func factory) + where T : DomainEvent + { + ArgumentNullException.ThrowIfNull(factory); + return factory(Guid.NewGuid(), DateTimeOffset.UtcNow); + } +} diff --git a/src/BuildingBlocks/Core/Domain/IAuditableEntity.cs b/src/BuildingBlocks/Core/Domain/IAuditableEntity.cs new file mode 100644 index 0000000000..5925f4d426 --- /dev/null +++ b/src/BuildingBlocks/Core/Domain/IAuditableEntity.cs @@ -0,0 +1,8 @@ +namespace FSH.Framework.Core.Domain; +public interface IAuditableEntity +{ + DateTimeOffset CreatedOnUtc { get; } + string? CreatedBy { get; } + DateTimeOffset? LastModifiedOnUtc { get; } + string? LastModifiedBy { get; } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Core/Domain/IDomainEvent.cs b/src/BuildingBlocks/Core/Domain/IDomainEvent.cs new file mode 100644 index 0000000000..c886257dbd --- /dev/null +++ b/src/BuildingBlocks/Core/Domain/IDomainEvent.cs @@ -0,0 +1,8 @@ +namespace FSH.Framework.Core.Domain; +public interface IDomainEvent +{ + Guid EventId { get; } + DateTimeOffset OccurredOnUtc { get; } + string? CorrelationId { get; } + string? TenantId { get; } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Core/Domain/IEntity.cs b/src/BuildingBlocks/Core/Domain/IEntity.cs new file mode 100644 index 0000000000..adffcdab19 --- /dev/null +++ b/src/BuildingBlocks/Core/Domain/IEntity.cs @@ -0,0 +1,5 @@ +namespace FSH.Framework.Core.Domain; +public interface IEntity +{ + TId Id { get; } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Core/Domain/IHasDomainEvents.cs b/src/BuildingBlocks/Core/Domain/IHasDomainEvents.cs new file mode 100644 index 0000000000..48a3ce0ba2 --- /dev/null +++ b/src/BuildingBlocks/Core/Domain/IHasDomainEvents.cs @@ -0,0 +1,6 @@ +namespace FSH.Framework.Core.Domain; +public interface IHasDomainEvents +{ + IReadOnlyCollection DomainEvents { get; } + void ClearDomainEvents(); +} \ No newline at end of file diff --git a/src/BuildingBlocks/Core/Domain/IHasTenant.cs b/src/BuildingBlocks/Core/Domain/IHasTenant.cs new file mode 100644 index 0000000000..b7f579b309 --- /dev/null +++ b/src/BuildingBlocks/Core/Domain/IHasTenant.cs @@ -0,0 +1,5 @@ +namespace FSH.Framework.Core.Domain; +public interface IHasTenant +{ + string TenantId { get; } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Core/Domain/ISoftDeletable.cs b/src/BuildingBlocks/Core/Domain/ISoftDeletable.cs new file mode 100644 index 0000000000..35f841268e --- /dev/null +++ b/src/BuildingBlocks/Core/Domain/ISoftDeletable.cs @@ -0,0 +1,7 @@ +namespace FSH.Framework.Core.Domain; +public interface ISoftDeletable +{ + bool IsDeleted { get; } + DateTimeOffset? DeletedOnUtc { get; } + string? DeletedBy { get; } +} diff --git a/src/BuildingBlocks/Core/Exceptions/CustomException.cs b/src/BuildingBlocks/Core/Exceptions/CustomException.cs new file mode 100644 index 0000000000..1c3a51a9b0 --- /dev/null +++ b/src/BuildingBlocks/Core/Exceptions/CustomException.cs @@ -0,0 +1,65 @@ +using System.Net; +using System.Linq; + +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 +{ + /// + /// A list of error messages (e.g., validation errors, business rules). + /// + public IReadOnlyList ErrorMessages { get; } + + /// + /// The HTTP status code associated with this exception. + /// + public HttpStatusCode StatusCode { get; } + + public CustomException() + : this("An error occurred.", Enumerable.Empty(), HttpStatusCode.InternalServerError) + { + } + + public CustomException(string message) + : this(message, Enumerable.Empty(), HttpStatusCode.InternalServerError) + { + } + + public CustomException(string message, Exception innerException) + : this(message, innerException, Enumerable.Empty(), HttpStatusCode.InternalServerError) + { + } + + public CustomException( + string message, + IEnumerable? errors, + HttpStatusCode statusCode = HttpStatusCode.InternalServerError) + : base(message) + { + ErrorMessages = errors?.ToList() ?? new List(); + StatusCode = statusCode; + } + + public CustomException( + string message, + Exception innerException, + IEnumerable? errors, + HttpStatusCode statusCode = HttpStatusCode.InternalServerError) + : base(message, innerException) + { + ErrorMessages = errors?.ToList() ?? new List(); + StatusCode = statusCode; + } + + public CustomException( + string message, + Exception innerException, + HttpStatusCode statusCode) + : this(message, innerException, Enumerable.Empty(), statusCode) + { + } +} diff --git a/src/BuildingBlocks/Core/Exceptions/ForbiddenException.cs b/src/BuildingBlocks/Core/Exceptions/ForbiddenException.cs new file mode 100644 index 0000000000..e1ef0705d9 --- /dev/null +++ b/src/BuildingBlocks/Core/Exceptions/ForbiddenException.cs @@ -0,0 +1,28 @@ +using System.Net; + +namespace FSH.Framework.Core.Exceptions; +/// +/// Exception representing a 403 Forbidden error. +/// +public class ForbiddenException : CustomException +{ + public ForbiddenException() + : base("Unauthorized access.", Array.Empty(), HttpStatusCode.Forbidden) + { + } + + public ForbiddenException(string message) + : base(message, Array.Empty(), HttpStatusCode.Forbidden) + { + } + + public ForbiddenException(string message, IEnumerable errors) + : base(message, errors.ToList(), HttpStatusCode.Forbidden) + { + } + + public ForbiddenException(string message, Exception innerException) + : base(message, innerException, HttpStatusCode.Forbidden) + { + } +} diff --git a/src/BuildingBlocks/Core/Exceptions/NotFoundException.cs b/src/BuildingBlocks/Core/Exceptions/NotFoundException.cs new file mode 100644 index 0000000000..78b1532b54 --- /dev/null +++ b/src/BuildingBlocks/Core/Exceptions/NotFoundException.cs @@ -0,0 +1,29 @@ +using System.Net; + +namespace FSH.Framework.Core.Exceptions; + +/// +/// Exception representing a 404 Not Found error. +/// +public class NotFoundException : CustomException +{ + public NotFoundException() + : base("Resource not found.", Array.Empty(), HttpStatusCode.NotFound) + { + } + + public NotFoundException(string message) + : base(message, Array.Empty(), HttpStatusCode.NotFound) + { + } + + public NotFoundException(string message, IEnumerable errors) + : base(message, errors.ToList(), HttpStatusCode.NotFound) + { + } + + public NotFoundException(string message, Exception innerException) + : base(message, innerException, HttpStatusCode.NotFound) + { + } +} diff --git a/src/BuildingBlocks/Core/Exceptions/UnauthorizedException.cs b/src/BuildingBlocks/Core/Exceptions/UnauthorizedException.cs new file mode 100644 index 0000000000..a6c745b95d --- /dev/null +++ b/src/BuildingBlocks/Core/Exceptions/UnauthorizedException.cs @@ -0,0 +1,28 @@ +using System.Net; + +namespace FSH.Framework.Core.Exceptions; +/// +/// Exception representing a 401 Unauthorized error (authentication failure). +/// +public class UnauthorizedException : CustomException +{ + public UnauthorizedException() + : base("Authentication failed.", Array.Empty(), HttpStatusCode.Unauthorized) + { + } + + public UnauthorizedException(string message) + : base(message, Array.Empty(), HttpStatusCode.Unauthorized) + { + } + + public UnauthorizedException(string message, IEnumerable errors) + : base(message, errors.ToList(), HttpStatusCode.Unauthorized) + { + } + + public UnauthorizedException(string message, Exception innerException) + : base(message, innerException, HttpStatusCode.Unauthorized) + { + } +} diff --git a/src/BuildingBlocks/Core/IFshCore.cs b/src/BuildingBlocks/Core/IFshCore.cs new file mode 100644 index 0000000000..56a4a5f65c --- /dev/null +++ b/src/BuildingBlocks/Core/IFshCore.cs @@ -0,0 +1,4 @@ +namespace FSH.Framework.Core; +public interface IFshCore +{ +} diff --git a/src/BuildingBlocks/Eventing/Abstractions/IEventBus.cs b/src/BuildingBlocks/Eventing/Abstractions/IEventBus.cs new file mode 100644 index 0000000000..9ac5f0d7d5 --- /dev/null +++ b/src/BuildingBlocks/Eventing/Abstractions/IEventBus.cs @@ -0,0 +1,13 @@ +namespace FSH.Framework.Eventing.Abstractions; + +/// +/// Abstraction over an event bus. The initial provider is in-memory; additional providers +/// can be added without changing modules that publish or handle events. +/// +public interface IEventBus +{ + Task PublishAsync(IIntegrationEvent @event, CancellationToken ct = default); + + Task PublishAsync(IEnumerable events, CancellationToken ct = default); +} + diff --git a/src/BuildingBlocks/Eventing/Abstractions/IEventSerializer.cs b/src/BuildingBlocks/Eventing/Abstractions/IEventSerializer.cs new file mode 100644 index 0000000000..1ad9eb9159 --- /dev/null +++ b/src/BuildingBlocks/Eventing/Abstractions/IEventSerializer.cs @@ -0,0 +1,12 @@ +namespace FSH.Framework.Eventing.Abstractions; + +/// +/// Serializes and deserializes integration events for transport and storage (outbox). +/// +public interface IEventSerializer +{ + string Serialize(IIntegrationEvent @event); + + IIntegrationEvent? Deserialize(string payload, string eventTypeName); +} + diff --git a/src/BuildingBlocks/Eventing/Abstractions/IIntegrationEvent.cs b/src/BuildingBlocks/Eventing/Abstractions/IIntegrationEvent.cs new file mode 100644 index 0000000000..eff2761a7a --- /dev/null +++ b/src/BuildingBlocks/Eventing/Abstractions/IIntegrationEvent.cs @@ -0,0 +1,27 @@ +namespace FSH.Framework.Eventing.Abstractions; + +/// +/// Base integration event contract used for cross-module and cross-service messaging. +/// +public interface IIntegrationEvent +{ + Guid Id { get; } + + DateTime OccurredOnUtc { get; } + + /// + /// Tenant identifier for tenant-scoped events. Null for global events. + /// + string? TenantId { get; } + + /// + /// Correlation identifier to tie events to requests and traces. + /// + string CorrelationId { get; } + + /// + /// Logical source of the event (e.g., module or service name). + /// + string Source { get; } +} + diff --git a/src/BuildingBlocks/Eventing/Abstractions/IIntegrationEventHandler.cs b/src/BuildingBlocks/Eventing/Abstractions/IIntegrationEventHandler.cs new file mode 100644 index 0000000000..06da876c5e --- /dev/null +++ b/src/BuildingBlocks/Eventing/Abstractions/IIntegrationEventHandler.cs @@ -0,0 +1,12 @@ +namespace FSH.Framework.Eventing.Abstractions; + +/// +/// Handles a single integration event type. +/// +/// The integration event type. +public interface IIntegrationEventHandler + where TEvent : IIntegrationEvent +{ + Task HandleAsync(TEvent @event, CancellationToken ct = default); +} + diff --git a/src/BuildingBlocks/Eventing/Eventing.csproj b/src/BuildingBlocks/Eventing/Eventing.csproj new file mode 100644 index 0000000000..ef8cdb0944 --- /dev/null +++ b/src/BuildingBlocks/Eventing/Eventing.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + FSH.Framework.Eventing + FSH.Framework.Eventing + FullStackHero.Framework.Eventing + $(NoWarn);CA1711;CA1716;CA1031;S2139;S1066 + + + + + + + + + + + + + + + + diff --git a/src/BuildingBlocks/Eventing/EventingOptions.cs b/src/BuildingBlocks/Eventing/EventingOptions.cs new file mode 100644 index 0000000000..006a5bfb5c --- /dev/null +++ b/src/BuildingBlocks/Eventing/EventingOptions.cs @@ -0,0 +1,28 @@ +namespace FSH.Framework.Eventing; + +/// +/// Configuration options for the eventing building block. +/// +public sealed class EventingOptions +{ + /// + /// Provider for the event bus implementation. Defaults to InMemory. + /// + public string Provider { get; set; } = "InMemory"; + + /// + /// Batch size for outbox dispatching. + /// + public int OutboxBatchSize { get; set; } = 100; + + /// + /// Maximum number of retries before an outbox message is marked as dead. + /// + public int OutboxMaxRetries { get; set; } = 5; + + /// + /// Whether inbox-based idempotent handling is enabled. + /// + public bool EnableInbox { get; set; } = true; +} + diff --git a/src/BuildingBlocks/Eventing/InMemory/InMemoryEventBus.cs b/src/BuildingBlocks/Eventing/InMemory/InMemoryEventBus.cs new file mode 100644 index 0000000000..4f9de0a2d8 --- /dev/null +++ b/src/BuildingBlocks/Eventing/InMemory/InMemoryEventBus.cs @@ -0,0 +1,99 @@ +using System.Reflection; +using FSH.Framework.Eventing.Abstractions; +using FSH.Framework.Eventing.Inbox; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace FSH.Framework.Eventing.InMemory; + +/// +/// In-memory event bus implementation used for single-process deployments. +/// It resolves handlers from DI and optionally uses an inbox store for idempotency. +/// +public sealed class InMemoryEventBus : IEventBus +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public InMemoryEventBus(IServiceProvider serviceProvider, ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public Task PublishAsync(IIntegrationEvent @event, CancellationToken ct = default) + => PublishAsync(new[] { @event }, ct); + + public async Task PublishAsync(IEnumerable events, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(events); + + foreach (var @event in events) + { + await PublishSingleAsync(@event, ct).ConfigureAwait(false); + } + } + + private async Task PublishSingleAsync(IIntegrationEvent @event, CancellationToken ct) + { + var eventType = @event.GetType(); + _logger.LogDebug("Publishing integration event {EventType} ({EventId})", eventType.FullName, @event.Id); + + using var scope = _serviceProvider.CreateScope(); + var provider = scope.ServiceProvider; + + var handlerInterfaceType = typeof(IIntegrationEventHandler<>).MakeGenericType(eventType); + var handlers = provider.GetServices(handlerInterfaceType).ToArray(); + + if (handlers.Length == 0) + { + _logger.LogDebug("No handlers registered for integration event type {EventType}", eventType.FullName); + return; + } + + var inbox = provider.GetService(); + + foreach (var handler in handlers) + { + if (handler is null) + { + continue; + } + + var handlerName = handler.GetType().FullName ?? handler.GetType().Name; + + if (inbox != null) + { + if (await inbox.HasProcessedAsync(@event.Id, handlerName, ct).ConfigureAwait(false)) + { + _logger.LogDebug("Skipping already processed integration event {EventId} for handler {Handler}", @event.Id, handlerName); + continue; + } + } + + var method = handlerInterfaceType.GetMethod(nameof(IIntegrationEventHandler.HandleAsync)); + if (method == null) + { + _logger.LogWarning("Handler {Handler} does not implement HandleAsync correctly for {EventType}", handlerName, eventType.FullName); + continue; + } + + try + { + var task = (Task)method.Invoke(handler, new object[] { @event, ct })!; + await task.ConfigureAwait(false); + + if (inbox != null) + { + await inbox.MarkProcessedAsync(@event.Id, handlerName, @event.TenantId, eventType.AssemblyQualifiedName ?? eventType.FullName!, ct) + .ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while handling integration event {EventId} with handler {Handler}", @event.Id, handlerName); + throw; + } + } + } +} diff --git a/src/BuildingBlocks/Eventing/Inbox/EfCoreInboxStore.cs b/src/BuildingBlocks/Eventing/Inbox/EfCoreInboxStore.cs new file mode 100644 index 0000000000..cd821d80ea --- /dev/null +++ b/src/BuildingBlocks/Eventing/Inbox/EfCoreInboxStore.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore; + +namespace FSH.Framework.Eventing.Inbox; + +/// +/// EF Core-based inbox store for a specific DbContext. +/// +/// The DbContext that owns the InboxMessages set. +public sealed class EfCoreInboxStore : IInboxStore + where TDbContext : DbContext +{ + private readonly TDbContext _dbContext; + + public EfCoreInboxStore(TDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task HasProcessedAsync(Guid eventId, string handlerName, CancellationToken ct = default) + { + return await _dbContext.Set() + .AnyAsync(i => i.Id == eventId && i.HandlerName == handlerName, ct) + .ConfigureAwait(false); + } + + public async Task MarkProcessedAsync(Guid eventId, string handlerName, string? tenantId, string eventType, CancellationToken ct = default) + { + var message = new InboxMessage + { + Id = eventId, + EventType = eventType, + HandlerName = handlerName, + TenantId = tenantId, + ProcessedOnUtc = DateTime.UtcNow + }; + + await _dbContext.Set().AddAsync(message, ct).ConfigureAwait(false); + await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false); + } +} + diff --git a/src/BuildingBlocks/Eventing/Inbox/IInboxStore.cs b/src/BuildingBlocks/Eventing/Inbox/IInboxStore.cs new file mode 100644 index 0000000000..f33473284e --- /dev/null +++ b/src/BuildingBlocks/Eventing/Inbox/IInboxStore.cs @@ -0,0 +1,12 @@ +namespace FSH.Framework.Eventing.Inbox; + +/// +/// Abstraction for idempotent consumer tracking. +/// +public interface IInboxStore +{ + Task HasProcessedAsync(Guid eventId, string handlerName, CancellationToken ct = default); + + Task MarkProcessedAsync(Guid eventId, string handlerName, string? tenantId, string eventType, CancellationToken ct = default); +} + diff --git a/src/BuildingBlocks/Eventing/Inbox/InboxMessage.cs b/src/BuildingBlocks/Eventing/Inbox/InboxMessage.cs new file mode 100644 index 0000000000..f6d9dcb106 --- /dev/null +++ b/src/BuildingBlocks/Eventing/Inbox/InboxMessage.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FSH.Framework.Eventing.Inbox; + +/// +/// Inbox message to track processed integration events per handler for idempotent consumers. +/// +public class InboxMessage +{ + public Guid Id { get; set; } + + public string EventType { get; set; } = default!; + + public string HandlerName { get; set; } = default!; + + public DateTime ProcessedOnUtc { get; set; } + + public string? TenantId { get; set; } +} + +public class InboxMessageConfiguration : IEntityTypeConfiguration +{ + private readonly string _schema; + + public InboxMessageConfiguration(string schema) + { + _schema = schema; + } + + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("InboxMessages", _schema); + + builder.HasKey(i => new { i.Id, i.HandlerName }); + + builder.Property(i => i.EventType) + .HasMaxLength(512) + .IsRequired(); + + builder.Property(i => i.HandlerName) + .HasMaxLength(256) + .IsRequired(); + + builder.Property(i => i.TenantId) + .HasMaxLength(64); + } +} diff --git a/src/BuildingBlocks/Eventing/Outbox/EfCoreOutboxStore.cs b/src/BuildingBlocks/Eventing/Outbox/EfCoreOutboxStore.cs new file mode 100644 index 0000000000..dc0f59c322 --- /dev/null +++ b/src/BuildingBlocks/Eventing/Outbox/EfCoreOutboxStore.cs @@ -0,0 +1,83 @@ +using FSH.Framework.Eventing.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace FSH.Framework.Eventing.Outbox; + +/// +/// EF Core-based outbox store for a specific DbContext. +/// +/// The DbContext that owns the OutboxMessages set. +public sealed class EfCoreOutboxStore : IOutboxStore + where TDbContext : DbContext +{ + private readonly TDbContext _dbContext; + private readonly IEventSerializer _serializer; + private readonly ILogger> _logger; + + public EfCoreOutboxStore( + TDbContext dbContext, + IEventSerializer serializer, + ILogger> logger) + { + _dbContext = dbContext; + _serializer = serializer; + _logger = logger; + } + + public async Task AddAsync(IIntegrationEvent @event, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(@event); + + var payload = _serializer.Serialize(@event); + var message = new OutboxMessage + { + Id = @event.Id, + CreatedOnUtc = @event.OccurredOnUtc, + Type = @event.GetType().AssemblyQualifiedName ?? @event.GetType().FullName!, + Payload = payload, + TenantId = @event.TenantId, + CorrelationId = @event.CorrelationId, + RetryCount = 0, + IsDead = false + }; + + await _dbContext.Set().AddAsync(message, ct).ConfigureAwait(false); + await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false); + } + + public async Task> GetPendingBatchAsync(int batchSize, CancellationToken ct = default) + { + return await _dbContext.Set() + .Where(m => !m.IsDead && m.ProcessedOnUtc == null) + .OrderBy(m => m.CreatedOnUtc) + .Take(batchSize) + .ToListAsync(ct) + .ConfigureAwait(false); + } + + public async Task MarkAsProcessedAsync(OutboxMessage message, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(message); + + message.ProcessedOnUtc = DateTime.UtcNow; + _dbContext.Set().Update(message); + await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false); + } + + public async Task MarkAsFailedAsync(OutboxMessage message, string error, bool isDead, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(message); + + message.RetryCount++; + message.LastError = error; + message.IsDead = isDead; + _dbContext.Set().Update(message); + + _logger.LogWarning("Outbox message {MessageId} failed. RetryCount={RetryCount}, IsDead={IsDead}, Error={Error}", + message.Id, message.RetryCount, message.IsDead, error); + + await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false); + } +} + diff --git a/src/BuildingBlocks/Eventing/Outbox/IOutboxStore.cs b/src/BuildingBlocks/Eventing/Outbox/IOutboxStore.cs new file mode 100644 index 0000000000..1c3cece66b --- /dev/null +++ b/src/BuildingBlocks/Eventing/Outbox/IOutboxStore.cs @@ -0,0 +1,18 @@ +using FSH.Framework.Eventing.Abstractions; + +namespace FSH.Framework.Eventing.Outbox; + +/// +/// Abstraction for persisting and reading outbox messages. +/// +public interface IOutboxStore +{ + Task AddAsync(IIntegrationEvent @event, CancellationToken ct = default); + + Task> GetPendingBatchAsync(int batchSize, CancellationToken ct = default); + + Task MarkAsProcessedAsync(OutboxMessage message, CancellationToken ct = default); + + Task MarkAsFailedAsync(OutboxMessage message, string error, bool isDead, CancellationToken ct = default); +} + diff --git a/src/BuildingBlocks/Eventing/Outbox/OutboxDispatcher.cs b/src/BuildingBlocks/Eventing/Outbox/OutboxDispatcher.cs new file mode 100644 index 0000000000..7a7fea23da --- /dev/null +++ b/src/BuildingBlocks/Eventing/Outbox/OutboxDispatcher.cs @@ -0,0 +1,101 @@ +using FSH.Framework.Eventing.Abstractions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace FSH.Framework.Eventing.Outbox; + +/// +/// Dispatches outbox messages via the configured event bus. +/// This type is intended to be invoked by a scheduler (e.g., Hangfire recurring job or hosted service). +/// +public sealed class OutboxDispatcher +{ + private readonly IOutboxStore _outbox; + private readonly IEventBus _bus; + private readonly IEventSerializer _serializer; + private readonly ILogger _logger; + private readonly EventingOptions _options; + + public OutboxDispatcher( + IOutboxStore outbox, + IEventBus bus, + IEventSerializer serializer, + IOptions options, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(options); + + _outbox = outbox; + _bus = bus; + _serializer = serializer; + _logger = logger; + _options = options.Value; + } + + public async Task DispatchAsync(CancellationToken ct = default) + { + var batchSize = _options.OutboxBatchSize; + if (batchSize <= 0) batchSize = 100; + + var messages = await _outbox.GetPendingBatchAsync(batchSize, ct).ConfigureAwait(false); + if (messages.Count == 0) + { + _logger.LogDebug("No outbox messages to dispatch."); + return; + } + + _logger.LogInformation("Dispatching {Count} outbox messages (BatchSize={BatchSize})", messages.Count, batchSize); + + var processedCount = 0; + var failedCount = 0; + var deadLetterCount = 0; + + foreach (var message in messages) + { + try + { + var @event = _serializer.Deserialize(message.Payload, message.Type); + if (@event is null) + { + await _outbox.MarkAsFailedAsync(message, "Cannot deserialize integration event.", isDead: true, ct).ConfigureAwait(false); + continue; + } + + await _bus.PublishAsync(@event, ct).ConfigureAwait(false); + await _outbox.MarkAsProcessedAsync(message, ct).ConfigureAwait(false); + processedCount++; + + _logger.LogDebug("Outbox message {MessageId} dispatched and marked as processed.", message.Id); + } + catch (Exception ex) + { + var maxRetries = _options.OutboxMaxRetries <= 0 ? 5 : _options.OutboxMaxRetries; + var isDead = message.RetryCount + 1 >= maxRetries; + + await _outbox.MarkAsFailedAsync(message, ex.Message, isDead, ct).ConfigureAwait(false); + + failedCount++; + if (isDead) + { + deadLetterCount++; + } + + if (isDead) + { + _logger.LogError(ex, "Outbox message {MessageId} moved to dead-letter after {RetryCount} retries", message.Id, message.RetryCount + 1); + } + else + { + _logger.LogWarning(ex, "Outbox message {MessageId} failed (RetryCount={RetryCount}).", message.Id, message.RetryCount + 1); + } + } + } + + _logger.LogInformation( + "Outbox dispatch summary: Total={Total}, Processed={Processed}, Failed={Failed}, DeadLettered={DeadLettered}", + messages.Count, + processedCount, + failedCount, + deadLetterCount); + } +} diff --git a/src/BuildingBlocks/Eventing/Outbox/OutboxMessage.cs b/src/BuildingBlocks/Eventing/Outbox/OutboxMessage.cs new file mode 100644 index 0000000000..f3de29bd2e --- /dev/null +++ b/src/BuildingBlocks/Eventing/Outbox/OutboxMessage.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FSH.Framework.Eventing.Outbox; + +/// +/// Outbox message entity used to persist integration events alongside domain changes. +/// +public class OutboxMessage +{ + public Guid Id { get; set; } + + public DateTime CreatedOnUtc { get; set; } + + public string Type { get; set; } = default!; + + public string Payload { get; set; } = default!; + + public string? TenantId { get; set; } + + public string? CorrelationId { get; set; } + + public DateTime? ProcessedOnUtc { get; set; } + + public int RetryCount { get; set; } + + public string? LastError { get; set; } + + public bool IsDead { get; set; } +} + +public class OutboxMessageConfiguration : IEntityTypeConfiguration +{ + private readonly string _schema; + + public OutboxMessageConfiguration(string schema) + { + _schema = schema; + } + + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("OutboxMessages", _schema); + + builder.HasKey(o => o.Id); + + builder.Property(o => o.Type) + .HasMaxLength(512) + .IsRequired(); + + builder.Property(o => o.Payload) + .IsRequired(); + + builder.Property(o => o.TenantId) + .HasMaxLength(64); + + builder.Property(o => o.CorrelationId) + .HasMaxLength(128); + + builder.Property(o => o.CreatedOnUtc) + .IsRequired(); + } +} diff --git a/src/BuildingBlocks/Eventing/Serialization/JsonEventSerializer.cs b/src/BuildingBlocks/Eventing/Serialization/JsonEventSerializer.cs new file mode 100644 index 0000000000..9276ef2d06 --- /dev/null +++ b/src/BuildingBlocks/Eventing/Serialization/JsonEventSerializer.cs @@ -0,0 +1,38 @@ +using System.Text.Json; +using FSH.Framework.Eventing.Abstractions; + +namespace FSH.Framework.Eventing.Serialization; + +/// +/// System.Text.Json-based event serializer. +/// +public sealed class JsonEventSerializer : IEventSerializer +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + public string Serialize(IIntegrationEvent @event) + { + ArgumentNullException.ThrowIfNull(@event); + return JsonSerializer.Serialize(@event, @event.GetType(), Options); + } + + public IIntegrationEvent? Deserialize(string payload, string eventTypeName) + { + ArgumentNullException.ThrowIfNull(payload); + ArgumentNullException.ThrowIfNull(eventTypeName); + + var type = Type.GetType(eventTypeName, throwOnError: false); + if (type is null) + { + return null; + } + + var result = JsonSerializer.Deserialize(payload, type, Options); + return result as IIntegrationEvent; + } +} + diff --git a/src/BuildingBlocks/Eventing/ServiceCollectionExtensions.cs b/src/BuildingBlocks/Eventing/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..5681a0bbeb --- /dev/null +++ b/src/BuildingBlocks/Eventing/ServiceCollectionExtensions.cs @@ -0,0 +1,90 @@ +using FSH.Framework.Eventing.Abstractions; +using FSH.Framework.Eventing.Inbox; +using FSH.Framework.Eventing.InMemory; +using FSH.Framework.Eventing.Outbox; +using FSH.Framework.Eventing.Serialization; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System.Reflection; + +namespace FSH.Framework.Eventing; + +public static class ServiceCollectionExtensions +{ + /// + /// Adds core eventing services (serializer, bus, options). + /// + public static IServiceCollection AddEventingCore( + this IServiceCollection services, + IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + services.AddOptions().BindConfiguration(nameof(EventingOptions)); + + services.AddSingleton(); + + // For now, only InMemory provider is implemented. + services.AddSingleton(); + + return services; + } + + /// + /// Registers EF Core-based outbox and inbox stores for the specified DbContext. + /// + public static IServiceCollection AddEventingForDbContext( + this IServiceCollection services) + where TDbContext : DbContext + { + ArgumentNullException.ThrowIfNull(services); + + services.AddScoped>(); + services.AddScoped>(); + services.AddScoped(); + + return services; + } + + /// + /// Registers integration event handlers from the specified assemblies. + /// + public static IServiceCollection AddIntegrationEventHandlers( + this IServiceCollection services, + params Assembly[] assemblies) + { + ArgumentNullException.ThrowIfNull(services); + if (assemblies is null || assemblies.Length == 0) + { + return services; + } + + foreach (var assembly in assemblies) + { + var handlerTypes = assembly + .GetTypes() + .Where(t => !t.IsAbstract && !t.IsInterface) + .Select(t => new + { + Type = t, + Interfaces = t.GetInterfaces() + .Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IIntegrationEventHandler<>)) + .ToArray() + }) + .Where(x => x.Interfaces.Length > 0); + + foreach (var handler in handlerTypes) + { + foreach (var handlerInterface in handler.Interfaces) + { + services.AddScoped(handlerInterface, handler.Type); + } + } + } + + return services; + } +} + diff --git a/src/BuildingBlocks/Jobs/Extensions.cs b/src/BuildingBlocks/Jobs/Extensions.cs new file mode 100644 index 0000000000..00ff7142d9 --- /dev/null +++ b/src/BuildingBlocks/Jobs/Extensions.cs @@ -0,0 +1,114 @@ +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Jobs.Services; +using FSH.Framework.Shared.Persistence; +using Hangfire; +using Hangfire.PostgreSql; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace FSH.Framework.Jobs; + +public static class Extensions +{ + public static IServiceCollection AddHeroJobs(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddOptions() + .BindConfiguration(nameof(HangfireOptions)); + + services.AddHangfireServer(options => + { + options.HeartbeatInterval = TimeSpan.FromSeconds(30); + options.Queues = ["default", "email"]; + options.WorkerCount = 5; + options.SchedulePollingInterval = TimeSpan.FromSeconds(30); + }); + + services.AddHangfire((provider, config) => + { + var configuration = provider.GetRequiredService(); + var dbOptions = configuration.GetSection(nameof(DatabaseOptions)).Get() + ?? throw new CustomException("Database options not found"); + + switch (dbOptions.Provider.ToUpperInvariant()) + { + case DbProviders.PostgreSQL: + // Clean up stale locks before configuring Hangfire + CleanupStaleLocks(dbOptions.ConnectionString, provider); + + config.UsePostgreSqlStorage(o => + { + o.UseNpgsqlConnection(dbOptions.ConnectionString); + }); + break; + + case DbProviders.MSSQL: + config.UseSqlServerStorage(dbOptions.ConnectionString); + break; + + default: + throw new CustomException($"Hangfire storage provider {dbOptions.Provider} is not supported"); + } + + config.UseFilter(new FshJobFilter(provider)); + config.UseFilter(new LogJobFilter()); + config.UseFilter(new HangfireTelemetryFilter()); + }); + + services.AddTransient(); + + return services; + } + + private static void CleanupStaleLocks(string connectionString, IServiceProvider provider) + { + var logger = provider.GetService()?.CreateLogger("Hangfire"); + + try + { + using var connection = new NpgsqlConnection(connectionString); + connection.Open(); + + // Delete locks older than 5 minutes (stale from crashed instances) + using var cmd = new NpgsqlCommand( + "DELETE FROM hangfire.lock WHERE acquired < NOW() - INTERVAL '5 minutes'", + connection); + + var deleted = cmd.ExecuteNonQuery(); + if (deleted > 0) + { + logger?.LogWarning("Cleaned up {Count} stale Hangfire locks", deleted); + } + } + catch (Exception ex) + { + // Don't fail startup if cleanup fails - the lock might not exist yet + logger?.LogDebug(ex, "Could not cleanup stale Hangfire locks (table may not exist yet)"); + } + } + + + public static IApplicationBuilder UseHeroJobDashboard(this IApplicationBuilder app, IConfiguration config) + { + ArgumentNullException.ThrowIfNull(app); + ArgumentNullException.ThrowIfNull(config); + + var hangfireOptions = config.GetSection(nameof(HangfireOptions)).Get() ?? new HangfireOptions(); + var dashboardOptions = new DashboardOptions(); + dashboardOptions.AppPath = "/"; + dashboardOptions.Authorization = new[] + { + new HangfireCustomBasicAuthenticationFilter + { + User = hangfireOptions.UserName!, + Pass = hangfireOptions.Password! + } + }; + + return app.UseHangfireDashboard(hangfireOptions.Route, dashboardOptions); + } +} diff --git a/src/api/framework/Infrastructure/Jobs/FshJobActivator.cs b/src/BuildingBlocks/Jobs/FshJobActivator.cs similarity index 81% rename from src/api/framework/Infrastructure/Jobs/FshJobActivator.cs rename to src/BuildingBlocks/Jobs/FshJobActivator.cs index dc0eb2fd83..09671411fa 100644 --- a/src/api/framework/Infrastructure/Jobs/FshJobActivator.cs +++ b/src/BuildingBlocks/Jobs/FshJobActivator.cs @@ -1,14 +1,13 @@ using Finbuckle.MultiTenant; using Finbuckle.MultiTenant.Abstractions; -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Infrastructure.Constants; -using FSH.Framework.Infrastructure.Tenant; -using FSH.Starter.Shared.Authorization; +using FSH.Framework.Core.Common; +using FSH.Framework.Core.Context; +using FSH.Framework.Shared.Multitenancy; using Hangfire; using Hangfire.Server; using Microsoft.Extensions.DependencyInjection; -namespace FSH.Framework.Infrastructure.Jobs; +namespace FSH.Framework.Jobs; public class FshJobActivator : JobActivator { @@ -35,14 +34,11 @@ public Scope(PerformContext context, IServiceScope scope) private void ReceiveParameters() { - var tenantInfo = _context.GetJobParameter(TenantConstants.Identifier); + var tenantInfo = _context.GetJobParameter(MultitenancyConstants.Identifier); if (tenantInfo is not null) { _scope.ServiceProvider.GetRequiredService() - .MultiTenantContext = new MultiTenantContext - { - TenantInfo = tenantInfo - }; + .MultiTenantContext = new MultiTenantContext(tenantInfo); } string userId = _context.GetJobParameter(QueryStringKeys.UserId); diff --git a/src/BuildingBlocks/Jobs/FshJobFilter.cs b/src/BuildingBlocks/Jobs/FshJobFilter.cs new file mode 100644 index 0000000000..2ad2b14dd7 --- /dev/null +++ b/src/BuildingBlocks/Jobs/FshJobFilter.cs @@ -0,0 +1,62 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Core.Common; +using FSH.Framework.Shared.Identity.Claims; +using FSH.Framework.Shared.Multitenancy; +using Hangfire.Client; +using Hangfire.Logging; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace FSH.Framework.Jobs; + +public class FshJobFilter : IClientFilter +{ + private static readonly ILog Logger = LogProvider.GetCurrentClassLogger(); + + private readonly IServiceProvider _services; + + public FshJobFilter(IServiceProvider services) => _services = services; + + public void OnCreating(CreatingContext context) + { + ArgumentNullException.ThrowIfNull(context); + + Logger.InfoFormat("Set TenantId and UserId parameters to job {0}.{1}...", + context.Job.Method.ReflectedType?.FullName, context.Job.Method.Name); + + using var scope = _services.CreateScope(); + + var httpContextAccessor = scope.ServiceProvider.GetService(); + var httpContext = httpContextAccessor?.HttpContext; + + if (httpContext is null) + { + // No HTTP context (e.g. recurring/background job creation) – skip setting tenant/user. + Logger.WarnFormat("No HttpContext available for job {0}.{1}; skipping tenant/user parameters.", + context.Job.Method.ReflectedType?.FullName, context.Job.Method.Name); + return; + } + + var mtAccessor = scope.ServiceProvider.GetService(); + var tenantInfo = mtAccessor?.MultiTenantContext?.TenantInfo; + if (tenantInfo is not null) + { + context.SetJobParameter(MultitenancyConstants.Identifier, tenantInfo); + } + + var userId = httpContext.User.GetUserId(); + if (!string.IsNullOrEmpty(userId)) + { + context.SetJobParameter(QueryStringKeys.UserId, userId); + } + } + + public void OnCreated(CreatedContext context) + { + ArgumentNullException.ThrowIfNull(context); + + Logger.InfoFormat( + "Job created with parameters {0}", + context.Parameters.Select(x => x.Key + "=" + x.Value).Aggregate((s1, s2) => s1 + ";" + s2)); + } +} diff --git a/src/api/framework/Infrastructure/Jobs/HangfireCustomBasicAuthenticationFilter.cs b/src/BuildingBlocks/Jobs/HangfireCustomBasicAuthenticationFilter.cs similarity index 97% rename from src/api/framework/Infrastructure/Jobs/HangfireCustomBasicAuthenticationFilter.cs rename to src/BuildingBlocks/Jobs/HangfireCustomBasicAuthenticationFilter.cs index 2cc8980d4b..a71398898d 100644 --- a/src/api/framework/Infrastructure/Jobs/HangfireCustomBasicAuthenticationFilter.cs +++ b/src/BuildingBlocks/Jobs/HangfireCustomBasicAuthenticationFilter.cs @@ -1,11 +1,11 @@ -using System.Net.Http.Headers; using Hangfire.Dashboard; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Primitives; +using System.Net.Http.Headers; -namespace FSH.Framework.Infrastructure.Jobs; +namespace FSH.Framework.Jobs; public class HangfireCustomBasicAuthenticationFilter : IDashboardAuthorizationFilter { @@ -24,7 +24,7 @@ public HangfireCustomBasicAuthenticationFilter() public bool Authorize(DashboardContext context) { var httpContext = context.GetHttpContext(); - var header = httpContext.Request.Headers["Authorization"]!; + var header = httpContext.Request.Headers.Authorization!; if (MissingAuthorizationHeader(header)) { @@ -118,4 +118,4 @@ private bool ContainsTwoTokens() { return _tokens.Length == 2; } -} +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Jobs/HangfireOptions.cs b/src/BuildingBlocks/Jobs/HangfireOptions.cs similarity index 80% rename from src/api/framework/Infrastructure/Jobs/HangfireOptions.cs rename to src/BuildingBlocks/Jobs/HangfireOptions.cs index 45f5ac0c63..f57b26fed8 100644 --- a/src/api/framework/Infrastructure/Jobs/HangfireOptions.cs +++ b/src/BuildingBlocks/Jobs/HangfireOptions.cs @@ -1,4 +1,5 @@ -namespace FSH.Framework.Infrastructure.Jobs; +namespace FSH.Framework.Jobs; + public class HangfireOptions { public string UserName { get; set; } = "admin"; diff --git a/src/BuildingBlocks/Jobs/HangfireTelemetryFilter.cs b/src/BuildingBlocks/Jobs/HangfireTelemetryFilter.cs new file mode 100644 index 0000000000..a7b343a406 --- /dev/null +++ b/src/BuildingBlocks/Jobs/HangfireTelemetryFilter.cs @@ -0,0 +1,59 @@ +using Hangfire.Common; +using Hangfire.Server; +using System.Diagnostics; + +namespace FSH.Framework.Jobs; + +/// +/// Adds basic tracing around Hangfire job execution. +/// +public sealed class HangfireTelemetryFilter : JobFilterAttribute, IServerFilter +{ + private const string ActivityKey = "__fsh_activity"; + private static readonly ActivitySource ActivitySource = new("FSH.Hangfire"); + + public void OnPerforming(PerformingContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var job = context.BackgroundJob?.Job; + string name = job is null + ? "Hangfire.Job" + : $"{job.Type.Name}.{job.Method.Name}"; + + var activity = ActivitySource.StartActivity(name, ActivityKind.Internal); + if (activity is null) + { + return; + } + + activity.SetTag("hangfire.job_id", context.BackgroundJob?.Id); + activity.SetTag("hangfire.job_type", job?.Type.FullName); + activity.SetTag("hangfire.job_method", job?.Method.Name); + + context.Items[ActivityKey] = activity; + } + + public void OnPerformed(PerformedContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (!context.Items.TryGetValue(ActivityKey, out var value) || value is not Activity activity) + { + return; + } + + if (context.Exception is not null) + { + activity.SetStatus(ActivityStatusCode.Error); + activity.SetTag("exception.type", context.Exception.GetType().FullName); + activity.SetTag("exception.message", context.Exception.Message); + } + else + { + activity.SetStatus(ActivityStatusCode.Ok); + } + + activity.Dispose(); + } +} diff --git a/src/BuildingBlocks/Jobs/Jobs.csproj b/src/BuildingBlocks/Jobs/Jobs.csproj new file mode 100644 index 0000000000..af6529f7ea --- /dev/null +++ b/src/BuildingBlocks/Jobs/Jobs.csproj @@ -0,0 +1,24 @@ + + + + FSH.Framework.Jobs + FSH.Framework.Jobs + FullStackHero.Framework.Jobs + $(NoWarn);CA1031;S3376;S3993 + + + + + + + + + + + + + + + + + diff --git a/src/BuildingBlocks/Jobs/LogJobFilter.cs b/src/BuildingBlocks/Jobs/LogJobFilter.cs new file mode 100644 index 0000000000..e382796693 --- /dev/null +++ b/src/BuildingBlocks/Jobs/LogJobFilter.cs @@ -0,0 +1,139 @@ +using Hangfire.Client; +using Hangfire.Logging; +using Hangfire.Server; +using Hangfire.States; +using Hangfire.Storage; + +namespace FSH.Framework.Jobs; + +public class LogJobFilter : IClientFilter, IServerFilter, IElectStateFilter, IApplyStateFilter +{ + private static readonly ILog Logger = LogProvider.GetCurrentClassLogger(); + + public LogJobFilter() + { + } + + public void OnCreating(CreatingContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var job = context.Job; + var jobName = GetJobName(job); + + Logger.DebugFormat( + "Creating job for {0}.", jobName); + } + + public void OnCreated(CreatedContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var job = context.Job; + var jobName = GetJobName(job); + var jobId = context.BackgroundJob?.Id ?? ""; + var recurringJobId = context.Parameters.TryGetValue("RecurringJobId", out var r) ? r : null; + + Logger.DebugFormat( + "Job created: Id={0}, Name={1}, RecurringJobId={2}", + jobId, + jobName, + recurringJobId ?? ""); + } + + public void OnPerforming(PerformingContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var backgroundJob = context.BackgroundJob; + var job = backgroundJob.Job; + var jobName = GetJobName(job); + var recurringJobId = context.GetJobParameter("RecurringJobId") ?? ""; + var args = FormatArguments(job.Args); + + Logger.DebugFormat( + "Starting job: Id={0}, Name={1}, RecurringJobId={2}, Queue={3}, Args={4}", + backgroundJob.Id, + jobName, + recurringJobId, + backgroundJob.Job.Queue, + args); + } + + public void OnPerformed(PerformedContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var backgroundJob = context.BackgroundJob; + var job = backgroundJob.Job; + var jobName = GetJobName(job); + + Logger.DebugFormat( + "Job completed: Id={0}, Name={1}, Succeeded={2}", + backgroundJob.Id, + jobName, + context.Exception == null); + } + + public void OnStateElection(ElectStateContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (context.CandidateState is FailedState failedState) + { + Logger.WarnFormat( + "Job '{0}' failed. Name={1}, Reason={2}", + context.BackgroundJob.Id, + GetJobName(context.BackgroundJob.Job), + failedState.Exception); + } + } + + public void OnStateApplied(ApplyStateContext context, IWriteOnlyTransaction transaction) + { + ArgumentNullException.ThrowIfNull(context); + + Logger.DebugFormat( + "Job state changed: Id={0}, Name={1}, OldState={2}, NewState={3}", + context.BackgroundJob.Id, + GetJobName(context.BackgroundJob.Job), + context.OldStateName ?? "", + context.NewState?.Name ?? ""); + } + + public void OnStateUnapplied(ApplyStateContext context, IWriteOnlyTransaction transaction) + { + ArgumentNullException.ThrowIfNull(context); + + Logger.DebugFormat( + "Job state unapplied: Id={0}, Name={1}, OldState={2}", + context.BackgroundJob.Id, + GetJobName(context.BackgroundJob.Job), + context.OldStateName ?? ""); + } + + private static string GetJobName(Hangfire.Common.Job job) + { + return $"{job.Method.Name}"; + } + + private static string FormatArguments(IReadOnlyList args) + { + if (args == null || args.Count == 0) + { + return "[]"; + } + + #pragma warning disable CA1031 // best-effort formatting for diagnostics + try + { + var rendered = args.Select(a => a?.ToString() ?? "null"); + return "[" + string.Join(", ", rendered) + "]"; + } + catch + { + return "[]"; + } + #pragma warning restore CA1031 + } +} diff --git a/src/api/framework/Infrastructure/Jobs/HangfireService.cs b/src/BuildingBlocks/Jobs/Services/HangfireService.cs similarity index 95% rename from src/api/framework/Infrastructure/Jobs/HangfireService.cs rename to src/BuildingBlocks/Jobs/Services/HangfireService.cs index 1c4785cd10..d16fd418e2 100644 --- a/src/api/framework/Infrastructure/Jobs/HangfireService.cs +++ b/src/BuildingBlocks/Jobs/Services/HangfireService.cs @@ -1,8 +1,7 @@ +using Hangfire; using System.Linq.Expressions; -using FSH.Framework.Core.Jobs; -using Hangfire; -namespace FSH.Framework.Infrastructure.Jobs; +namespace FSH.Framework.Jobs.Services; public class HangfireService : IJobService { @@ -56,4 +55,4 @@ public string Schedule(Expression> methodCall, DateTimeOffset enque public string Schedule(Expression> methodCall, DateTimeOffset enqueueAt) => BackgroundJob.Schedule(methodCall, enqueueAt); -} +} \ No newline at end of file diff --git a/src/api/framework/Core/Jobs/IJobService.cs b/src/BuildingBlocks/Jobs/Services/IJobService.cs similarity index 93% rename from src/api/framework/Core/Jobs/IJobService.cs rename to src/BuildingBlocks/Jobs/Services/IJobService.cs index 7016ae79d5..2cb2bea70d 100644 --- a/src/api/framework/Core/Jobs/IJobService.cs +++ b/src/BuildingBlocks/Jobs/Services/IJobService.cs @@ -1,6 +1,6 @@ -using System.Linq.Expressions; +using System.Linq.Expressions; -namespace FSH.Framework.Core.Jobs; +namespace FSH.Framework.Jobs.Services; public interface IJobService { @@ -37,4 +37,4 @@ public interface IJobService string Schedule(Expression> methodCall, DateTimeOffset enqueueAt); string Schedule(Expression> methodCall, DateTimeOffset enqueueAt); -} +} \ No newline at end of file diff --git a/src/BuildingBlocks/Mailing/Extensions.cs b/src/BuildingBlocks/Mailing/Extensions.cs new file mode 100644 index 0000000000..fc0146d75c --- /dev/null +++ b/src/BuildingBlocks/Mailing/Extensions.cs @@ -0,0 +1,23 @@ +using FSH.Framework.Mailing.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace FSH.Framework.Mailing; + +public static class Extensions +{ + public static IServiceCollection AddHeroMailing(this IServiceCollection services) + { + services.AddTransient(); + services + .AddOptions() + .BindConfiguration(nameof(MailOptions)) + .Validate(o => !string.IsNullOrWhiteSpace(o.From), "MailOptions: From is required.") + .Validate(o => !string.IsNullOrWhiteSpace(o.Host), "MailOptions: Host is required.") + .Validate(o => o.Port > 0, "MailOptions: Port must be greater than zero.") + .Validate(o => !string.IsNullOrWhiteSpace(o.UserName), "MailOptions: UserName is required.") + .Validate(o => !string.IsNullOrWhiteSpace(o.Password), "MailOptions: Password is required.") + .ValidateOnStart(); + return services; + } +} \ No newline at end of file diff --git a/src/api/framework/Core/Mail/MailOptions.cs b/src/BuildingBlocks/Mailing/MailOptions.cs similarity index 87% rename from src/api/framework/Core/Mail/MailOptions.cs rename to src/BuildingBlocks/Mailing/MailOptions.cs index 4b01169572..2a5614f967 100644 --- a/src/api/framework/Core/Mail/MailOptions.cs +++ b/src/BuildingBlocks/Mailing/MailOptions.cs @@ -1,4 +1,5 @@ -namespace FSH.Framework.Core.Mail; +namespace FSH.Framework.Mailing; + public class MailOptions { public string? From { get; set; } @@ -12,4 +13,4 @@ public class MailOptions public string? Password { get; set; } public string? DisplayName { get; set; } -} +} \ No newline at end of file diff --git a/src/api/framework/Core/Mail/MailRequest.cs b/src/BuildingBlocks/Mailing/MailRequest.cs similarity index 96% rename from src/api/framework/Core/Mail/MailRequest.cs rename to src/BuildingBlocks/Mailing/MailRequest.cs index 662dcd4010..597d2e9728 100644 --- a/src/api/framework/Core/Mail/MailRequest.cs +++ b/src/BuildingBlocks/Mailing/MailRequest.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; -namespace FSH.Framework.Core.Mail; +namespace FSH.Framework.Mailing; + public class MailRequest(Collection to, string subject, string? body = null, string? from = null, string? displayName = null, string? replyTo = null, string? replyToName = null, Collection? bcc = null, Collection? cc = null, IDictionary? attachmentData = null, IDictionary? headers = null) { public Collection To { get; } = to; @@ -24,4 +25,4 @@ public class MailRequest(Collection to, string subject, string? body = n public IDictionary AttachmentData { get; } = attachmentData ?? new Dictionary(); public IDictionary Headers { get; } = headers ?? new Dictionary(); -} +} \ No newline at end of file diff --git a/src/BuildingBlocks/Mailing/Mailing.csproj b/src/BuildingBlocks/Mailing/Mailing.csproj new file mode 100644 index 0000000000..906722a834 --- /dev/null +++ b/src/BuildingBlocks/Mailing/Mailing.csproj @@ -0,0 +1,21 @@ + + + + FSH.Framework.Mailing + FSH.Framework.Mailing + FullStackHero.Framework.Mailing + + + + + + + + + + + + + + + diff --git a/src/BuildingBlocks/Mailing/Services/IMailService.cs b/src/BuildingBlocks/Mailing/Services/IMailService.cs new file mode 100644 index 0000000000..6ecff3caa0 --- /dev/null +++ b/src/BuildingBlocks/Mailing/Services/IMailService.cs @@ -0,0 +1,6 @@ +namespace FSH.Framework.Mailing.Services; + +public interface IMailService +{ + Task SendAsync(MailRequest request, CancellationToken ct); +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Mail/SmtpMailService.cs b/src/BuildingBlocks/Mailing/Services/SmtpMailService.cs similarity index 83% rename from src/api/framework/Infrastructure/Mail/SmtpMailService.cs rename to src/BuildingBlocks/Mailing/Services/SmtpMailService.cs index aee5969491..c36bd57d1c 100644 --- a/src/api/framework/Infrastructure/Mail/SmtpMailService.cs +++ b/src/BuildingBlocks/Mailing/Services/SmtpMailService.cs @@ -1,15 +1,10 @@ -namespace FSH.Framework.Infrastructure.Mail; -using System; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using FSH.Framework.Core.Mail; -using MailKit.Net.Smtp; -using MailKit.Security; +using MailKit.Security; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using MimeKit; +using SmtpClient = MailKit.Net.Smtp.SmtpClient; + +namespace FSH.Framework.Mailing.Services; public class SmtpMailService(IOptions settings, ILogger logger) : IMailService { @@ -18,6 +13,8 @@ public class SmtpMailService(IOptions settings, ILogger dbSettings, ILogger logger) : IConnectionStringValidator +namespace FSH.Framework.Persistence; + +public sealed class ConnectionStringValidator(IOptions dbSettings, ILogger logger) : IConnectionStringValidator { private readonly DatabaseOptions _dbSettings = dbSettings.Value; private readonly ILogger _logger = logger; @@ -33,6 +34,7 @@ public bool TryValidate(string connectionString, string? dbProvider = null) return true; } +#pragma warning disable CA1031 // Validation should not throw to callers; we log and return false. catch (Exception ex) { #pragma warning disable S6667 // Logging in a catch clause should pass the caught exception as a parameter. @@ -40,5 +42,6 @@ public bool TryValidate(string connectionString, string? dbProvider = null) #pragma warning restore S6667 // Logging in a catch clause should pass the caught exception as a parameter. return false; } +#pragma warning restore CA1031 } } diff --git a/src/BuildingBlocks/Persistence/Context/BaseDbContext.cs b/src/BuildingBlocks/Persistence/Context/BaseDbContext.cs new file mode 100644 index 0000000000..0bbab352d8 --- /dev/null +++ b/src/BuildingBlocks/Persistence/Context/BaseDbContext.cs @@ -0,0 +1,45 @@ +using Finbuckle.MultiTenant.Abstractions; +using Finbuckle.MultiTenant.EntityFrameworkCore; +using FSH.Framework.Core.Domain; +using FSH.Framework.Shared.Multitenancy; +using FSH.Framework.Shared.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace FSH.Framework.Persistence.Context; + +public class BaseDbContext(IMultiTenantContextAccessor multiTenantContextAccessor, + DbContextOptions options, + IOptions settings, + IHostEnvironment environment) + : MultiTenantDbContext(multiTenantContextAccessor, options) +{ + private readonly DatabaseOptions _settings = settings.Value; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ArgumentNullException.ThrowIfNull(modelBuilder); + modelBuilder.AppendGlobalQueryFilter(s => !s.IsDeleted); + base.OnModelCreating(modelBuilder); + } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + ArgumentNullException.ThrowIfNull(optionsBuilder); + + if (!string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext.TenantInfo?.ConnectionString)) + { + optionsBuilder.ConfigureHeroDatabase( + _settings.Provider, + multiTenantContextAccessor.MultiTenantContext.TenantInfo.ConnectionString!, + _settings.MigrationsAssembly, + environment.IsDevelopment()); + } + } + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + TenantNotSetMode = TenantNotSetMode.Overwrite; + int result = await base.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return result; + } +} diff --git a/src/BuildingBlocks/Persistence/DatabaseOptionsStartupLogger.cs b/src/BuildingBlocks/Persistence/DatabaseOptionsStartupLogger.cs new file mode 100644 index 0000000000..16fba87cc6 --- /dev/null +++ b/src/BuildingBlocks/Persistence/DatabaseOptionsStartupLogger.cs @@ -0,0 +1,32 @@ +using FSH.Framework.Shared.Persistence; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace FSH.Framework.Persistence; + +public sealed class DatabaseOptionsStartupLogger : IHostedService +{ + private readonly ILogger _logger; + private readonly IOptions _options; + + public DatabaseOptionsStartupLogger( + ILogger logger, + IOptions options) + { + _logger = logger; + _options = options; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + var options = _options.Value; + _logger.LogInformation("current db provider: {Provider}", options.Provider); + _logger.LogInformation("for docs: https://www.fullstackhero.net"); + _logger.LogInformation("sponsor: https://opencollective.com/fullstackhero"); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + diff --git a/src/BuildingBlocks/Persistence/Extensions.cs b/src/BuildingBlocks/Persistence/Extensions.cs new file mode 100644 index 0000000000..38332bf4a1 --- /dev/null +++ b/src/BuildingBlocks/Persistence/Extensions.cs @@ -0,0 +1,39 @@ +using FSH.Framework.Shared.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace FSH.Framework.Persistence; + +public static class Extensions +{ + public static IServiceCollection AddHeroDatabaseOptions(this IServiceCollection services, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + services.AddOptions() + .Bind(configuration.GetSection(nameof(DatabaseOptions))) + .ValidateDataAnnotations() + .Validate(o => !string.IsNullOrWhiteSpace(o.Provider), "DatabaseOptions.Provider is required.") + .ValidateOnStart(); + services.AddHostedService(); + return services; + } + + public static IServiceCollection AddHeroDbContext(this IServiceCollection services) + where TContext : DbContext + { + ArgumentNullException.ThrowIfNull(services); + + services.AddDbContext((sp, options) => + { + var env = sp.GetRequiredService(); + var dbConfig = sp.GetRequiredService>().Value; + options.ConfigureHeroDatabase(dbConfig.Provider, dbConfig.ConnectionString, dbConfig.MigrationsAssembly, env.IsDevelopment()); + options.AddInterceptors(sp.GetServices()); + }); + return services; + } +} diff --git a/src/BuildingBlocks/Persistence/IConnectionStringValidator.cs b/src/BuildingBlocks/Persistence/IConnectionStringValidator.cs new file mode 100644 index 0000000000..65ef8612a1 --- /dev/null +++ b/src/BuildingBlocks/Persistence/IConnectionStringValidator.cs @@ -0,0 +1,6 @@ +namespace FSH.Framework.Persistence; + +public interface IConnectionStringValidator +{ + bool TryValidate(string connectionString, string? dbProvider = null); +} \ No newline at end of file diff --git a/src/api/framework/Core/Persistence/IDbInitializer.cs b/src/BuildingBlocks/Persistence/IDbInitializer.cs similarity index 76% rename from src/api/framework/Core/Persistence/IDbInitializer.cs rename to src/BuildingBlocks/Persistence/IDbInitializer.cs index ed4a08c844..5aaca67093 100644 --- a/src/api/framework/Core/Persistence/IDbInitializer.cs +++ b/src/BuildingBlocks/Persistence/IDbInitializer.cs @@ -1,6 +1,7 @@ -namespace FSH.Framework.Core.Persistence; +namespace FSH.Framework.Persistence; + public interface IDbInitializer { Task MigrateAsync(CancellationToken cancellationToken); Task SeedAsync(CancellationToken cancellationToken); -} +} \ No newline at end of file diff --git a/src/BuildingBlocks/Persistence/Inteceptors/DomainEventsInterceptor.cs b/src/BuildingBlocks/Persistence/Inteceptors/DomainEventsInterceptor.cs new file mode 100644 index 0000000000..8ffdb4483b --- /dev/null +++ b/src/BuildingBlocks/Persistence/Inteceptors/DomainEventsInterceptor.cs @@ -0,0 +1,57 @@ +using FSH.Framework.Core.Domain; +using Mediator; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace FSH.Framework.Persistence.Inteceptors; + +public sealed class DomainEventsInterceptor : SaveChangesInterceptor +{ + private readonly IPublisher _publisher; + private readonly ILogger _logger; + + public DomainEventsInterceptor(IPublisher publisher, ILogger logger) + { + _publisher = publisher; + _logger = logger; + } + + public override async ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + return await base.SavingChangesAsync(eventData, result, cancellationToken); + } + + public override async ValueTask SavedChangesAsync( + SaveChangesCompletedEventData eventData, + int result, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(eventData); + var context = eventData.Context; + if (context == null) + return await base.SavedChangesAsync(eventData, result, cancellationToken); + + var domainEvents = context.ChangeTracker + .Entries() + .SelectMany(e => + { + var pending = e.Entity.DomainEvents.ToArray(); + e.Entity.ClearDomainEvents(); + return pending; + }) + .ToArray(); + + if (domainEvents.Length == 0) + return await base.SavedChangesAsync(eventData, result, cancellationToken); + + _logger.LogDebug("Publishing {Count} domain events...", domainEvents.Length); + + foreach (var domainEvent in domainEvents) + await _publisher.Publish(domainEvent, cancellationToken).ConfigureAwait(false); + + return await base.SavedChangesAsync(eventData, result, cancellationToken); + } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Persistence/ModelBuilderExtensions.cs b/src/BuildingBlocks/Persistence/ModelBuilderExtensions.cs new file mode 100644 index 0000000000..397db064d8 --- /dev/null +++ b/src/BuildingBlocks/Persistence/ModelBuilderExtensions.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Query; +using System.Linq.Expressions; + +namespace FSH.Framework.Persistence; + +internal static class ModelBuilderExtensions +{ + public static ModelBuilder AppendGlobalQueryFilter(this ModelBuilder modelBuilder, Expression> filter) + { + // get a list of entities without a baseType that implement the interface TInterface + var entities = modelBuilder.Model.GetEntityTypes() + .Where(e => e.BaseType is null && e.ClrType.GetInterface(typeof(TInterface).Name) is not null) + .Select(e => e.ClrType); + + foreach (var entity in entities) + { + var parameterType = Expression.Parameter(modelBuilder.Entity(entity).Metadata.ClrType); + var filterBody = ReplacingExpressionVisitor.Replace(filter.Parameters.Single(), parameterType, filter.Body); + + // get the existing query filter + if (modelBuilder.Entity(entity).Metadata.GetQueryFilter() is { } existingFilter) + { + var existingFilterBody = ReplacingExpressionVisitor.Replace(existingFilter.Parameters.Single(), parameterType, existingFilter.Body); + + // combine the existing query filter with the new query filter + filterBody = Expression.AndAlso(existingFilterBody, filterBody); + } + + // apply the new query filter + modelBuilder.Entity(entity).HasQueryFilter(Expression.Lambda(filterBody, parameterType)); + } + + return modelBuilder; + } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Persistence/OptionsBuilderExtensions.cs b/src/BuildingBlocks/Persistence/OptionsBuilderExtensions.cs new file mode 100644 index 0000000000..4f4e7daf4a --- /dev/null +++ b/src/BuildingBlocks/Persistence/OptionsBuilderExtensions.cs @@ -0,0 +1,52 @@ +using FSH.Framework.Shared.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace FSH.Framework.Persistence; + +public static class OptionsBuilderExtensions +{ + public static DbContextOptionsBuilder ConfigureHeroDatabase( + this DbContextOptionsBuilder builder, + string dbProvider, + string connectionString, + string migrationsAssembly, + bool isDevelopment) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNullOrWhiteSpace(dbProvider); + + builder.ConfigureWarnings(warnings => + warnings.Log(RelationalEventId.PendingModelChangesWarning)); + + switch (dbProvider.ToUpperInvariant()) + { + case DbProviders.PostgreSQL: + builder.UseNpgsql(connectionString, e => + { + e.MigrationsAssembly(migrationsAssembly); + }); + break; + + case DbProviders.MSSQL: + builder.UseSqlServer(connectionString, e => + { + e.MigrationsAssembly(migrationsAssembly); + e.EnableRetryOnFailure(); + }); + break; + + default: + throw new InvalidOperationException( + $"Database Provider {dbProvider} is not supported."); + } + + if (isDevelopment) + { + builder.EnableSensitiveDataLogging(); + builder.EnableDetailedErrors(); + } + + return builder; + } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Persistence/Pagination/PaginationExtensions.cs b/src/BuildingBlocks/Persistence/Pagination/PaginationExtensions.cs new file mode 100644 index 0000000000..4d888ece17 --- /dev/null +++ b/src/BuildingBlocks/Persistence/Pagination/PaginationExtensions.cs @@ -0,0 +1,75 @@ +using FSH.Framework.Shared.Persistence; +using Microsoft.EntityFrameworkCore; +using System.Linq.Expressions; + +namespace FSH.Framework.Persistence; + +public static class PaginationExtensions +{ + private const int DefaultPageSize = 20; + private const int MaxPageSize = 100; + + public static Task> ToPagedResponseAsync( + this IQueryable source, + IPagedQuery pagination, + CancellationToken cancellationToken = default) + where T : class + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(pagination); + + var pageNumber = pagination.PageNumber is null or <= 0 + ? 1 + : pagination.PageNumber.Value; + + var pageSize = pagination.PageSize is null or <= 0 + ? DefaultPageSize + : pagination.PageSize.Value; + + if (pageSize > MaxPageSize) + { + pageSize = MaxPageSize; + } + + // Pagination is intentionally decoupled from specifications; the incoming + // source is expected to already have any required ordering applied via + // specifications or explicit ordering at call sites. + return ToPagedResponseInternalAsync(source, pageNumber, pageSize, cancellationToken); + } + + private static async Task> ToPagedResponseInternalAsync( + IQueryable source, + int pageNumber, + int pageSize, + CancellationToken cancellationToken) + where T : class + { + var totalCount = await source.LongCountAsync(cancellationToken).ConfigureAwait(false); + + var totalPages = totalCount == 0 + ? 0 + : (int)Math.Ceiling(totalCount / (double)pageSize); + + if (pageNumber > totalPages && totalPages > 0) + { + pageNumber = totalPages; + } + + var skip = (pageNumber - 1) * pageSize; + + var items = await source + .Skip(skip) + .Take(pageSize) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return new PagedResponse + { + Items = items, + PageNumber = pageNumber, + PageSize = pageSize, + TotalCount = totalCount, + TotalPages = totalPages + }; + } +} diff --git a/src/BuildingBlocks/Persistence/Persistence.csproj b/src/BuildingBlocks/Persistence/Persistence.csproj new file mode 100644 index 0000000000..c1bbe11b7c --- /dev/null +++ b/src/BuildingBlocks/Persistence/Persistence.csproj @@ -0,0 +1,25 @@ + + + + FSH.Framework.Persistence + FSH.Framework.Persistence + FullStackHero.Framework.Persistence + $(NoWarn);S4144;CS0618 + + + + + + + + + + + + + + + + + + diff --git a/src/BuildingBlocks/Persistence/Specifications/ISpecification.cs b/src/BuildingBlocks/Persistence/Specifications/ISpecification.cs new file mode 100644 index 0000000000..9cd8191c1b --- /dev/null +++ b/src/BuildingBlocks/Persistence/Specifications/ISpecification.cs @@ -0,0 +1,48 @@ +using System.Linq.Expressions; + +namespace FSH.Framework.Persistence; + +/// +/// Entity-level specification describing how to compose a query for . +/// Specifications are responsible only for query composition – never pagination. +/// +/// The root entity type. +public interface ISpecification + where T : class +{ + /// + /// Optional filter criteria applied via . + /// + Expression>? Criteria { get; } + + /// + /// Strongly-typed include expressions. + /// + IReadOnlyList>> Includes { get; } + + /// + /// String-based include paths. + /// + IReadOnlyList IncludeStrings { get; } + + /// + /// Default ordering expressions applied when no client-side sort override is present. + /// + IReadOnlyList> OrderExpressions { get; } + + /// + /// When true (default), queries are executed with AsNoTracking(). + /// + bool AsNoTracking { get; } + + /// + /// When true, queries are executed with AsSplitQuery(). + /// + bool AsSplitQuery { get; } + + /// + /// When true, EF Core global query filters are ignored. + /// + bool IgnoreQueryFilters { get; } +} + diff --git a/src/BuildingBlocks/Persistence/Specifications/ISpecificationOfTResult.cs b/src/BuildingBlocks/Persistence/Specifications/ISpecificationOfTResult.cs new file mode 100644 index 0000000000..d68526ad01 --- /dev/null +++ b/src/BuildingBlocks/Persistence/Specifications/ISpecificationOfTResult.cs @@ -0,0 +1,23 @@ +using System.Linq.Expressions; + +namespace FSH.Framework.Persistence; + +/// +/// Projected specification that composes a query for +/// and then selects into . +/// +/// +/// Includes may be ignored when a selector is present; behavior is documented +/// at the evaluator/extension level. +/// +/// The root entity type. +/// The projected result type. +public interface ISpecification : ISpecification + where T : class +{ + /// + /// Projection applied at the end of the query pipeline. + /// + Expression> Selector { get; } +} + diff --git a/src/BuildingBlocks/Persistence/Specifications/OrderExpression.cs b/src/BuildingBlocks/Persistence/Specifications/OrderExpression.cs new file mode 100644 index 0000000000..7c0c0a0537 --- /dev/null +++ b/src/BuildingBlocks/Persistence/Specifications/OrderExpression.cs @@ -0,0 +1,13 @@ +using System.Linq.Expressions; + +namespace FSH.Framework.Persistence; + +/// +/// Normalized representation of an ordering expression for specifications. +/// +/// The root entity type. +public sealed record OrderExpression( + Expression> KeySelector, + bool Descending) + where T : class; + diff --git a/src/BuildingBlocks/Persistence/Specifications/Specification.cs b/src/BuildingBlocks/Persistence/Specifications/Specification.cs new file mode 100644 index 0000000000..160433449c --- /dev/null +++ b/src/BuildingBlocks/Persistence/Specifications/Specification.cs @@ -0,0 +1,264 @@ +using System.Collections.ObjectModel; +using System.Linq.Expressions; + +namespace FSH.Framework.Persistence.Specifications; + +/// +/// Base specification for entity-level queries. +/// +/// The root entity type. +public abstract class Specification : ISpecification + where T : class +{ + private readonly List>> _criteria = []; + private readonly List>> _includes = []; + private readonly List _includeStrings = []; + private readonly List> _orderExpressions = []; + + protected Specification() + { + // Favor read-only queries by default. + AsNoTracking = true; + } + + public Expression>? Criteria => + _criteria.Count == 0 + ? null + : _criteria.Aggregate((current, next) => Combine(current, next)); + + public IReadOnlyList>> Includes => + new ReadOnlyCollection>>(_includes); + + public IReadOnlyList IncludeStrings => + new ReadOnlyCollection(_includeStrings); + + public IReadOnlyList> OrderExpressions => + new ReadOnlyCollection>(_orderExpressions); + + public bool AsNoTracking { get; private set; } + + public bool AsSplitQuery { get; private set; } + + public bool IgnoreQueryFilters { get; private set; } + + /// + /// Adds a filter criteria expression combined with existing criteria via logical AND. + /// + /// The filter expression. + protected void Where(Expression> expression) + { + ArgumentNullException.ThrowIfNull(expression); + _criteria.Add(expression); + } + + /// + /// Adds a strongly-typed include expression. + /// + protected void Include(Expression> includeExpression) + { + ArgumentNullException.ThrowIfNull(includeExpression); + _includes.Add(includeExpression); + } + + /// + /// Adds a string-based include path. + /// + protected void Include(string includeString) + { + ArgumentException.ThrowIfNullOrWhiteSpace(includeString); + _includeStrings.Add(includeString); + } + + /// + /// Configures primary ascending ordering. + /// + protected void OrderBy(Expression> keySelector) + { + ArgumentNullException.ThrowIfNull(keySelector); + _orderExpressions.Add(new OrderExpression(keySelector, Descending: false)); + } + + /// + /// Configures primary descending ordering. + /// + protected void OrderByDescending(Expression> keySelector) + { + ArgumentNullException.ThrowIfNull(keySelector); + _orderExpressions.Add(new OrderExpression(keySelector, Descending: true)); + } + + /// + /// Appends ascending ordering (ThenBy). + /// + protected void ThenBy(Expression> keySelector) + { + ArgumentNullException.ThrowIfNull(keySelector); + _orderExpressions.Add(new OrderExpression(keySelector, Descending: false)); + } + + /// + /// Appends descending ordering (ThenByDescending). + /// + protected void ThenByDescending(Expression> keySelector) + { + ArgumentNullException.ThrowIfNull(keySelector); + _orderExpressions.Add(new OrderExpression(keySelector, Descending: true)); + } + + /// + /// Clears all configured ordering expressions. + /// + protected void ClearOrderExpressions() => _orderExpressions.Clear(); + + /// + /// Enables AsNoTracking() for the query (default). + /// + protected void AsNoTrackingQuery() => AsNoTracking = true; + + /// + /// Enables tracking queries by disabling AsNoTracking(). + /// + protected void AsTrackingQuery() => AsNoTracking = false; + + /// + /// Enables AsSplitQuery(). + /// + protected void AsSplitQueryBehavior() => AsSplitQuery = true; + + /// + /// Enables IgnoreQueryFilters(). + /// + protected void IgnoreQueryFiltersBehavior() => IgnoreQueryFilters = true; + + /// + /// Applies a client-provided sort expression using a whitelist of sort keys. + /// When a non-empty sort expression is provided and at least one valid key is resolved, + /// the client ordering overrides any existing specification ordering. + /// When the sort expression is empty or contains only invalid keys, the provided + /// delegate is invoked to configure a + /// deterministic default ordering. + /// + /// Multi-column sort expression, for example: "Name,-CreatedOn". + /// + /// Delegate that configures the default ordering using the specification's ordering helpers. + /// + /// + /// Whitelisted mapping from sort keys to strongly-typed expressions. No reflection is used. + /// + protected void ApplySortingOverride( + string? sortExpression, + Action applyDefaultOrdering, + IReadOnlyDictionary>> sortMappings) + { + ArgumentNullException.ThrowIfNull(applyDefaultOrdering); + ArgumentNullException.ThrowIfNull(sortMappings); + + if (string.IsNullOrWhiteSpace(sortExpression)) + { + ClearOrderExpressions(); + applyDefaultOrdering(); + return; + } + + ClearOrderExpressions(); + + string[] clauses = sortExpression.Split( + ',', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + bool anyApplied = false; + + foreach (string rawClause in clauses) + { + if (string.IsNullOrWhiteSpace(rawClause)) + { + continue; + } + + string clause = rawClause.Trim(); + bool descending = clause[0] == '-'; + if (clause[0] is '-' or '+') + { + clause = clause[1..]; + } + + if (string.IsNullOrWhiteSpace(clause)) + { + continue; + } + + if (!sortMappings.TryGetValue(clause, out Expression>? selector)) + { + // Unknown sort key; skip to keep sorting safe. + continue; + } + + if (!anyApplied) + { + if (descending) + { + OrderByDescending(selector); + } + else + { + OrderBy(selector); + } + + anyApplied = true; + } + else + { + if (descending) + { + ThenByDescending(selector); + } + else + { + ThenBy(selector); + } + } + } + + if (!anyApplied) + { + applyDefaultOrdering(); + } + } + + private static Expression> Combine( + Expression> first, + Expression> second) + { + var parameter = Expression.Parameter(typeof(T), "x"); + var left = ReplaceParameter(first.Body, first.Parameters[0], parameter); + var right = ReplaceParameter(second.Body, second.Parameters[0], parameter); + var body = Expression.AndAlso(left, right); + return Expression.Lambda>(body, parameter); + } + + private static Expression ReplaceParameter( + Expression expression, + ParameterExpression source, + ParameterExpression target) + { + return new ParameterReplaceVisitor(source, target).Visit(expression) + ?? throw new InvalidOperationException("Failed to replace parameter in expression."); + } + + private sealed class ParameterReplaceVisitor : ExpressionVisitor + { + private readonly ParameterExpression _source; + private readonly ParameterExpression _target; + + public ParameterReplaceVisitor(ParameterExpression source, ParameterExpression target) + { + _source = source; + _target = target; + } + + protected override Expression VisitParameter(ParameterExpression node) + { + return node == _source ? _target : base.VisitParameter(node); + } + } +} diff --git a/src/BuildingBlocks/Persistence/Specifications/SpecificationEvaluator.cs b/src/BuildingBlocks/Persistence/Specifications/SpecificationEvaluator.cs new file mode 100644 index 0000000000..c60070e5a1 --- /dev/null +++ b/src/BuildingBlocks/Persistence/Specifications/SpecificationEvaluator.cs @@ -0,0 +1,94 @@ +using Microsoft.EntityFrameworkCore; + +namespace FSH.Framework.Persistence; + +/// +/// Internal evaluator that turns specifications into executable queries. +/// +internal static class SpecificationEvaluator +{ + public static IQueryable GetQuery( + IQueryable inputQuery, + ISpecification specification) + where T : class + { + ArgumentNullException.ThrowIfNull(inputQuery); + ArgumentNullException.ThrowIfNull(specification); + + IQueryable query = inputQuery; + + if (specification.IgnoreQueryFilters) + { + query = query.IgnoreQueryFilters(); + } + + if (specification.AsNoTracking) + { + query = query.AsNoTracking(); + } + + if (specification.AsSplitQuery) + { + query = query.AsSplitQuery(); + } + + if (specification.Criteria is not null) + { + query = query.Where(specification.Criteria); + } + + foreach (var include in specification.Includes) + { + query = query.Include(include); + } + + foreach (var includeString in specification.IncludeStrings) + { + query = query.Include(includeString); + } + + if (specification.OrderExpressions.Count > 0) + { + IOrderedQueryable? ordered = null; + + foreach (var order in specification.OrderExpressions) + { + if (ordered is null) + { + ordered = order.Descending + ? query.OrderByDescending(order.KeySelector) + : query.OrderBy(order.KeySelector); + } + else + { + ordered = order.Descending + ? ordered.ThenByDescending(order.KeySelector) + : ordered.ThenBy(order.KeySelector); + } + } + + if (ordered is not null) + { + query = ordered; + } + } + + return query; + } + + public static IQueryable GetQuery( + IQueryable inputQuery, + ISpecification specification) + where T : class + { + ArgumentNullException.ThrowIfNull(inputQuery); + ArgumentNullException.ThrowIfNull(specification); + + var query = GetQuery(inputQuery, (ISpecification)specification); + + // When a selector is configured, includes may be ignored at the EF level, + // but behavior is consistently applied by always projecting at the end. + return query.Select(specification.Selector); + } +} + diff --git a/src/BuildingBlocks/Persistence/Specifications/SpecificationExtensions.cs b/src/BuildingBlocks/Persistence/Specifications/SpecificationExtensions.cs new file mode 100644 index 0000000000..1f48208c98 --- /dev/null +++ b/src/BuildingBlocks/Persistence/Specifications/SpecificationExtensions.cs @@ -0,0 +1,32 @@ +namespace FSH.Framework.Persistence; + +/// +/// Extension methods to apply specifications to instances. +/// +public static class SpecificationExtensions +{ + /// + /// Applies an entity-level specification to the query. + /// + public static IQueryable ApplySpecification( + this IQueryable query, + ISpecification specification) + where T : class + { + ArgumentNullException.ThrowIfNull(query); + return SpecificationEvaluator.GetQuery(query, specification); + } + + /// + /// Applies a projected specification to the query. + /// + public static IQueryable ApplySpecification( + this IQueryable query, + ISpecification specification) + where T : class + { + ArgumentNullException.ThrowIfNull(query); + return SpecificationEvaluator.GetQuery(query, specification); + } +} + diff --git a/src/BuildingBlocks/Persistence/Specifications/SpecificationOfTResult.cs b/src/BuildingBlocks/Persistence/Specifications/SpecificationOfTResult.cs new file mode 100644 index 0000000000..cd1b5994f3 --- /dev/null +++ b/src/BuildingBlocks/Persistence/Specifications/SpecificationOfTResult.cs @@ -0,0 +1,27 @@ +using FSH.Framework.Persistence.Specifications; +using System.Linq.Expressions; + +namespace FSH.Framework.Persistence; + +/// +/// Base specification that composes a query for and +/// projects it into . +/// +/// The root entity type. +/// The projected result type. +public abstract class Specification : Specification, ISpecification + where T : class +{ + public Expression> Selector { get; private set; } = default!; + + /// + /// Configures the projection applied at the end of the query pipeline. + /// + /// Projection expression. + protected void Select(Expression> selector) + { + ArgumentNullException.ThrowIfNull(selector); + Selector = selector; + } +} + diff --git a/src/BuildingBlocks/Shared/Auditing/AuditAttributes.cs b/src/BuildingBlocks/Shared/Auditing/AuditAttributes.cs new file mode 100644 index 0000000000..dd93f9d7d4 --- /dev/null +++ b/src/BuildingBlocks/Shared/Auditing/AuditAttributes.cs @@ -0,0 +1,19 @@ +namespace FSH.Framework.Shared.Auditing; + +/// Marks a property that should be excluded from audit diffs and payloads. +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] +public sealed class AuditIgnoreAttribute : Attribute { } + +/// +/// Marks a property as sensitive (to be masked or hashed when serialized). +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] +public sealed class AuditSensitiveAttribute : Attribute +{ + public bool Hash { get; init; } + public bool Redact { get; init; } + + public AuditSensitiveAttribute(bool hash = false, bool redact = false) + => (Hash, Redact) = (hash, redact); +} + diff --git a/src/BuildingBlocks/Shared/Identity/ActionConstants.cs b/src/BuildingBlocks/Shared/Identity/ActionConstants.cs new file mode 100644 index 0000000000..dffdd1cd07 --- /dev/null +++ b/src/BuildingBlocks/Shared/Identity/ActionConstants.cs @@ -0,0 +1,13 @@ +namespace FSH.Framework.Shared.Constants; +public static class ActionConstants +{ + public const string View = nameof(View); + public const string Search = nameof(Search); + public const string Create = nameof(Create); + public const string Update = nameof(Update); + public const string Delete = nameof(Delete); + public const string Export = nameof(Export); + public const string Generate = nameof(Generate); + public const string Clean = nameof(Clean); + public const string UpgradeSubscription = nameof(UpgradeSubscription); +} diff --git a/src/BuildingBlocks/Shared/Identity/AuditingPermissionConstants.cs b/src/BuildingBlocks/Shared/Identity/AuditingPermissionConstants.cs new file mode 100644 index 0000000000..7f643674cc --- /dev/null +++ b/src/BuildingBlocks/Shared/Identity/AuditingPermissionConstants.cs @@ -0,0 +1,7 @@ +namespace FSH.Framework.Shared.Identity; + +public static class AuditingPermissionConstants +{ + public const string View = "Permissions.AuditTrails.View"; +} + diff --git a/src/api/framework/Infrastructure/Auth/Policy/EndpointExtensions.cs b/src/BuildingBlocks/Shared/Identity/Authorization/EndpointExtensions.cs similarity index 88% rename from src/api/framework/Infrastructure/Auth/Policy/EndpointExtensions.cs rename to src/BuildingBlocks/Shared/Identity/Authorization/EndpointExtensions.cs index 7cd6ea7e1e..1dc63ddbf7 100644 --- a/src/api/framework/Infrastructure/Auth/Policy/EndpointExtensions.cs +++ b/src/BuildingBlocks/Shared/Identity/Authorization/EndpointExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Builder; -namespace FSH.Framework.Infrastructure.Auth.Policy; +namespace FSH.Framework.Shared.Identity.Authorization; + public static class EndpointExtensions { public static TBuilder RequirePermission( @@ -9,4 +10,4 @@ public static TBuilder RequirePermission( { return endpointConventionBuilder.WithMetadata(new RequiredPermissionAttribute(requiredPermission, additionalRequiredPermissions)); } -} +} \ No newline at end of file diff --git a/src/BuildingBlocks/Shared/Identity/Authorization/RequiredPermissionAttribute.cs b/src/BuildingBlocks/Shared/Identity/Authorization/RequiredPermissionAttribute.cs new file mode 100644 index 0000000000..8076b69eaf --- /dev/null +++ b/src/BuildingBlocks/Shared/Identity/Authorization/RequiredPermissionAttribute.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Linq; +namespace FSH.Framework.Shared.Identity.Authorization; + +public interface IRequiredPermissionMetadata +{ + HashSet RequiredPermissions { get; } +} + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public sealed class RequiredPermissionAttribute : Attribute, IRequiredPermissionMetadata +{ + public HashSet RequiredPermissions { get; } + public string? RequiredPermission { get; } + public string[]? AdditionalRequiredPermissions { get; } + + public RequiredPermissionAttribute(string? requiredPermission, params string[]? additionalRequiredPermissions) + { + RequiredPermission = requiredPermission; + AdditionalRequiredPermissions = additionalRequiredPermissions; + + var permissions = new HashSet(StringComparer.OrdinalIgnoreCase); + if (!string.IsNullOrWhiteSpace(requiredPermission)) + { + permissions.Add(requiredPermission); + } + + if (additionalRequiredPermissions is { Length: > 0 }) + { + foreach (var p in additionalRequiredPermissions.Where(p => !string.IsNullOrWhiteSpace(p))) + { + permissions.Add(p); + } + } + + RequiredPermissions = permissions; + } +} + diff --git a/src/BuildingBlocks/Shared/Identity/ClaimConstants.cs b/src/BuildingBlocks/Shared/Identity/ClaimConstants.cs new file mode 100644 index 0000000000..dfb8e35d7b --- /dev/null +++ b/src/BuildingBlocks/Shared/Identity/ClaimConstants.cs @@ -0,0 +1,11 @@ +namespace FSH.Framework.Shared.Constants; + +public static class ClaimConstants +{ + public const string Tenant = "tenant"; + public const string Fullname = "fullName"; + public const string Permission = "permission"; + public const string ImageUrl = "image_url"; + public const string IpAddress = "ipAddress"; + public const string Expiration = "exp"; +} diff --git a/src/BuildingBlocks/Shared/Identity/Claims/ClaimsPrincipalExtensions.cs b/src/BuildingBlocks/Shared/Identity/Claims/ClaimsPrincipalExtensions.cs new file mode 100644 index 0000000000..74475efa97 --- /dev/null +++ b/src/BuildingBlocks/Shared/Identity/Claims/ClaimsPrincipalExtensions.cs @@ -0,0 +1,55 @@ +using FSH.Framework.Shared.Constants; +using System.Security.Claims; + +namespace FSH.Framework.Shared.Identity.Claims; + +public static class ClaimsPrincipalExtensions +{ + // Retrieves the email claim + public static string? GetEmail(this ClaimsPrincipal principal) => + principal?.FindFirstValue(ClaimTypes.Email); + + // Retrieves the tenant claim + public static string? GetTenant(this ClaimsPrincipal principal) => + principal?.FindFirstValue(CustomClaims.Tenant); + + // Retrieves the user's full name + public static string? GetFullName(this ClaimsPrincipal principal) => + principal?.FindFirstValue(CustomClaims.Fullname); + + // Retrieves the user's first name + public static string? GetFirstName(this ClaimsPrincipal principal) => + principal?.FindFirstValue(ClaimTypes.Name); + + // Retrieves the user's surname + public static string? GetSurname(this ClaimsPrincipal principal) => + principal?.FindFirstValue(ClaimTypes.Surname); + + // Retrieves the user's phone number + public static string? GetPhoneNumber(this ClaimsPrincipal principal) => + principal?.FindFirstValue(ClaimTypes.MobilePhone); + + // Retrieves the user's ID + public static string? GetUserId(this ClaimsPrincipal principal) => + principal?.FindFirstValue(ClaimTypes.NameIdentifier); + + // Retrieves the user's image URL as Uri + public static Uri? GetImageUrl(this ClaimsPrincipal principal) + { + var imageUrl = principal?.FindFirstValue(CustomClaims.ImageUrl); + return Uri.TryCreate(imageUrl, UriKind.Absolute, out var uri) ? uri : null; + } + + // Retrieves the user's token expiration date + public static DateTimeOffset GetExpiration(this ClaimsPrincipal principal) + { + var expiration = principal?.FindFirstValue(CustomClaims.Expiration); + return expiration != null + ? DateTimeOffset.FromUnixTimeSeconds(Convert.ToInt64(expiration)) + : throw new InvalidOperationException("Expiration claim not found."); + } + + // Helper method to extract claim value + private static string? FindFirstValue(this ClaimsPrincipal principal, string claimType) => + principal?.FindFirst(claimType)?.Value; +} \ No newline at end of file diff --git a/src/BuildingBlocks/Shared/Identity/CustomClaims.cs b/src/BuildingBlocks/Shared/Identity/CustomClaims.cs new file mode 100644 index 0000000000..bfa4ad8729 --- /dev/null +++ b/src/BuildingBlocks/Shared/Identity/CustomClaims.cs @@ -0,0 +1,11 @@ +namespace FSH.Framework.Shared.Constants; + +public static class CustomClaims +{ + public const string Tenant = "tenant"; + public const string Fullname = "fullName"; + public const string Permission = "permission"; + public const string ImageUrl = "image_url"; + public const string IpAddress = "ipAddress"; + public const string Expiration = "exp"; +} \ No newline at end of file diff --git a/src/BuildingBlocks/Shared/Identity/IdentityPermissionConstants.cs b/src/BuildingBlocks/Shared/Identity/IdentityPermissionConstants.cs new file mode 100644 index 0000000000..67aca149c4 --- /dev/null +++ b/src/BuildingBlocks/Shared/Identity/IdentityPermissionConstants.cs @@ -0,0 +1,37 @@ +namespace FSH.Framework.Shared.Identity; + +public static class IdentityPermissionConstants +{ + public static class Users + { + public const string View = "Permissions.Users.View"; + public const string Create = "Permissions.Users.Create"; + public const string Update = "Permissions.Users.Update"; + public const string Delete = "Permissions.Users.Delete"; + } + + public static class Roles + { + public const string View = "Permissions.Roles.View"; + public const string Create = "Permissions.Roles.Create"; + public const string Update = "Permissions.Roles.Update"; + public const string Delete = "Permissions.Roles.Delete"; + } + + public static class Sessions + { + public const string View = "Permissions.Sessions.View"; + public const string Revoke = "Permissions.Sessions.Revoke"; + public const string ViewAll = "Permissions.Sessions.ViewAll"; + public const string RevokeAll = "Permissions.Sessions.RevokeAll"; + } + + public static class Groups + { + public const string View = "Permissions.Groups.View"; + public const string Create = "Permissions.Groups.Create"; + public const string Update = "Permissions.Groups.Update"; + public const string Delete = "Permissions.Groups.Delete"; + public const string ManageMembers = "Permissions.Groups.ManageMembers"; + } +} diff --git a/src/BuildingBlocks/Shared/Identity/PermissionConstants.cs b/src/BuildingBlocks/Shared/Identity/PermissionConstants.cs new file mode 100644 index 0000000000..b20a8e5df6 --- /dev/null +++ b/src/BuildingBlocks/Shared/Identity/PermissionConstants.cs @@ -0,0 +1,61 @@ +namespace FSH.Framework.Shared.Constants; +public static class PermissionConstants +{ + private static readonly List _all = new() + { + // Built-in permissions + + // Tenants + new("View Tenants", ActionConstants.View, ResourceConstants.Tenants, IsRoot: true), + new("Create Tenants", ActionConstants.Create, ResourceConstants.Tenants, IsRoot: true), + new("Update Tenants", ActionConstants.Update, ResourceConstants.Tenants, IsRoot: true), + new("Upgrade Tenant Subscription", ActionConstants.UpgradeSubscription, ResourceConstants.Tenants, IsRoot: true), + + // Identity + new("View Users", ActionConstants.View, ResourceConstants.Users, IsBasic: true), + new("Search Users", ActionConstants.Search, ResourceConstants.Users), + new("Create Users", ActionConstants.Create, ResourceConstants.Users), + new("Update Users", ActionConstants.Update, ResourceConstants.Users), + new("Delete Users", ActionConstants.Delete, ResourceConstants.Users), + new("Export Users", ActionConstants.Export, ResourceConstants.Users), + new("View UserRoles", ActionConstants.View, ResourceConstants.UserRoles, IsBasic: true), + new("Update UserRoles", ActionConstants.Update, ResourceConstants.UserRoles), + new("View Roles", ActionConstants.View, ResourceConstants.Roles, IsBasic: true), + new("Create Roles", ActionConstants.Create, ResourceConstants.Roles), + new("Update Roles", ActionConstants.Update, ResourceConstants.Roles), + new("Delete Roles", ActionConstants.Delete, ResourceConstants.Roles), + new("View RoleClaims", ActionConstants.View, ResourceConstants.RoleClaims, IsBasic: true), + new("Update RoleClaims", ActionConstants.Update, ResourceConstants.RoleClaims), + + // Audit + new("View Audit Trails", ActionConstants.View, ResourceConstants.AuditTrails, IsBasic: true), + + // Hangfire / Dashboard + new("View Hangfire", ActionConstants.View, ResourceConstants.Hangfire, IsBasic: true), + new("View Dashboard", ActionConstants.View, ResourceConstants.Dashboard, IsBasic: true), + }; + + /// + /// Register additional permissions from external projects/modules. + /// + public static void Register(IEnumerable additionalPermissions) + { + _all.AddRange(from permission in additionalPermissions + where !_all.Any(p => p.Name == permission.Name) + select permission); + } + public const string RequiredPermissionPolicyName = "RequiredPermission"; + public static IReadOnlyList All => _all.AsReadOnly(); + public static IReadOnlyList Root => [.. _all.Where(p => p.IsRoot)]; + public static IReadOnlyList Admin => [.. _all.Where(p => !p.IsRoot)]; + public static IReadOnlyList Basic => [.. _all.Where(p => p.IsBasic)]; +} + +public record FshPermission(string Description, string Action, string Resource, bool IsBasic = false, bool IsRoot = false) +{ + public string Name => NameFor(Action, Resource); + public static string NameFor(string action, string resource) + { + return $"Permissions.{resource}.{action}"; + } +} diff --git a/src/BuildingBlocks/Shared/Identity/ResourceConstants.cs b/src/BuildingBlocks/Shared/Identity/ResourceConstants.cs new file mode 100644 index 0000000000..09084ab42b --- /dev/null +++ b/src/BuildingBlocks/Shared/Identity/ResourceConstants.cs @@ -0,0 +1,12 @@ +namespace FSH.Framework.Shared.Constants; +public static class ResourceConstants +{ + public const string Tenants = nameof(Tenants); + public const string Dashboard = nameof(Dashboard); + public const string Hangfire = nameof(Hangfire); + public const string Users = nameof(Users); + public const string UserRoles = nameof(UserRoles); + public const string Roles = nameof(Roles); + public const string RoleClaims = nameof(RoleClaims); + public const string AuditTrails = nameof(AuditTrails); +} diff --git a/src/BuildingBlocks/Shared/Identity/RoleConstants.cs b/src/BuildingBlocks/Shared/Identity/RoleConstants.cs new file mode 100644 index 0000000000..454451116a --- /dev/null +++ b/src/BuildingBlocks/Shared/Identity/RoleConstants.cs @@ -0,0 +1,23 @@ +using System.Collections.ObjectModel; + +namespace FSH.Framework.Shared.Constants; + +public static class RoleConstants +{ + public const string Admin = nameof(Admin); + public const string Basic = nameof(Basic); + + /// + /// The base roles provided by the framework. + /// + public static IReadOnlyList DefaultRoles { get; } = new ReadOnlyCollection(new[] + { + Admin, + Basic + }); + + /// + /// Determines whether the role is a framework-defined default. + /// + public static bool IsDefault(string roleName) => DefaultRoles.Contains(roleName); +} \ No newline at end of file diff --git a/src/BuildingBlocks/Shared/Multitenancy/AppTenantInfo.cs b/src/BuildingBlocks/Shared/Multitenancy/AppTenantInfo.cs new file mode 100644 index 0000000000..b843153fc1 --- /dev/null +++ b/src/BuildingBlocks/Shared/Multitenancy/AppTenantInfo.cs @@ -0,0 +1,67 @@ +using Finbuckle.MultiTenant.Abstractions; + +namespace FSH.Framework.Shared.Multitenancy; + +public record AppTenantInfo(string Id, string Identifier, string? Name = null) + : TenantInfo(Id, Identifier, Name), IAppTenantInfo +{ + // Parameterless constructor for tooling/EF. + public AppTenantInfo() : this(string.Empty, string.Empty) + { + } + + public AppTenantInfo(string id, string name, string? connectionString, string adminEmail, string? issuer = null) + : this(id, id, name) + { + ConnectionString = connectionString ?? string.Empty; + AdminEmail = adminEmail; + IsActive = true; + Issuer = issuer; + + // Add Default 1 Month Validity for all new tenants. Something like a DEMO period for tenants. + ValidUpto = DateTime.UtcNow.AddMonths(1); + } + + public string ConnectionString { get; set; } = string.Empty; + public string AdminEmail { get; set; } = default!; + public bool IsActive { get; set; } + public DateTime ValidUpto { get; set; } + public string? Issuer { get; set; } + + public void AddValidity(int months) => + ValidUpto = ValidUpto.AddMonths(months); + + public void SetValidity(in DateTime validTill) + { + var normalized = validTill; + ValidUpto = ValidUpto < normalized + ? normalized + : throw new InvalidOperationException("Subscription cannot be backdated."); + } + + public void Activate() + { + if (Id == MultitenancyConstants.Root.Id) + { + throw new InvalidOperationException("Invalid Tenant"); + } + + IsActive = true; + } + + public void Deactivate() + { + if (Id == MultitenancyConstants.Root.Id) + { + throw new InvalidOperationException("Invalid Tenant"); + } + + IsActive = false; + } + + string? IAppTenantInfo.ConnectionString + { + get => ConnectionString; + set => ConnectionString = value ?? throw new InvalidOperationException("ConnectionString can't be null."); + } +} diff --git a/src/BuildingBlocks/Shared/Multitenancy/IAppTenantInfo.cs b/src/BuildingBlocks/Shared/Multitenancy/IAppTenantInfo.cs new file mode 100644 index 0000000000..8a74d2b751 --- /dev/null +++ b/src/BuildingBlocks/Shared/Multitenancy/IAppTenantInfo.cs @@ -0,0 +1,6 @@ +namespace FSH.Framework.Shared.Multitenancy; + +public interface IAppTenantInfo +{ + string? ConnectionString { get; set; } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Shared/Multitenancy/MultitenancyConstants.cs b/src/BuildingBlocks/Shared/Multitenancy/MultitenancyConstants.cs new file mode 100644 index 0000000000..8c84aebc9f --- /dev/null +++ b/src/BuildingBlocks/Shared/Multitenancy/MultitenancyConstants.cs @@ -0,0 +1,26 @@ +namespace FSH.Framework.Shared.Multitenancy; + +public static class MultitenancyConstants +{ + public static class Root + { + public const string Id = "root"; + public const string Name = "Root"; + public const string EmailAddress = "admin@root.com"; + public const string DefaultProfilePicture = "assets/defaults/profile-picture.webp"; + public const string Issuer = "mukesh.murugan"; + } + + public const string DefaultPassword = "123Pa$$word!"; + public const string Identifier = "tenant"; + public const string Schema = "tenant"; + + public static class Permissions + { + public const string View = "Permissions.Tenants.View"; + public const string Create = "Permissions.Tenants.Create"; + public const string Update = "Permissions.Tenants.Update"; + public const string ViewTheme = "Permissions.Tenants.ViewTheme"; + public const string UpdateTheme = "Permissions.Tenants.UpdateTheme"; + } +} diff --git a/src/BuildingBlocks/Shared/Persistence/DatabaseOptions.cs b/src/BuildingBlocks/Shared/Persistence/DatabaseOptions.cs new file mode 100644 index 0000000000..807df8f103 --- /dev/null +++ b/src/BuildingBlocks/Shared/Persistence/DatabaseOptions.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; + +namespace FSH.Framework.Shared.Persistence; + +/// +/// Configuration options for database provider selection and connection information. +/// +public class DatabaseOptions : IValidatableObject +{ + /// + /// The database provider to use. Valid values are or . + /// Defaults to PostgreSQL. + /// + public string Provider { get; set; } = DbProviders.PostgreSQL; + + /// + /// The connection string used by EF Core DbContexts and related services. + /// + public string ConnectionString { get; set; } = string.Empty; + + /// + /// The assembly that contains EF Core migrations for the selected provider. + /// + public string MigrationsAssembly { get; set; } = string.Empty; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (string.IsNullOrEmpty(ConnectionString)) + { + yield return new ValidationResult("connection string cannot be empty.", new[] { nameof(ConnectionString) }); + } + } +} diff --git a/src/BuildingBlocks/Shared/Persistence/DbProviders.cs b/src/BuildingBlocks/Shared/Persistence/DbProviders.cs new file mode 100644 index 0000000000..c9713513d9 --- /dev/null +++ b/src/BuildingBlocks/Shared/Persistence/DbProviders.cs @@ -0,0 +1,17 @@ +namespace FSH.Framework.Shared.Persistence; + +/// +/// Supported database providers for the starter kit. +/// +public static class DbProviders +{ + /// + /// PostgreSQL database provider. + /// + public const string PostgreSQL = "POSTGRESQL"; + + /// + /// Microsoft SQL Server (MSSQL) database provider. + /// + public const string MSSQL = "MSSQL"; +} diff --git a/src/BuildingBlocks/Shared/Persistence/IPagedQuery.cs b/src/BuildingBlocks/Shared/Persistence/IPagedQuery.cs new file mode 100644 index 0000000000..e09dc9ab00 --- /dev/null +++ b/src/BuildingBlocks/Shared/Persistence/IPagedQuery.cs @@ -0,0 +1,24 @@ +namespace FSH.Framework.Shared.Persistence; + +/// +/// Shared pagination and sorting contract that can be implemented +/// or extended by module-specific request types. +/// +public interface IPagedQuery +{ + /// + /// 1-based page number. Values less than 1 are normalized to 1. + /// + int? PageNumber { get; set; } + + /// + /// Requested page size. Implementations may enforce caps. + /// + int? PageSize { get; set; } + + /// + /// Multi-column sort expression, for example: "Name,-CreatedOn". + /// "-" prefix indicates descending order. + /// + string? Sort { get; set; } +} diff --git a/src/BuildingBlocks/Shared/Persistence/PagedResponse.cs b/src/BuildingBlocks/Shared/Persistence/PagedResponse.cs new file mode 100644 index 0000000000..f5b331ac61 --- /dev/null +++ b/src/BuildingBlocks/Shared/Persistence/PagedResponse.cs @@ -0,0 +1,19 @@ +namespace FSH.Framework.Shared.Persistence; + +public sealed class PagedResponse +{ + public IReadOnlyCollection Items { get; init; } = Array.Empty(); + + public int PageNumber { get; init; } + + public int PageSize { get; init; } + + public long TotalCount { get; init; } + + public int TotalPages { get; init; } + + public bool HasNext => PageNumber < TotalPages; + + public bool HasPrevious => PageNumber > 1; +} + diff --git a/src/BuildingBlocks/Shared/Shared.csproj b/src/BuildingBlocks/Shared/Shared.csproj new file mode 100644 index 0000000000..589830b9a5 --- /dev/null +++ b/src/BuildingBlocks/Shared/Shared.csproj @@ -0,0 +1,21 @@ + + + + FSH.Framework.Shared + FSH.Framework.Shared + FullStackHero.Framework.Shared + $(NoWarn);CA1716;CA1711;CA1019;CA1305 + + + + + + + + + + + + + + diff --git a/src/BuildingBlocks/Storage/DTOs/FileUploadRequest.cs b/src/BuildingBlocks/Storage/DTOs/FileUploadRequest.cs new file mode 100644 index 0000000000..5f26403826 --- /dev/null +++ b/src/BuildingBlocks/Storage/DTOs/FileUploadRequest.cs @@ -0,0 +1,8 @@ +namespace FSH.Framework.Storage.DTOs; + +public class FileUploadRequest +{ + public string FileName { get; set; } = default!; + public string ContentType { get; set; } = default!; + public List Data { get; set; } = []; +} \ No newline at end of file diff --git a/src/BuildingBlocks/Storage/Extensions.cs b/src/BuildingBlocks/Storage/Extensions.cs new file mode 100644 index 0000000000..adda870bec --- /dev/null +++ b/src/BuildingBlocks/Storage/Extensions.cs @@ -0,0 +1,53 @@ +using Amazon; +using Amazon.S3; +using FSH.Framework.Storage.Local; +using FSH.Framework.Storage.S3; +using FSH.Framework.Storage.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace FSH.Framework.Storage; + +public static class Extensions +{ + public static IServiceCollection AddHeroLocalFileStorage(this IServiceCollection services) + { + services.AddScoped(); + return services; + } + + public static IServiceCollection AddHeroStorage(this IServiceCollection services, IConfiguration configuration) + { + var provider = configuration["Storage:Provider"]?.ToLowerInvariant(); + + if (string.Equals(provider, "s3", StringComparison.OrdinalIgnoreCase)) + { + services.Configure(configuration.GetSection("Storage:S3")); + + services.AddSingleton(sp => + { + var options = sp.GetRequiredService>().Value; + + if (string.IsNullOrWhiteSpace(options.Bucket)) + { + throw new InvalidOperationException("Storage:S3:Bucket is required when using S3 storage."); + } + + if (string.IsNullOrWhiteSpace(options.Region)) + { + return new AmazonS3Client(); + } + + return new AmazonS3Client(RegionEndpoint.GetBySystemName(options.Region)); + }); + + services.AddTransient(); + } + else + { + services.AddScoped(); + } + + return services; + } +} diff --git a/src/BuildingBlocks/Storage/FileType.cs b/src/BuildingBlocks/Storage/FileType.cs new file mode 100644 index 0000000000..0f7f4aa147 --- /dev/null +++ b/src/BuildingBlocks/Storage/FileType.cs @@ -0,0 +1,25 @@ +namespace FSH.Framework.Storage; + +public enum FileType +{ + Image, + Document, + Pdf +} + +public class FileValidationRules +{ + public IReadOnlyList AllowedExtensions { get; init; } = Array.Empty(); + public int MaxSizeInMB { get; init; } = 5; +} + +public static class FileTypeMetadata +{ + public static FileValidationRules GetRules(FileType type) => + type switch + { + FileType.Image => new() { AllowedExtensions = [".jpg", ".jpeg", ".png"], MaxSizeInMB = 5 }, + FileType.Pdf => new() { AllowedExtensions = [".pdf"], MaxSizeInMB = 10 }, + _ => throw new NotSupportedException($"Unsupported file type: {type}") + }; +} \ No newline at end of file diff --git a/src/BuildingBlocks/Storage/Local/LocalStorageService.cs b/src/BuildingBlocks/Storage/Local/LocalStorageService.cs new file mode 100644 index 0000000000..8b834813e2 --- /dev/null +++ b/src/BuildingBlocks/Storage/Local/LocalStorageService.cs @@ -0,0 +1,72 @@ +using FSH.Framework.Storage.DTOs; +using FSH.Framework.Storage.Services; +using Microsoft.AspNetCore.Hosting; +using System.Text.RegularExpressions; + +namespace FSH.Framework.Storage.Local; + +public class LocalStorageService : IStorageService +{ + private const string UploadBasePath = "uploads"; + private readonly string _rootPath; + + public LocalStorageService(IWebHostEnvironment environment) + { + ArgumentNullException.ThrowIfNull(environment); + _rootPath = string.IsNullOrWhiteSpace(environment.WebRootPath) + ? Path.Combine(environment.ContentRootPath, "wwwroot") + : environment.WebRootPath; + } + + public async Task UploadAsync(FileUploadRequest request, FileType fileType, CancellationToken cancellationToken = default) + where T : class + { + ArgumentNullException.ThrowIfNull(request); + + var rules = FileTypeMetadata.GetRules(fileType); + var extension = Path.GetExtension(request.FileName); + + if (string.IsNullOrWhiteSpace(extension) || + !rules.AllowedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"File type '{extension}' is not allowed. Allowed: {string.Join(", ", rules.AllowedExtensions)}"); + } + + if (request.Data.Count > rules.MaxSizeInMB * 1024 * 1024) + { + throw new InvalidOperationException($"File exceeds max size of {rules.MaxSizeInMB} MB."); + } + + #pragma warning disable CA1308 // folder names are intentionally lower-case for URLs/paths + var folder = Regex.Replace(typeof(T).Name.ToLowerInvariant(), @"[^a-z0-9]", "_"); + #pragma warning restore CA1308 + var safeFileName = $"{Guid.NewGuid():N}_{SanitizeFileName(request.FileName)}"; + var relativePath = Path.Combine(UploadBasePath, folder, safeFileName); + var fullPath = Path.Combine(_rootPath, relativePath); + + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + + await File.WriteAllBytesAsync(fullPath, request.Data.ToArray(), cancellationToken); + + return relativePath.Replace("\\", "/", StringComparison.Ordinal); // Normalize for URLs + } + + public Task RemoveAsync(string path, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(path)) return Task.CompletedTask; + + var fullPath = Path.Combine(_rootPath, path); + + if (File.Exists(fullPath)) + { + File.Delete(fullPath); + } + + return Task.CompletedTask; + } + + private static string SanitizeFileName(string fileName) + { + return Regex.Replace(fileName, @"[^a-zA-Z0-9_\.-]", "_"); + } +} diff --git a/src/BuildingBlocks/Storage/S3/S3StorageOptions.cs b/src/BuildingBlocks/Storage/S3/S3StorageOptions.cs new file mode 100644 index 0000000000..f05d55bef8 --- /dev/null +++ b/src/BuildingBlocks/Storage/S3/S3StorageOptions.cs @@ -0,0 +1,10 @@ +namespace FSH.Framework.Storage.S3; + +public sealed class S3StorageOptions +{ + public string? Bucket { get; set; } + public string? Region { get; set; } + public string? Prefix { get; set; } + public bool PublicRead { get; set; } = true; + public string? PublicBaseUrl { get; set; } +} diff --git a/src/BuildingBlocks/Storage/S3/S3StorageService.cs b/src/BuildingBlocks/Storage/S3/S3StorageService.cs new file mode 100644 index 0000000000..e46548b80e --- /dev/null +++ b/src/BuildingBlocks/Storage/S3/S3StorageService.cs @@ -0,0 +1,146 @@ +using Amazon.S3; +using Amazon.S3.Model; +using FSH.Framework.Storage.DTOs; +using FSH.Framework.Storage.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System; +using System.Text.RegularExpressions; + +namespace FSH.Framework.Storage.S3; + +internal sealed class S3StorageService : IStorageService +{ + private readonly IAmazonS3 _s3; + private readonly S3StorageOptions _options; + private readonly ILogger _logger; + + private const string UploadBasePath = "uploads"; + + public S3StorageService(IAmazonS3 s3, IOptions options, ILogger logger) + { + _s3 = s3; + _options = options.Value ?? throw new ArgumentNullException(nameof(options)); + _logger = logger; + + if (string.IsNullOrWhiteSpace(_options.Bucket)) + { + throw new InvalidOperationException("Storage:S3:Bucket is required when using S3 storage."); + } + } + + public async Task UploadAsync(FileUploadRequest request, FileType fileType, CancellationToken cancellationToken = default) where T : class + { + ArgumentNullException.ThrowIfNull(request); + + var rules = FileTypeMetadata.GetRules(fileType); + var extension = Path.GetExtension(request.FileName); + + if (string.IsNullOrWhiteSpace(extension) || !rules.AllowedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"File type '{extension}' is not allowed. Allowed: {string.Join(", ", rules.AllowedExtensions)}"); + } + + if (request.Data.Count > rules.MaxSizeInMB * 1024 * 1024) + { + throw new InvalidOperationException($"File exceeds max size of {rules.MaxSizeInMB} MB."); + } + + var key = BuildKey(SanitizeFileName(request.FileName)); + + using var stream = new MemoryStream([.. request.Data]); + + var putRequest = new PutObjectRequest + { + BucketName = _options.Bucket, + Key = key, + InputStream = stream, + ContentType = request.ContentType + }; + + // Rely on bucket policy for public access; do not set ACLs to avoid conflicts with ACL-disabled buckets. + await _s3.PutObjectAsync(putRequest, cancellationToken).ConfigureAwait(false); + _logger.LogInformation("Uploaded file to S3 bucket {Bucket} with key {Key}", _options.Bucket, key); + + return BuildPublicUrl(key); + } + + public async Task RemoveAsync(string path, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(path)) + { + return; + } + + try + { + var key = NormalizeKey(path); + await _s3.DeleteObjectAsync(_options.Bucket, key, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete S3 object {Path}", path); + } + } + + private string BuildKey(string fileName) where T : class + { + var folder = Regex.Replace(typeof(T).Name.ToLowerInvariant(), @"[^a-z0-9]", "_"); + var relativePath = Path.Combine(UploadBasePath, folder, $"{Guid.NewGuid():N}_{fileName}").Replace("\\", "/", StringComparison.Ordinal); + if (!string.IsNullOrWhiteSpace(_options.Prefix)) + { + return $"{_options.Prefix.TrimEnd('/')}/{relativePath}"; + } + + return relativePath; + } + + private string BuildPublicUrl(string key) + { + var safeKey = key.TrimStart('/'); + + if (!string.IsNullOrWhiteSpace(_options.PublicBaseUrl)) + { + return $"{_options.PublicBaseUrl.TrimEnd('/')}/{safeKey}"; + } + + if (!_options.PublicRead) + { + return key; + } + + if (string.IsNullOrWhiteSpace(_options.Region) || string.Equals(_options.Region, "us-east-1", StringComparison.OrdinalIgnoreCase)) + { + return $"https://{_options.Bucket}.s3.amazonaws.com/{safeKey}"; + } + + return $"https://{_options.Bucket}.s3.{_options.Region}.amazonaws.com/{safeKey}"; + } + + private string NormalizeKey(string path) + { + // If a full URL was passed, strip host and query to get the object key. + if (Uri.TryCreate(path, UriKind.Absolute, out var uri)) + { + path = uri.AbsolutePath; + } + + var trimmed = path.TrimStart('/'); + if (!string.IsNullOrWhiteSpace(_options.Prefix) && trimmed.StartsWith(_options.Prefix, StringComparison.OrdinalIgnoreCase)) + { + return trimmed; + } + + if (!string.IsNullOrWhiteSpace(_options.Prefix)) + { + return $"{_options.Prefix.TrimEnd('/')}/{trimmed}"; + } + + return trimmed; + } + + private static string SanitizeFileName(string fileName) + { + return Regex.Replace(fileName, @"[^a-zA-Z0-9_\.-]", "_"); + } +} diff --git a/src/BuildingBlocks/Storage/Services/IStorageService.cs b/src/BuildingBlocks/Storage/Services/IStorageService.cs new file mode 100644 index 0000000000..010a0d2d6e --- /dev/null +++ b/src/BuildingBlocks/Storage/Services/IStorageService.cs @@ -0,0 +1,13 @@ +using FSH.Framework.Storage.DTOs; + +namespace FSH.Framework.Storage.Services; + +public interface IStorageService +{ + Task UploadAsync( + FileUploadRequest request, + FileType fileType, + CancellationToken cancellationToken = default) where T : class; + + Task RemoveAsync(string path, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/BuildingBlocks/Storage/Storage.csproj b/src/BuildingBlocks/Storage/Storage.csproj new file mode 100644 index 0000000000..dbca67a6bb --- /dev/null +++ b/src/BuildingBlocks/Storage/Storage.csproj @@ -0,0 +1,26 @@ + + + + FSH.Framework.Storage + FSH.Framework.Storage + FullStackHero.Framework.Storage + $(NoWarn);CA1031;CA1056;CA1002;CA2227;CA1812;CA1308;CA1062 + + + + + + + + + + + + + + + + + + + diff --git a/src/BuildingBlocks/Web/Auth/CurrentUserMiddleware.cs b/src/BuildingBlocks/Web/Auth/CurrentUserMiddleware.cs new file mode 100644 index 0000000000..ebc9e60a8e --- /dev/null +++ b/src/BuildingBlocks/Web/Auth/CurrentUserMiddleware.cs @@ -0,0 +1,38 @@ +using FSH.Framework.Core.Context; +using FSH.Framework.Shared.Identity.Claims; +using Microsoft.AspNetCore.Http; +using System.Diagnostics; + +namespace FSH.Framework.Web.Auth; + +public class CurrentUserMiddleware(ICurrentUserInitializer currentUserInitializer) : IMiddleware +{ + private readonly ICurrentUserInitializer _currentUserInitializer = currentUserInitializer; + + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(next); + + _currentUserInitializer.SetCurrentUser(context.User); + + var activity = Activity.Current; + if (activity is not null && context.User?.Identity?.IsAuthenticated == true) + { + var userId = context.User.GetUserId(); + var tenant = context.User.GetTenant(); + var correlationId = context.Request.HttpContext.TraceIdentifier; + + if (!string.IsNullOrEmpty(userId)) + activity.SetTag("fsh.user_id", userId); + + if (!string.IsNullOrEmpty(tenant)) + activity.SetTag("fsh.tenant_id", tenant); + + if (!string.IsNullOrEmpty(correlationId)) + activity.SetTag("fsh.correlation_id", correlationId); + } + + await next(context); + } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Web/Cors/CorsOptions.cs b/src/BuildingBlocks/Web/Cors/CorsOptions.cs new file mode 100644 index 0000000000..380e3027a2 --- /dev/null +++ b/src/BuildingBlocks/Web/Cors/CorsOptions.cs @@ -0,0 +1,9 @@ +namespace FSH.Framework.Web.Cors; + +public sealed class CorsOptions +{ + public bool AllowAll { get; init; } = true; + public string[] AllowedOrigins { get; init; } = []; + public string[] AllowedHeaders { get; init; } = ["*"]; + public string[] AllowedMethods { get; init; } = ["*"]; +} \ No newline at end of file diff --git a/src/BuildingBlocks/Web/Cors/Extensions.cs b/src/BuildingBlocks/Web/Cors/Extensions.cs new file mode 100644 index 0000000000..a4cfe06df3 --- /dev/null +++ b/src/BuildingBlocks/Web/Cors/Extensions.cs @@ -0,0 +1,65 @@ +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using AspNetCorsOptions = Microsoft.AspNetCore.Cors.Infrastructure.CorsOptions; + +namespace FSH.Framework.Web.Cors; + +public static class Extensions +{ + private const string PolicyName = "FSHCorsPolicy"; + + public static IServiceCollection AddHeroCors( + this IServiceCollection services, + IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + services + .AddOptions() + .Bind(configuration.GetSection(nameof(CorsOptions))) + .Validate(settings => settings.AllowAll || settings.AllowedOrigins.Length > 0, "CorsOptions: AllowedOrigins are required when AllowAll is false.") + .Validate(settings => settings.AllowAll || settings.AllowedHeaders.Length > 0, "CorsOptions: AllowedHeaders are required when AllowAll is false.") + .Validate(settings => settings.AllowAll || settings.AllowedMethods.Length > 0, "CorsOptions: AllowedMethods are required when AllowAll is false.") + .ValidateOnStart(); + + services.AddCors(); + services.AddSingleton>(sp => + { + var corsSettings = sp.GetRequiredService>(); + return new ConfigureOptions(options => + { + options.AddPolicy(PolicyName, builder => + { + var settings = corsSettings.Value; + if (settings.AllowAll) + { + builder + .AllowAnyOrigin() + .AllowAnyHeader() + .AllowAnyMethod(); + } + else + { + builder + .WithOrigins(settings.AllowedOrigins) + .WithHeaders(settings.AllowedHeaders) + .WithMethods(settings.AllowedMethods) + .AllowCredentials(); + } + }); + }); + }); + + return services; + } + + public static void UseHeroCors(this WebApplication app) + { + ArgumentNullException.ThrowIfNull(app); + app.UseCors(PolicyName); + } +} diff --git a/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs b/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs new file mode 100644 index 0000000000..8ea24f0ed1 --- /dev/null +++ b/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs @@ -0,0 +1,75 @@ +using FSH.Framework.Core.Exceptions; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Serilog.Context; + +namespace FSH.Framework.Web.Exceptions; + +public class GlobalExceptionHandler(ILogger logger) : IExceptionHandler +{ + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(exception); + + var problemDetails = new ProblemDetails + { + Instance = httpContext.Request.Path + }; + + var statusCode = StatusCodes.Status500InternalServerError; + + if (exception is FluentValidation.ValidationException fluentException) + { + statusCode = StatusCodes.Status400BadRequest; + + problemDetails.Status = statusCode; + problemDetails.Title = "Validation error"; + problemDetails.Detail = "One or more validation errors occurred."; + problemDetails.Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1"; + + var errors = fluentException.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary( + g => g.Key, + g => g.Select(e => e.ErrorMessage).ToArray()); + + problemDetails.Extensions["errors"] = errors; + } + else if (exception is CustomException e) + { + statusCode = (int)e.StatusCode; + + problemDetails.Status = statusCode; + problemDetails.Title = e.GetType().Name; + problemDetails.Detail = e.Message; + + if (e.ErrorMessages is { Count: > 0 }) + { + problemDetails.Extensions["errors"] = e.ErrorMessages; + } + } + else + { + statusCode = StatusCodes.Status500InternalServerError; + + problemDetails.Status = statusCode; + problemDetails.Title = "An unexpected error occurred"; + problemDetails.Detail = "An unexpected error occurred. Please try again later."; + } + + httpContext.Response.StatusCode = statusCode; + + 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} - {Detail}", httpContext.Request.Path, problemDetails.Detail); + + await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken).ConfigureAwait(false); + return true; + } +} diff --git a/src/BuildingBlocks/Web/Extensions.cs b/src/BuildingBlocks/Web/Extensions.cs new file mode 100644 index 0000000000..0aa1e8cb87 --- /dev/null +++ b/src/BuildingBlocks/Web/Extensions.cs @@ -0,0 +1,183 @@ +using FSH.Framework.Caching; +using FSH.Framework.Jobs; +using FSH.Framework.Mailing; +using FSH.Framework.Persistence; +using FSH.Framework.Web.Auth; +using FSH.Framework.Web.Cors; +using FSH.Framework.Web.Exceptions; +using FSH.Framework.Web.Health; +using FSH.Framework.Web.Mediator.Behaviors; +using FSH.Framework.Web.Modules; +using FSH.Framework.Web.Observability.Logging.Serilog; +using FSH.Framework.Web.Observability.OpenTelemetry; +using FSH.Framework.Web.OpenApi; +using FSH.Framework.Web.Origin; +using FSH.Framework.Web.RateLimiting; +using FSH.Framework.Web.Security; +using FSH.Framework.Web.Versioning; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; + +namespace FSH.Framework.Web; + +public static class Extensions +{ + public static IHostApplicationBuilder AddHeroPlatform(this IHostApplicationBuilder builder, Action? configure = null) + { + ArgumentNullException.ThrowIfNull(builder); + + var options = new FshPlatformOptions(); + configure?.Invoke(options); + + builder.Services.AddScoped(); + + builder.AddHeroLogging(); + if (options.EnableOpenTelemetry) + { + builder.AddHeroOpenTelemetry(); + } + + builder.Services.AddHttpContextAccessor(); + builder.Services.AddHeroDatabaseOptions(builder.Configuration); + builder.Services.AddHeroRateLimiting(builder.Configuration); + + var corsEnabled = options.EnableCors && IsCorsEnabled(builder.Configuration); + var openApiEnabled = options.EnableOpenApi && IsOpenApiEnabled(builder.Configuration); + + if (corsEnabled) + { + builder.Services.AddHeroCors(builder.Configuration); + } + + builder.Services.AddHeroVersioning(); + + if (openApiEnabled) + { + builder.Services.AddHeroOpenApi(builder.Configuration); + } + + builder.Services.AddHealthChecks().AddCheck("self", () => HealthCheckResult.Healthy()); + + if (options.EnableJobs) + { + builder.Services.AddHeroJobs(); + } + + if (options.EnableMailing) + { + builder.Services.AddHeroMailing(); + } + + if (options.EnableCaching) + { + builder.Services.AddHeroCaching(builder.Configuration); + } + + builder.Services.AddExceptionHandler(); + builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); + builder.Services.AddProblemDetails(); + builder.Services.AddOptions().BindConfiguration(nameof(OriginOptions)); + builder.Services.AddOptions().BindConfiguration(nameof(SecurityHeadersOptions)); + + return builder; + } + + + public static WebApplication UseHeroPlatform(this WebApplication app, Action? configure = null) + { + ArgumentNullException.ThrowIfNull(app); + + var options = new FshPipelineOptions(); + configure?.Invoke(options); + + var corsEnabled = options.UseCors && IsCorsEnabled(app.Configuration); + var openApiEnabled = options.UseOpenApi && IsOpenApiEnabled(app.Configuration); + + app.UseExceptionHandler(); + app.UseHttpsRedirection(); + + app.UseHeroSecurityHeaders(); + + // Serve static files as early as possible to short-circuit pipeline + if (options.ServeStaticFiles) + { + var assetsPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot"); + if (!Directory.Exists(assetsPath)) + { + Directory.CreateDirectory(assetsPath); + } + + app.UseStaticFiles(); + } + + app.UseHeroJobDashboard(app.Configuration); + app.UseRouting(); + + // CORS should run between routing and authN/authZ + if (corsEnabled) + { + app.UseHeroCors(); + } + + if (openApiEnabled) + { + app.UseHeroOpenApi(); + } + + app.UseAuthentication(); + + // If Auditing module is referenced, wire its HTTP middleware (request/response logging) + var auditMiddlewareType = Type.GetType("FSH.Modules.Auditing.AuditHttpMiddleware, FSH.Modules.Auditing"); + if (auditMiddlewareType is not null) + { + app.UseMiddleware(auditMiddlewareType); + } + + app.UseHeroRateLimiting(); + app.UseAuthorization(); + + if (options.MapModules) + { + app.MapModules(); + } + + // Always expose health endpoints + app.MapHeroHealthEndpoints(); + app.UseMiddleware(); + return app; + } + + private static bool IsCorsEnabled(IConfiguration configuration) + { + var allowAll = configuration.GetValue("CorsOptions:AllowAll", false); + var origins = configuration.GetSection("CorsOptions:AllowedOrigins").Get() ?? []; + return allowAll || origins.Length > 0; + } + + private static bool IsOpenApiEnabled(IConfiguration configuration) + { + return configuration.GetValue("OpenApiOptions:Enabled", true); + } +} + +public sealed class FshPlatformOptions +{ + public bool EnableCors { get; set; } = true; + public bool EnableOpenApi { get; set; } = true; + public bool EnableCaching { get; set; } = false; + public bool EnableJobs { get; set; } = false; + public bool EnableMailing { get; set; } = false; + public bool EnableOpenTelemetry { get; set; } = true; +} + +public sealed class FshPipelineOptions +{ + public bool UseCors { get; set; } = true; + public bool UseOpenApi { get; set; } = true; + public bool ServeStaticFiles { get; set; } = true; + public bool MapModules { get; set; } = true; +} diff --git a/src/BuildingBlocks/Web/Health/HealthEndpoints.cs b/src/BuildingBlocks/Web/Health/HealthEndpoints.cs new file mode 100644 index 0000000000..6654996c72 --- /dev/null +++ b/src/BuildingBlocks/Web/Health/HealthEndpoints.cs @@ -0,0 +1,67 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace FSH.Framework.Web.Health; + +public static class HealthEndpoints +{ + public sealed record HealthResult(string Status, IEnumerable Results); + public sealed record HealthEntry(string Name, string Status, string? Description, double DurationMs, Dictionary? Details = default); + public static IEndpointRouteBuilder MapHeroHealthEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/health") + .WithTags("Health") + .AllowAnonymous() + .DisableRateLimiting(); + + + // Liveness: only process up (no external deps) + group.MapGet("/live", + async Task> (HealthCheckService hc, CancellationToken cancellationToken) => + { + var report = await hc.CheckHealthAsync(_ => false, cancellationToken); + var payload = new HealthResult( + Status: report.Status.ToString(), + Results: Array.Empty()); + + return TypedResults.Ok(payload); + }) + .WithName("Liveness") + .WithSummary("Quick process liveness probe.") + .WithDescription("Reports if the API process is alive. Does not check dependencies.") + .Produces(StatusCodes.Status200OK); + + // Readiness: includes DB (and any other registered checks) + group.MapGet("/ready", + async Task, StatusCodeHttpResult>> (HealthCheckService hc, CancellationToken cancellationToken) => + { + var report = await hc.CheckHealthAsync(cancellationToken: cancellationToken); + var results = report.Entries.Select(e => + new HealthEntry( + Name: e.Key, + Status: e.Value.Status.ToString(), + Description: e.Value.Description, + DurationMs: e.Value.Duration.TotalMilliseconds, + Details: e.Value.Data.ToDictionary( + k => k.Key, + v => v.Value is null ? "null" : v.Value + ))); + + var payload = new HealthResult(report.Status.ToString(), results); + + return report.Status == HealthStatus.Healthy + ? TypedResults.Ok(payload) + : TypedResults.StatusCode(StatusCodes.Status503ServiceUnavailable); + }) + .WithName("Readiness") + .WithSummary("Readiness probe with database check.") + .WithDescription("Returns 200 if all dependencies are healthy, otherwise 503.") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status503ServiceUnavailable); + + return app; + } +} diff --git a/src/BuildingBlocks/Web/IFshWeb.cs b/src/BuildingBlocks/Web/IFshWeb.cs new file mode 100644 index 0000000000..088bbdc925 --- /dev/null +++ b/src/BuildingBlocks/Web/IFshWeb.cs @@ -0,0 +1,4 @@ +namespace FSH.Framework.Web; +public interface IFshWeb +{ +} diff --git a/src/BuildingBlocks/Web/Mediator/Behaviors/ValidationBehavior.cs b/src/BuildingBlocks/Web/Mediator/Behaviors/ValidationBehavior.cs new file mode 100644 index 0000000000..cc9f0546c8 --- /dev/null +++ b/src/BuildingBlocks/Web/Mediator/Behaviors/ValidationBehavior.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using Mediator; + +namespace FSH.Framework.Web.Mediator.Behaviors; +public sealed class ValidationBehavior(IEnumerable> validators) : IPipelineBehavior + where TMessage : IMessage +{ + private readonly IEnumerable> _validators = validators; + + public async ValueTask Handle( + TMessage message, + MessageHandlerDelegate next, + CancellationToken cancellationToken + ) + { + ArgumentNullException.ThrowIfNull(next); + + if (_validators.Any()) + { + var context = new ValidationContext(message); + var validationResults = await Task.WhenAll(_validators.Select(v => v.ValidateAsync(context, cancellationToken))); + var failures = validationResults.SelectMany(r => r.Errors).Where(f => f != null).ToList(); + + if (failures.Count > 0) + throw new ValidationException(failures); + } + return await next(message, cancellationToken); + } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Web/Mediator/Extensions.cs b/src/BuildingBlocks/Web/Mediator/Extensions.cs new file mode 100644 index 0000000000..0d42c149de --- /dev/null +++ b/src/BuildingBlocks/Web/Mediator/Extensions.cs @@ -0,0 +1,21 @@ +using FSH.Framework.Web.Mediator.Behaviors; +using Mediator; +using Microsoft.Extensions.DependencyInjection; +using System.Reflection; + +namespace FSH.Framework.Web.Mediator; + +public static class Extensions +{ + public static IServiceCollection + EnableMediator(this IServiceCollection services, params Assembly[] featureAssemblies) + { + ArgumentNullException.ThrowIfNull(services); + + // Behaviors + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); + + return services; + } + +} \ No newline at end of file diff --git a/src/BuildingBlocks/Web/Modules/FshModuleAttribute.cs b/src/BuildingBlocks/Web/Modules/FshModuleAttribute.cs new file mode 100644 index 0000000000..dd4bdb6f4c --- /dev/null +++ b/src/BuildingBlocks/Web/Modules/FshModuleAttribute.cs @@ -0,0 +1,21 @@ +using System; + +namespace FSH.Framework.Web.Modules; + +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] +public sealed class FshModuleAttribute : Attribute +{ + public Type ModuleType { get; } + + /// + /// Optional ordering hint that allows hosts to control module startup sequencing. + /// Lower numbers execute first. + /// + public int Order { get; } + + public FshModuleAttribute(Type moduleType, int order = 0) + { + ModuleType = moduleType ?? throw new ArgumentNullException(nameof(moduleType)); + Order = order; + } +} diff --git a/src/BuildingBlocks/Web/Modules/IModule.cs b/src/BuildingBlocks/Web/Modules/IModule.cs new file mode 100644 index 0000000000..5b0cd94652 --- /dev/null +++ b/src/BuildingBlocks/Web/Modules/IModule.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Hosting; + +namespace FSH.Framework.Web.Modules; + +public interface IModule +{ + // DI/Options/Health/etc. — don’t depend on ASP.NET types here + void ConfigureServices(IHostApplicationBuilder builder); + + // HTTP wiring — Minimal APIs only + void MapEndpoints(IEndpointRouteBuilder endpoints); +} \ No newline at end of file diff --git a/src/BuildingBlocks/Web/Modules/IModuleConstants.cs b/src/BuildingBlocks/Web/Modules/IModuleConstants.cs new file mode 100644 index 0000000000..6209e9e2e6 --- /dev/null +++ b/src/BuildingBlocks/Web/Modules/IModuleConstants.cs @@ -0,0 +1,8 @@ +namespace FSH.Framework.Web.Modules; + +public interface IModuleConstants +{ + string ModuleId { get; } + string ModuleName { get; } + string ApiPrefix { get; } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Web/Modules/ModuleLoader.cs b/src/BuildingBlocks/Web/Modules/ModuleLoader.cs new file mode 100644 index 0000000000..3e6e2a3e10 --- /dev/null +++ b/src/BuildingBlocks/Web/Modules/ModuleLoader.cs @@ -0,0 +1,63 @@ +using FluentValidation; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Hosting; +using System.Reflection; + +namespace FSH.Framework.Web.Modules; + +public static class ModuleLoader +{ + private static readonly List _modules = new(); + private static readonly object _lock = new(); + private static bool _modulesLoaded; + + public static IHostApplicationBuilder AddModules(this IHostApplicationBuilder builder, params Assembly[] assemblies) + { + ArgumentNullException.ThrowIfNull(builder); + + lock (_lock) + { + if (_modulesLoaded) + { + return builder; + } + + builder.Services.AddValidatorsFromAssemblies(assemblies); + + var source = assemblies is { Length: > 0 } + ? assemblies + : AppDomain.CurrentDomain.GetAssemblies(); + + var moduleRegistrations = source + .SelectMany(a => a.GetCustomAttributes()) + .Where(r => typeof(IModule).IsAssignableFrom(r.ModuleType)) + .DistinctBy(r => r.ModuleType) + .OrderBy(r => r.Order) + .ThenBy(r => r.ModuleType.Name) + .Select(r => r.ModuleType); + + foreach (var moduleType in moduleRegistrations) + { + if (Activator.CreateInstance(moduleType) is not IModule module) + { + throw new InvalidOperationException($"Unable to create module {moduleType.Name}."); + } + + module.ConfigureServices(builder); + _modules.Add(module); + } + + _modulesLoaded = true; + } + + return builder; + } + + public static IEndpointRouteBuilder MapModules(this IEndpointRouteBuilder endpoints) + { + foreach (var m in _modules) + m.MapEndpoints(endpoints); + + return endpoints; + } +} diff --git a/src/BuildingBlocks/Web/Observability/Logging/Serilog/Extensions.cs b/src/BuildingBlocks/Web/Observability/Logging/Serilog/Extensions.cs new file mode 100644 index 0000000000..678f068a6b --- /dev/null +++ b/src/BuildingBlocks/Web/Observability/Logging/Serilog/Extensions.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Serilog; +using Serilog.Events; +using Serilog.Filters; + +namespace FSH.Framework.Web.Observability.Logging.Serilog; + +public static class Extensions +{ + public static IHostApplicationBuilder AddHeroLogging(this IHostApplicationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.Services.AddSingleton(); + builder.Services.AddSerilog((context, logger) => + { + var httpEnricher = context.GetRequiredService(); + logger.ReadFrom.Configuration(builder.Configuration); + logger.Enrich.With(httpEnricher); + logger + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information) + .MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Error) + .MinimumLevel.Override("Hangfire", LogEventLevel.Warning) + .MinimumLevel.Override("Finbuckle.MultiTenant", LogEventLevel.Warning) + .Filter.ByExcluding(Matching.FromSource("Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware")); + }); + return builder; + } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Web/Observability/Logging/Serilog/HttpRequestContextEnricher.cs b/src/BuildingBlocks/Web/Observability/Logging/Serilog/HttpRequestContextEnricher.cs new file mode 100644 index 0000000000..b2e5fd6a9b --- /dev/null +++ b/src/BuildingBlocks/Web/Observability/Logging/Serilog/HttpRequestContextEnricher.cs @@ -0,0 +1,43 @@ +using FSH.Framework.Shared.Identity.Claims; +using Microsoft.AspNetCore.Http; +using Serilog.Core; +using Serilog.Events; + +namespace FSH.Framework.Web.Observability.Logging.Serilog; + +public class HttpRequestContextEnricher : ILogEventEnricher +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + public HttpRequestContextEnricher(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + ArgumentNullException.ThrowIfNull(logEvent); + ArgumentNullException.ThrowIfNull(propertyFactory); + + // Get HttpContext properties here + var httpContext = _httpContextAccessor.HttpContext; + + if (httpContext != null) + { + // Add properties to the log event based on HttpContext + logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("RequestMethod", httpContext.Request.Method)); + logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("RequestPath", httpContext.Request.Path)); + logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("UserAgent", httpContext.Request.Headers["User-Agent"])); + + if (httpContext.User?.Identity?.IsAuthenticated == true) + { + var userId = httpContext.User.GetUserId(); + var tenant = httpContext.User.GetTenant(); + var userEmailId = httpContext.User.GetEmail(); + + logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("UserId", userId)); + logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("Tenant", tenant)); + logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("UserEmail", userEmailId)); + } + } + } +} diff --git a/src/api/framework/Infrastructure/Logging/Serilog/StaticLogger.cs b/src/BuildingBlocks/Web/Observability/Logging/Serilog/StaticLogger.cs similarity index 77% rename from src/api/framework/Infrastructure/Logging/Serilog/StaticLogger.cs rename to src/BuildingBlocks/Web/Observability/Logging/Serilog/StaticLogger.cs index 1809893b53..18307fa60e 100644 --- a/src/api/framework/Infrastructure/Logging/Serilog/StaticLogger.cs +++ b/src/BuildingBlocks/Web/Observability/Logging/Serilog/StaticLogger.cs @@ -1,7 +1,7 @@ using Serilog; using Serilog.Core; -namespace FSH.Framework.Infrastructure.Logging.Serilog; +namespace FSH.Framework.Web.Observability.Logging.Serilog; public static class StaticLogger { @@ -12,8 +12,7 @@ public static void EnsureInitialized() Log.Logger = new LoggerConfiguration() .Enrich.FromLogContext() .WriteTo.Console() - .WriteTo.OpenTelemetry() .CreateLogger(); } } -} +} \ No newline at end of file diff --git a/src/BuildingBlocks/Web/Observability/OpenTelemetry/Extensions.cs b/src/BuildingBlocks/Web/Observability/OpenTelemetry/Extensions.cs new file mode 100644 index 0000000000..697f9f04c8 --- /dev/null +++ b/src/BuildingBlocks/Web/Observability/OpenTelemetry/Extensions.cs @@ -0,0 +1,187 @@ +using Mediator; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Npgsql; +using OpenTelemetry.Exporter; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using System.Diagnostics; +using static FSH.Framework.Web.Observability.OpenTelemetry.OpenTelemetryOptions; + +namespace FSH.Framework.Web.Observability.OpenTelemetry; + +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + public static IHostApplicationBuilder AddHeroOpenTelemetry(this IHostApplicationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + var options = new OpenTelemetryOptions(); + builder.Configuration.GetSection(OpenTelemetryOptions.SectionName).Bind(options); + + if (!options.Enabled) + { + return builder; + } + + builder.Services.AddOptions() + .BindConfiguration(OpenTelemetryOptions.SectionName) + .ValidateDataAnnotations() + .ValidateOnStart(); + + var resourceBuilder = ResourceBuilder + .CreateDefault() + .AddService(serviceName: builder.Environment.ApplicationName); + + // Shared ActivitySource for spans (Mediator, etc.) + builder.Services.AddSingleton(new ActivitySource(builder.Environment.ApplicationName)); + + ConfigureMetricsAndTracing(builder, options, resourceBuilder); + + return builder; + } + + private static void ConfigureMetricsAndTracing( + IHostApplicationBuilder builder, + OpenTelemetryOptions options, + ResourceBuilder resourceBuilder) + { + builder.Services.AddOpenTelemetry() + .ConfigureResource(rb => rb.AddService(builder.Environment.ApplicationName)) + .WithMetrics(metrics => + { + if (!options.Metrics.Enabled) + { + return; + } + + metrics + .SetResourceBuilder(resourceBuilder) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddNpgsqlInstrumentation() + .AddRuntimeInstrumentation(); + + // Apply histogram buckets for HTTP server duration + if (options.Http.Histograms.Enabled) + { + metrics.AddView( + "http.server.duration", + new ExplicitBucketHistogramConfiguration + { + Boundaries = GetHistogramBuckets(options) + }); + } + + foreach (var meterName in options.Metrics.MeterNames ?? Array.Empty()) + { + metrics.AddMeter(meterName); + } + + if (options.Exporter.Otlp.Enabled) + { + metrics.AddOtlpExporter(otlp => + { + ConfigureOtlpExporter(options.Exporter.Otlp, otlp); + }); + } + }) + .WithTracing(tracing => + { + if (!options.Tracing.Enabled) + { + return; + } + + tracing + .SetResourceBuilder(resourceBuilder) + .AddAspNetCoreInstrumentation(instrumentation => + { + instrumentation.Filter = context => !IsHealthCheck(context.Request.Path); + instrumentation.EnrichWithHttpRequest = EnrichWithHttpRequest; + instrumentation.EnrichWithHttpResponse = EnrichWithHttpResponse; + }) + .AddHttpClientInstrumentation() + .AddNpgsql() + .AddEntityFrameworkCoreInstrumentation() + .AddRedisInstrumentation(redis => + { + if (options.Data.FilterRedisCommands) + { + redis.SetVerboseDatabaseStatements = false; + } + }) + .AddSource(builder.Environment.ApplicationName) + .AddSource("FSH.Hangfire"); + + if (options.Exporter.Otlp.Enabled) + { + tracing.AddOtlpExporter(otlp => + { + ConfigureOtlpExporter(options.Exporter.Otlp, otlp); + }); + } + }); + + // Mediator spans (optional): add behavior in DI for pipeline spans. + if (options.Mediator.Enabled) + { + builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(MediatorTracingBehavior<,>)); + } + + // Hangfire/job instrumentation placeholder: currently enabled via Jobs.Enabled; wire hooks in jobs building block. + } + + private static double[] GetHistogramBuckets(OpenTelemetryOptions options) + { + if (options.Http.Histograms.BucketBoundaries is { Length: > 0 } custom) + { + return custom; + } + + // Default buckets in seconds (fast to slow) + return new[] { 0.01, 0.05, 0.1, 0.25, 0.5, 1, 2, 5 }; + } + + private static bool IsHealthCheck(PathString path) => + path.StartsWithSegments(HealthEndpointPath) || + path.StartsWithSegments(AlivenessEndpointPath); + + private static void EnrichWithHttpRequest(Activity activity, HttpRequest request) + { + activity.SetTag("http.method", request.Method); + activity.SetTag("http.scheme", request.Scheme); + activity.SetTag("http.host", request.Host.Value); + activity.SetTag("http.target", request.Path); + } + + private static void EnrichWithHttpResponse(Activity activity, HttpResponse response) + { + activity.SetTag("http.status_code", response.StatusCode); + } + + private static void ConfigureOtlpExporter( + OtlpOptions options, + OtlpExporterOptions otlp) + { + if (!string.IsNullOrWhiteSpace(options.Endpoint)) + { + otlp.Endpoint = new Uri(options.Endpoint); + } + + var protocol = options.Protocol?.Trim().ToLowerInvariant(); + otlp.Protocol = protocol switch + { + "grpc" => OtlpExportProtocol.Grpc, + "http/protobuf" => OtlpExportProtocol.HttpProtobuf, + _ => otlp.Protocol + }; + } +} diff --git a/src/BuildingBlocks/Web/Observability/OpenTelemetry/MediatorTracingBehavior.cs b/src/BuildingBlocks/Web/Observability/OpenTelemetry/MediatorTracingBehavior.cs new file mode 100644 index 0000000000..eb711a79a5 --- /dev/null +++ b/src/BuildingBlocks/Web/Observability/OpenTelemetry/MediatorTracingBehavior.cs @@ -0,0 +1,51 @@ +using Mediator; +using System.Diagnostics; + +namespace FSH.Framework.Web.Observability.OpenTelemetry; + +/// +/// Emits spans around Mediator commands/queries to improve trace visibility. +/// +public sealed class MediatorTracingBehavior : IPipelineBehavior + where TMessage : IMessage +{ + private readonly ActivitySource _activitySource; + + public MediatorTracingBehavior(ActivitySource activitySource) + { + _activitySource = activitySource; + } + + public async ValueTask Handle(TMessage message, MessageHandlerDelegate next, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(message); + ArgumentNullException.ThrowIfNull(next); + + using var activity = _activitySource.StartActivity( + $"Mediator {typeof(TMessage).Name}", + ActivityKind.Internal); + + if (activity is not null) + { + activity.SetTag("mediator.request_type", typeof(TMessage).FullName); + } + + try + { + var response = await next(message, cancellationToken); + activity?.SetStatus(ActivityStatusCode.Ok); + return response; + } + catch (Exception ex) + { + if (activity is not null) + { + activity.SetStatus(ActivityStatusCode.Error); + activity.SetTag("exception.type", ex.GetType().FullName); + activity.SetTag("exception.message", ex.Message); + } + + throw; + } + } +} diff --git a/src/BuildingBlocks/Web/Observability/OpenTelemetry/OpenTelemetryOptions.cs b/src/BuildingBlocks/Web/Observability/OpenTelemetry/OpenTelemetryOptions.cs new file mode 100644 index 0000000000..02faab76ee --- /dev/null +++ b/src/BuildingBlocks/Web/Observability/OpenTelemetry/OpenTelemetryOptions.cs @@ -0,0 +1,104 @@ +using System.ComponentModel.DataAnnotations; + +namespace FSH.Framework.Web.Observability.OpenTelemetry; + +public sealed class OpenTelemetryOptions +{ + public const string SectionName = "OpenTelemetryOptions"; + + /// + /// Global switch to turn OpenTelemetry on/off. + /// + public bool Enabled { get; set; } = true; + + public TracingOptions Tracing { get; set; } = new(); + + public MetricsOptions Metrics { get; set; } = new(); + + public ExporterOptions Exporter { get; set; } = new(); + + /// + /// Job instrumentation options (e.g., Hangfire). + /// + public JobOptions Jobs { get; set; } = new(); + + /// + /// Mediator pipeline instrumentation options. + /// + public MediatorOptions Mediator { get; set; } = new(); + + /// + /// HTTP instrumentation options (including histograms). + /// + public HttpOptions Http { get; set; } = new(); + + /// + /// EF/Redis instrumentation filtering options. + /// + public DataOptions Data { get; set; } = new(); + + public sealed class TracingOptions + { + public bool Enabled { get; set; } = true; + } + + public sealed class MetricsOptions + { + public bool Enabled { get; set; } = true; + public string[]? MeterNames { get; set; } + } + + public sealed class ExporterOptions + { + public OtlpOptions Otlp { get; set; } = new(); + } + + public sealed class OtlpOptions + { + public bool Enabled { get; set; } = true; + + [Url] + public string? Endpoint { get; set; } + + /// + /// Transport protocol, e.g. "grpc" or "http/protobuf". + /// + public string? Protocol { get; set; } + } + + public sealed class JobOptions + { + /// Enable tracing/metrics for jobs (e.g., Hangfire). + public bool Enabled { get; set; } = true; + } + + public sealed class MediatorOptions + { + /// Enable spans around Mediator commands/queries. + public bool Enabled { get; set; } = true; + } + + public sealed class HttpOptions + { + public HistogramOptions Histograms { get; set; } = new(); + + public sealed class HistogramOptions + { + /// Enable HTTP request duration histograms. + public bool Enabled { get; set; } = true; + + /// Custom bucket boundaries (seconds). If null/empty, defaults apply. + public double[]? BucketBoundaries { get; set; } + } + } + + public sealed class DataOptions + { + /// Suppress SQL text in EF instrumentation to reduce PII/noise. + public bool FilterEfStatements { get; set; } = true; + + /// Suppress Redis command text in instrumentation to reduce noise. + public bool FilterRedisCommands { get; set; } = true; + } + +} diff --git a/src/BuildingBlocks/Web/OpenApi/BearerSecuritySchemeTransformer.cs b/src/BuildingBlocks/Web/OpenApi/BearerSecuritySchemeTransformer.cs new file mode 100644 index 0000000000..d020e00591 --- /dev/null +++ b/src/BuildingBlocks/Web/OpenApi/BearerSecuritySchemeTransformer.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace FSH.Framework.Web.OpenApi; + +internal sealed class BearerSecuritySchemeTransformer(IAuthenticationSchemeProvider authenticationSchemeProvider) : IOpenApiDocumentTransformer +{ + public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + var authenticationSchemes = await authenticationSchemeProvider.GetAllSchemesAsync(); + if (authenticationSchemes.Any(authScheme => authScheme.Name == "Bearer")) + { + // Add the security scheme at the document level + var securitySchemes = new Dictionary + { + ["Bearer"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", // "bearer" refers to the header name here + In = ParameterLocation.Header, + BearerFormat = "Json Web Token" + } + }; + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes = securitySchemes; + + // Apply it as a requirement for all operations + foreach (var operation in document.Paths.Values + .SelectMany(path => path.Operations ?? new Dictionary()) + .Select(operation => operation.Value)) + { + operation.Security ??= []; + operation.Security.Add(new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference("Bearer", document)] = [] + }); + } + } + } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Web/OpenApi/Extensions.cs b/src/BuildingBlocks/Web/OpenApi/Extensions.cs new file mode 100644 index 0000000000..6c0d3f6b15 --- /dev/null +++ b/src/BuildingBlocks/Web/OpenApi/Extensions.cs @@ -0,0 +1,78 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi; +using Scalar.AspNetCore; + +namespace FSH.Framework.Web.OpenApi; + +public static class Extensions +{ + public static IServiceCollection AddHeroOpenApi(this IServiceCollection services, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + services + .AddOptions() + .Bind(configuration.GetSection(nameof(OpenApiOptions))) + .Validate(o => !string.IsNullOrWhiteSpace(o.Title), "OpenApi:Title is required.") + .Validate(o => !string.IsNullOrWhiteSpace(o.Description), "OpenApi:Description is required.") + .ValidateOnStart(); + + // Minimal OpenAPI generator (ASP.NET Core 8) + services.AddOpenApi(options => + { + options.AddDocumentTransformer(); + options.AddDocumentTransformer(async (document, context, ct) => + { + var provider = context.ApplicationServices; + var openApi = provider.GetRequiredService>().Value; + + // Title/metadata + document.Info = new OpenApiInfo + { + Title = openApi.Title, + Version = openApi.Version, + Description = openApi.Description, + Contact = openApi.Contact is null ? null : new OpenApiContact + { + Name = openApi.Contact.Name, + Url = openApi.Contact.Url, + Email = openApi.Contact.Email + }, + License = openApi.License is null ? null : new OpenApiLicense + { + Name = openApi.License.Name, + Url = openApi.License.Url + } + }; + await Task.CompletedTask; + }); + }); + + return services; + } + + public static void UseHeroOpenApi( + this WebApplication app, + string openApiPath = "/openapi/{documentName}.json") + { + ArgumentNullException.ThrowIfNull(app); + + app.MapOpenApi(openApiPath); + + app.MapScalarApiReference(options => + { + var configuration = app.Configuration; + options + .WithTitle(configuration["OpenApi:Title"] ?? "FSH API") + .WithTheme(Scalar.AspNetCore.ScalarTheme.Alternate) + .EnableDarkMode() + .HideModels() + .WithOpenApiRoutePattern(openApiPath) + .AddPreferredSecuritySchemes("Bearer"); + }); + } +} diff --git a/src/BuildingBlocks/Web/OpenApi/OpenApiOptions.cs b/src/BuildingBlocks/Web/OpenApi/OpenApiOptions.cs new file mode 100644 index 0000000000..b11fe48795 --- /dev/null +++ b/src/BuildingBlocks/Web/OpenApi/OpenApiOptions.cs @@ -0,0 +1,23 @@ +namespace FSH.Framework.Web.OpenApi; +public sealed class OpenApiOptions +{ + public required string Title { get; init; } + public string Version { get; init; } = "v1"; + public required string Description { get; init; } + + public ContactOptions? Contact { get; init; } + public LicenseOptions? License { get; init; } + + public sealed class ContactOptions + { + public string? Name { get; init; } + public Uri? Url { get; init; } + public string? Email { get; init; } + } + + public sealed class LicenseOptions + { + public string? Name { get; init; } + public Uri? Url { get; init; } + } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Web/Origin/OriginOptions.cs b/src/BuildingBlocks/Web/Origin/OriginOptions.cs new file mode 100644 index 0000000000..117f4ea27f --- /dev/null +++ b/src/BuildingBlocks/Web/Origin/OriginOptions.cs @@ -0,0 +1,6 @@ +namespace FSH.Framework.Web.Origin; + +public class OriginOptions +{ + public Uri? OriginUrl { get; set; } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Web/RateLimiting/Extensions.cs b/src/BuildingBlocks/Web/RateLimiting/Extensions.cs new file mode 100644 index 0000000000..630d7a3e59 --- /dev/null +++ b/src/BuildingBlocks/Web/RateLimiting/Extensions.cs @@ -0,0 +1,105 @@ +using FSH.Framework.Shared.Constants; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using System.Security.Claims; +using System.Threading.RateLimiting; + +namespace FSH.Framework.Web.RateLimiting; + +public static class Extensions +{ + public static IServiceCollection AddHeroRateLimiting(this IServiceCollection services, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + var settings = configuration.GetSection(nameof(RateLimitingOptions)).Get() ?? new RateLimitingOptions(); + + services.AddOptions() + .BindConfiguration(nameof(RateLimitingOptions)); + + services.AddRateLimiter(options => + { + options.RejectionStatusCode = 429; + + if (!settings.Enabled) + { + return; + } + + string GetPartitionKey(HttpContext context) + { + var tenant = context.User?.FindFirst(ClaimConstants.Tenant)?.Value; + var userId = context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!string.IsNullOrWhiteSpace(tenant)) return $"tenant:{tenant}"; + if (!string.IsNullOrWhiteSpace(userId)) return $"user:{userId}"; + var ip = context.Connection.RemoteIpAddress?.ToString(); + return string.IsNullOrWhiteSpace(ip) ? "ip:unknown" : $"ip:{ip}"; + } + + bool IsHealthPath(PathString path) => + path.StartsWithSegments("/health", StringComparison.OrdinalIgnoreCase) || + path.StartsWithSegments("/healthz", StringComparison.OrdinalIgnoreCase) || + path.StartsWithSegments("/ready", StringComparison.OrdinalIgnoreCase) || + path.StartsWithSegments("/live", StringComparison.OrdinalIgnoreCase); + + options.GlobalLimiter = PartitionedRateLimiter.Create(context => + { + if (IsHealthPath(context.Request.Path)) + { + return RateLimitPartition.GetNoLimiter("health"); + } + + var key = GetPartitionKey(context); + return RateLimitPartition.GetFixedWindowLimiter( + partitionKey: key, + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = settings.Global.PermitLimit, + Window = TimeSpan.FromSeconds(settings.Global.WindowSeconds), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = settings.Global.QueueLimit + }); + }); + + options.AddPolicy("global", context => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: GetPartitionKey(context), + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = settings.Global.PermitLimit, + Window = TimeSpan.FromSeconds(settings.Global.WindowSeconds), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = settings.Global.QueueLimit + })); + + options.AddPolicy("auth", context => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: GetPartitionKey(context), + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = settings.Auth.PermitLimit, + Window = TimeSpan.FromSeconds(settings.Auth.WindowSeconds), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = settings.Auth.QueueLimit + })); + }); + + return services; + } + + public static IApplicationBuilder UseHeroRateLimiting(this IApplicationBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + + var opts = app.ApplicationServices.GetRequiredService>().Value; + if (opts.Enabled) + { + app.UseRateLimiter(); + } + return app; + } +} diff --git a/src/BuildingBlocks/Web/RateLimiting/FixedWindowPolicyOptions.cs b/src/BuildingBlocks/Web/RateLimiting/FixedWindowPolicyOptions.cs new file mode 100644 index 0000000000..857fe32d0d --- /dev/null +++ b/src/BuildingBlocks/Web/RateLimiting/FixedWindowPolicyOptions.cs @@ -0,0 +1,9 @@ +namespace FSH.Framework.Web.RateLimiting; + +public sealed class FixedWindowPolicyOptions +{ + public int PermitLimit { get; set; } = 100; + public int WindowSeconds { get; set; } = 60; + public int QueueLimit { get; set; } = 0; +} + diff --git a/src/BuildingBlocks/Web/RateLimiting/RateLimitingOptions.cs b/src/BuildingBlocks/Web/RateLimiting/RateLimitingOptions.cs new file mode 100644 index 0000000000..36c59d70b1 --- /dev/null +++ b/src/BuildingBlocks/Web/RateLimiting/RateLimitingOptions.cs @@ -0,0 +1,9 @@ +namespace FSH.Framework.Web.RateLimiting; + +public sealed class RateLimitingOptions +{ + public bool Enabled { get; set; } = true; + public FixedWindowPolicyOptions Global { get; set; } = new(); + public FixedWindowPolicyOptions Auth { get; set; } = new() { PermitLimit = 10, WindowSeconds = 60, QueueLimit = 0 }; +} + diff --git a/src/BuildingBlocks/Web/Security/SecurityExtensions.cs b/src/BuildingBlocks/Web/Security/SecurityExtensions.cs new file mode 100644 index 0000000000..caec4134b3 --- /dev/null +++ b/src/BuildingBlocks/Web/Security/SecurityExtensions.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Builder; + +namespace FSH.Framework.Web.Security; + +public static class SecurityExtensions +{ + public static IApplicationBuilder UseHeroSecurityHeaders(this IApplicationBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + return app.UseMiddleware(); + } +} + diff --git a/src/BuildingBlocks/Web/Security/SecurityHeadersMiddleware.cs b/src/BuildingBlocks/Web/Security/SecurityHeadersMiddleware.cs new file mode 100644 index 0000000000..7b7253c1d4 --- /dev/null +++ b/src/BuildingBlocks/Web/Security/SecurityHeadersMiddleware.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace FSH.Framework.Web.Security; + +public sealed class SecurityHeadersMiddleware(RequestDelegate next, IOptions options) +{ + private readonly SecurityHeadersOptions _options = options.Value; + + public Task InvokeAsync(HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (!_options.Enabled) + { + return next(context); + } + + var path = context.Request.Path; + + // Allow listed paths (e.g., OpenAPI / Scalar UI) to manage their own scripts/styles. + if (_options.ExcludedPaths?.Any(p => path.StartsWithSegments(p, StringComparison.OrdinalIgnoreCase)) == true) + { + return next(context); + } + + var headers = context.Response.Headers; + + headers["X-Content-Type-Options"] = "nosniff"; + headers["X-Frame-Options"] = "DENY"; + headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; + headers["X-XSS-Protection"] = "0"; + + if (!headers.ContainsKey("Content-Security-Policy")) + { + var scriptSources = string.Join(' ', _options.ScriptSources ?? []); + var styleSources = string.Join(' ', _options.StyleSources ?? []); + + var csp = + "default-src 'self'; " + + "img-src 'self' data: https:; " + + $"script-src 'self' https: {scriptSources}; " + + $"style-src 'self' {(_options.AllowInlineStyles ? "'unsafe-inline' " : string.Empty)}{styleSources}; " + + "object-src 'none'; " + + "frame-ancestors 'none'; " + + "base-uri 'self';"; + + headers["Content-Security-Policy"] = csp; + } + + return next(context); + } +} diff --git a/src/BuildingBlocks/Web/Security/SecurityHeadersOptions.cs b/src/BuildingBlocks/Web/Security/SecurityHeadersOptions.cs new file mode 100644 index 0000000000..50fde3b083 --- /dev/null +++ b/src/BuildingBlocks/Web/Security/SecurityHeadersOptions.cs @@ -0,0 +1,29 @@ +namespace FSH.Framework.Web.Security; + +public sealed class SecurityHeadersOptions +{ + /// + /// Enables or disables the security headers middleware entirely. + /// + public bool Enabled { get; set; } = true; + + /// + /// Paths to bypass (e.g., OpenAPI/Scalar assets). + /// + public string[] ExcludedPaths { get; set; } = ["/scalar", "/openapi"]; + + /// + /// Whether to allow inline styles in CSP (default true for MudBlazor/Scalar compatibility). + /// + public bool AllowInlineStyles { get; set; } = true; + + /// + /// Additional script sources to append to CSP. + /// + public string[] ScriptSources { get; set; } = []; + + /// + /// Additional style sources to append to CSP. + /// + public string[] StyleSources { get; set; } = []; +} diff --git a/src/BuildingBlocks/Web/Versioning/Extensions.cs b/src/BuildingBlocks/Web/Versioning/Extensions.cs new file mode 100644 index 0000000000..6e03cb88eb --- /dev/null +++ b/src/BuildingBlocks/Web/Versioning/Extensions.cs @@ -0,0 +1,26 @@ +using Asp.Versioning; +using Microsoft.Extensions.DependencyInjection; + +namespace FSH.Framework.Web.Versioning; + +public static class Extensions +{ + public static IServiceCollection AddHeroVersioning(this IServiceCollection services) + { + services + .AddApiVersioning(options => + { + options.ReportApiVersions = true; + options.DefaultApiVersion = new ApiVersion(1, 0); + options.AssumeDefaultVersionWhenUnspecified = true; + options.ApiVersionReader = new UrlSegmentApiVersionReader(); + }) + .AddApiExplorer(options => + { + options.GroupNameFormat = "'v'VVV"; + options.SubstituteApiVersionInUrl = true; + }) + .EnableApiVersionBinding(); + return services; + } +} diff --git a/src/BuildingBlocks/Web/Web.csproj b/src/BuildingBlocks/Web/Web.csproj new file mode 100644 index 0000000000..078e71a150 --- /dev/null +++ b/src/BuildingBlocks/Web/Web.csproj @@ -0,0 +1,64 @@ + + + + FSH.Framework.Web + FSH.Framework.Web + FullStackHero.Framework.Web + $(NoWarn);CA1805;CA1307;CA1308;S1854;CA1812;CA1305;CA2000 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index cf4b4bb3de..921aa04b4a 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,18 +1,57 @@ - - net9.0 - false - false - true - true - enable - enable - true - latest - All - 2.0.4-rc;latest - - - - - \ No newline at end of file + + + net10.0 + + + latest + enable + enable + + + false + false + true + latest + AllEnabledByDefault + + + true + $(NoWarn);CS1591 + + + + 10.0.0-rc;latest + + + true + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + Mukesh Murugan + FullStackHero + 10.0.0-rc.1 + https://github.com/fullstackhero/dotnet-starter-kit + FSH;FullStackHero;Modular;CQRS;VerticalSlice;DotNet;CleanArchitecture + MIT + README.md + https://fullstackhero.net + FullStackHero .NET Starter Kit - A production-ready modular .NET framework for building enterprise applications + + true + + + + + + + diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 7015fadbab..24ac03dfe9 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -8,89 +8,104 @@ true true + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + + + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - - + + + + \ No newline at end of file diff --git a/src/Dockerfile.Blazor b/src/Dockerfile.Blazor deleted file mode 100644 index 2438ffea64..0000000000 --- a/src/Dockerfile.Blazor +++ /dev/null @@ -1,13 +0,0 @@ -FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build-env -WORKDIR /app - -COPY . ./ -RUN dotnet publish ./apps/blazor/client/Client.csproj -c Release -o output - -FROM nginx:alpine -WORKDIR /usr/share/nginx/html -COPY --from=build-env /app/output/wwwroot . - -COPY ./apps/blazor/nginx.conf /etc/nginx/nginx.conf - -EXPOSE 80 \ No newline at end of file diff --git a/src/FSH.Framework.slnx b/src/FSH.Framework.slnx new file mode 100644 index 0000000000..aa955df569 --- /dev/null +++ b/src/FSH.Framework.slnx @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/FSH.Starter.sln b/src/FSH.Starter.sln deleted file mode 100644 index 904c59f770..0000000000 --- a/src/FSH.Starter.sln +++ /dev/null @@ -1,287 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{F3DF5AC5-8CDC-46D4-969D-1245A6880215}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{A32CEFB3-4E50-401E-8835-787534414F41}" - ProjectSection(SolutionItems) = preProject - .editorconfig = .editorconfig - Directory.Build.props = Directory.Build.props - Directory.Packages.props = Directory.Packages.props - README.md = README.md - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Catalog", "Catalog", "{93324D12-DE1B-4C1B-934A-92AA140FF6F6}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Todo", "Todo", "{79981A5A-207A-4A16-A21B-5E80394082F6}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Framework", "_Framework", "{05248A38-0F34-4E59-A3D1-B07097987AFB}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Migrations", "Migrations", "{12F8343D-20A6-4E24-B0F5-3A66F2228CF6}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "WebApi", "WebApi", "{CE64E92B-E088-46FB-9028-7FB6B67DEC55}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Blazor", "Blazor", "{2B1F75CE-07A6-4C19-A2E3-F9E062CFDDFB}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure", "api\framework\Infrastructure\Infrastructure.csproj", "{294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core", "api\framework\Core\Core.csproj", "{A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Server", "api\server\Server.csproj", "{86BD3DF6-A3E9-4839-8036-813A20DC8AD6}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MSSQL", "api\migrations\MSSQL\MSSQL.csproj", "{ECCEA352-8953-49D6-8F87-8AB361499420}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PostgreSQL", "api\migrations\PostgreSQL\PostgreSQL.csproj", "{D64AD07C-A711-42D8-8653-EDCD7A825A44}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Todo", "api\modules\Todo\Todo.csproj", "{B3866EEF-8F46-4302-ABAC-A95EE2F27331}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Catalog.Application", "api\modules\Catalog\Catalog.Application\Catalog.Application.csproj", "{8C7DAF8E-F792-4092-8BBF-31A6B898B39A}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Catalog.Domain", "api\modules\Catalog\Catalog.Domain\Catalog.Domain.csproj", "{B15705B5-041C-4F1E-8342-AD03182EDD42}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Catalog.Infrastructure", "api\modules\Catalog\Catalog.Infrastructure\Catalog.Infrastructure.csproj", "{89FE1C3B-29D3-48A8-8E7D-90C261D266C5}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Client", "apps\blazor\client\Client.csproj", "{BCE4A428-8B97-4B56-AE45-496EE3906667}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure", "apps\blazor\infrastructure\Infrastructure.csproj", "{27BEF279-AE73-43DC-92A9-FD7021A999D0}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Shared", "apps\blazor\shared\Shared.csproj", "{34359707-CE66-4DF0-9EF4-D7544B615564}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Aspire", "Aspire", "{D36E77BC-4568-4BC8-9506-1EFB7B1CD335}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ServiceDefaults", "aspire\service-defaults\ServiceDefaults.csproj", "{990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Host", "aspire\host\Host.csproj", "{2119CE89-308D-4932-BFCE-8CDC0A05EB9E}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared", "Shared\Shared.csproj", "{49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docs", "Docs", "{FE1B1E84-F993-4840-9CAB-9082EB523FDD}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Auth", "Auth", "{F17769D7-0E41-4E80-BDD4-282EBE7B5199}" - ProjectSection(SolutionItems) = preProject - GetToken.http = GetToken.http - EndProjectSection -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 - Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}.Debug|x64.ActiveCfg = Debug|Any CPU - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}.Debug|x64.Build.0 = Debug|Any CPU - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}.Debug|x86.ActiveCfg = Debug|Any CPU - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}.Debug|x86.Build.0 = Debug|Any CPU - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}.Release|Any CPU.Build.0 = Release|Any CPU - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}.Release|x64.ActiveCfg = Release|Any CPU - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}.Release|x64.Build.0 = Release|Any CPU - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}.Release|x86.ActiveCfg = Release|Any CPU - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB}.Release|x86.Build.0 = Release|Any CPU - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}.Debug|x64.ActiveCfg = Debug|Any CPU - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}.Debug|x64.Build.0 = Debug|Any CPU - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}.Debug|x86.ActiveCfg = Debug|Any CPU - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}.Debug|x86.Build.0 = Debug|Any CPU - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}.Release|Any CPU.Build.0 = Release|Any CPU - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}.Release|x64.ActiveCfg = Release|Any CPU - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}.Release|x64.Build.0 = Release|Any CPU - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}.Release|x86.ActiveCfg = Release|Any CPU - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31}.Release|x86.Build.0 = Release|Any CPU - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6}.Debug|x64.ActiveCfg = Debug|Any CPU - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6}.Debug|x64.Build.0 = Debug|Any CPU - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6}.Debug|x86.ActiveCfg = Debug|Any CPU - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6}.Debug|x86.Build.0 = Debug|Any CPU - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6}.Release|Any CPU.Build.0 = Release|Any CPU - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6}.Release|x64.ActiveCfg = Release|Any CPU - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6}.Release|x64.Build.0 = Release|Any CPU - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6}.Release|x86.ActiveCfg = Release|Any CPU - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6}.Release|x86.Build.0 = Release|Any CPU - {ECCEA352-8953-49D6-8F87-8AB361499420}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {ECCEA352-8953-49D6-8F87-8AB361499420}.Debug|Any CPU.Build.0 = Debug|Any CPU - {ECCEA352-8953-49D6-8F87-8AB361499420}.Debug|x64.ActiveCfg = Debug|Any CPU - {ECCEA352-8953-49D6-8F87-8AB361499420}.Debug|x64.Build.0 = Debug|Any CPU - {ECCEA352-8953-49D6-8F87-8AB361499420}.Debug|x86.ActiveCfg = Debug|Any CPU - {ECCEA352-8953-49D6-8F87-8AB361499420}.Debug|x86.Build.0 = Debug|Any CPU - {ECCEA352-8953-49D6-8F87-8AB361499420}.Release|Any CPU.ActiveCfg = Release|Any CPU - {ECCEA352-8953-49D6-8F87-8AB361499420}.Release|Any CPU.Build.0 = Release|Any CPU - {ECCEA352-8953-49D6-8F87-8AB361499420}.Release|x64.ActiveCfg = Release|Any CPU - {ECCEA352-8953-49D6-8F87-8AB361499420}.Release|x64.Build.0 = Release|Any CPU - {ECCEA352-8953-49D6-8F87-8AB361499420}.Release|x86.ActiveCfg = Release|Any CPU - {ECCEA352-8953-49D6-8F87-8AB361499420}.Release|x86.Build.0 = Release|Any CPU - {D64AD07C-A711-42D8-8653-EDCD7A825A44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D64AD07C-A711-42D8-8653-EDCD7A825A44}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D64AD07C-A711-42D8-8653-EDCD7A825A44}.Debug|x64.ActiveCfg = Debug|Any CPU - {D64AD07C-A711-42D8-8653-EDCD7A825A44}.Debug|x64.Build.0 = Debug|Any CPU - {D64AD07C-A711-42D8-8653-EDCD7A825A44}.Debug|x86.ActiveCfg = Debug|Any CPU - {D64AD07C-A711-42D8-8653-EDCD7A825A44}.Debug|x86.Build.0 = Debug|Any CPU - {D64AD07C-A711-42D8-8653-EDCD7A825A44}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D64AD07C-A711-42D8-8653-EDCD7A825A44}.Release|Any CPU.Build.0 = Release|Any CPU - {D64AD07C-A711-42D8-8653-EDCD7A825A44}.Release|x64.ActiveCfg = Release|Any CPU - {D64AD07C-A711-42D8-8653-EDCD7A825A44}.Release|x64.Build.0 = Release|Any CPU - {D64AD07C-A711-42D8-8653-EDCD7A825A44}.Release|x86.ActiveCfg = Release|Any CPU - {D64AD07C-A711-42D8-8653-EDCD7A825A44}.Release|x86.Build.0 = Release|Any CPU - {B3866EEF-8F46-4302-ABAC-A95EE2F27331}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B3866EEF-8F46-4302-ABAC-A95EE2F27331}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B3866EEF-8F46-4302-ABAC-A95EE2F27331}.Debug|x64.ActiveCfg = Debug|Any CPU - {B3866EEF-8F46-4302-ABAC-A95EE2F27331}.Debug|x64.Build.0 = Debug|Any CPU - {B3866EEF-8F46-4302-ABAC-A95EE2F27331}.Debug|x86.ActiveCfg = Debug|Any CPU - {B3866EEF-8F46-4302-ABAC-A95EE2F27331}.Debug|x86.Build.0 = Debug|Any CPU - {B3866EEF-8F46-4302-ABAC-A95EE2F27331}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B3866EEF-8F46-4302-ABAC-A95EE2F27331}.Release|Any CPU.Build.0 = Release|Any CPU - {B3866EEF-8F46-4302-ABAC-A95EE2F27331}.Release|x64.ActiveCfg = Release|Any CPU - {B3866EEF-8F46-4302-ABAC-A95EE2F27331}.Release|x64.Build.0 = Release|Any CPU - {B3866EEF-8F46-4302-ABAC-A95EE2F27331}.Release|x86.ActiveCfg = Release|Any CPU - {B3866EEF-8F46-4302-ABAC-A95EE2F27331}.Release|x86.Build.0 = Release|Any CPU - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A}.Debug|x64.ActiveCfg = Debug|Any CPU - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A}.Debug|x64.Build.0 = Debug|Any CPU - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A}.Debug|x86.ActiveCfg = Debug|Any CPU - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A}.Debug|x86.Build.0 = Debug|Any CPU - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A}.Release|Any CPU.Build.0 = Release|Any CPU - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A}.Release|x64.ActiveCfg = Release|Any CPU - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A}.Release|x64.Build.0 = Release|Any CPU - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A}.Release|x86.ActiveCfg = Release|Any CPU - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A}.Release|x86.Build.0 = Release|Any CPU - {B15705B5-041C-4F1E-8342-AD03182EDD42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B15705B5-041C-4F1E-8342-AD03182EDD42}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B15705B5-041C-4F1E-8342-AD03182EDD42}.Debug|x64.ActiveCfg = Debug|Any CPU - {B15705B5-041C-4F1E-8342-AD03182EDD42}.Debug|x64.Build.0 = Debug|Any CPU - {B15705B5-041C-4F1E-8342-AD03182EDD42}.Debug|x86.ActiveCfg = Debug|Any CPU - {B15705B5-041C-4F1E-8342-AD03182EDD42}.Debug|x86.Build.0 = Debug|Any CPU - {B15705B5-041C-4F1E-8342-AD03182EDD42}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B15705B5-041C-4F1E-8342-AD03182EDD42}.Release|Any CPU.Build.0 = Release|Any CPU - {B15705B5-041C-4F1E-8342-AD03182EDD42}.Release|x64.ActiveCfg = Release|Any CPU - {B15705B5-041C-4F1E-8342-AD03182EDD42}.Release|x64.Build.0 = Release|Any CPU - {B15705B5-041C-4F1E-8342-AD03182EDD42}.Release|x86.ActiveCfg = Release|Any CPU - {B15705B5-041C-4F1E-8342-AD03182EDD42}.Release|x86.Build.0 = Release|Any CPU - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5}.Debug|x64.ActiveCfg = Debug|Any CPU - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5}.Debug|x64.Build.0 = Debug|Any CPU - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5}.Debug|x86.ActiveCfg = Debug|Any CPU - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5}.Debug|x86.Build.0 = Debug|Any CPU - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5}.Release|Any CPU.Build.0 = Release|Any CPU - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5}.Release|x64.ActiveCfg = Release|Any CPU - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5}.Release|x64.Build.0 = Release|Any CPU - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5}.Release|x86.ActiveCfg = Release|Any CPU - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5}.Release|x86.Build.0 = Release|Any CPU - {BCE4A428-8B97-4B56-AE45-496EE3906667}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BCE4A428-8B97-4B56-AE45-496EE3906667}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BCE4A428-8B97-4B56-AE45-496EE3906667}.Debug|x64.ActiveCfg = Debug|Any CPU - {BCE4A428-8B97-4B56-AE45-496EE3906667}.Debug|x64.Build.0 = Debug|Any CPU - {BCE4A428-8B97-4B56-AE45-496EE3906667}.Debug|x86.ActiveCfg = Debug|Any CPU - {BCE4A428-8B97-4B56-AE45-496EE3906667}.Debug|x86.Build.0 = Debug|Any CPU - {BCE4A428-8B97-4B56-AE45-496EE3906667}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BCE4A428-8B97-4B56-AE45-496EE3906667}.Release|Any CPU.Build.0 = Release|Any CPU - {BCE4A428-8B97-4B56-AE45-496EE3906667}.Release|x64.ActiveCfg = Release|Any CPU - {BCE4A428-8B97-4B56-AE45-496EE3906667}.Release|x64.Build.0 = Release|Any CPU - {BCE4A428-8B97-4B56-AE45-496EE3906667}.Release|x86.ActiveCfg = Release|Any CPU - {BCE4A428-8B97-4B56-AE45-496EE3906667}.Release|x86.Build.0 = Release|Any CPU - {27BEF279-AE73-43DC-92A9-FD7021A999D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {27BEF279-AE73-43DC-92A9-FD7021A999D0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {27BEF279-AE73-43DC-92A9-FD7021A999D0}.Debug|x64.ActiveCfg = Debug|Any CPU - {27BEF279-AE73-43DC-92A9-FD7021A999D0}.Debug|x64.Build.0 = Debug|Any CPU - {27BEF279-AE73-43DC-92A9-FD7021A999D0}.Debug|x86.ActiveCfg = Debug|Any CPU - {27BEF279-AE73-43DC-92A9-FD7021A999D0}.Debug|x86.Build.0 = Debug|Any CPU - {27BEF279-AE73-43DC-92A9-FD7021A999D0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {27BEF279-AE73-43DC-92A9-FD7021A999D0}.Release|Any CPU.Build.0 = Release|Any CPU - {27BEF279-AE73-43DC-92A9-FD7021A999D0}.Release|x64.ActiveCfg = Release|Any CPU - {27BEF279-AE73-43DC-92A9-FD7021A999D0}.Release|x64.Build.0 = Release|Any CPU - {27BEF279-AE73-43DC-92A9-FD7021A999D0}.Release|x86.ActiveCfg = Release|Any CPU - {27BEF279-AE73-43DC-92A9-FD7021A999D0}.Release|x86.Build.0 = Release|Any CPU - {34359707-CE66-4DF0-9EF4-D7544B615564}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {34359707-CE66-4DF0-9EF4-D7544B615564}.Debug|Any CPU.Build.0 = Debug|Any CPU - {34359707-CE66-4DF0-9EF4-D7544B615564}.Debug|x64.ActiveCfg = Debug|Any CPU - {34359707-CE66-4DF0-9EF4-D7544B615564}.Debug|x64.Build.0 = Debug|Any CPU - {34359707-CE66-4DF0-9EF4-D7544B615564}.Debug|x86.ActiveCfg = Debug|Any CPU - {34359707-CE66-4DF0-9EF4-D7544B615564}.Debug|x86.Build.0 = Debug|Any CPU - {34359707-CE66-4DF0-9EF4-D7544B615564}.Release|Any CPU.ActiveCfg = Release|Any CPU - {34359707-CE66-4DF0-9EF4-D7544B615564}.Release|Any CPU.Build.0 = Release|Any CPU - {34359707-CE66-4DF0-9EF4-D7544B615564}.Release|x64.ActiveCfg = Release|Any CPU - {34359707-CE66-4DF0-9EF4-D7544B615564}.Release|x64.Build.0 = Release|Any CPU - {34359707-CE66-4DF0-9EF4-D7544B615564}.Release|x86.ActiveCfg = Release|Any CPU - {34359707-CE66-4DF0-9EF4-D7544B615564}.Release|x86.Build.0 = Release|Any CPU - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}.Debug|x64.ActiveCfg = Debug|Any CPU - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}.Debug|x64.Build.0 = Debug|Any CPU - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}.Debug|x86.ActiveCfg = Debug|Any CPU - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}.Debug|x86.Build.0 = Debug|Any CPU - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}.Release|Any CPU.Build.0 = Release|Any CPU - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}.Release|x64.ActiveCfg = Release|Any CPU - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}.Release|x64.Build.0 = Release|Any CPU - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}.Release|x86.ActiveCfg = Release|Any CPU - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6}.Release|x86.Build.0 = Release|Any CPU - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E}.Debug|x64.ActiveCfg = Debug|Any CPU - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E}.Debug|x64.Build.0 = Debug|Any CPU - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E}.Debug|x86.ActiveCfg = Debug|Any CPU - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E}.Debug|x86.Build.0 = Debug|Any CPU - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E}.Release|Any CPU.Build.0 = Release|Any CPU - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E}.Release|x64.ActiveCfg = Release|Any CPU - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E}.Release|x64.Build.0 = Release|Any CPU - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E}.Release|x86.ActiveCfg = Release|Any CPU - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E}.Release|x86.Build.0 = Release|Any CPU - {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Debug|x64.ActiveCfg = Debug|Any CPU - {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Debug|x64.Build.0 = Debug|Any CPU - {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Debug|x86.ActiveCfg = Debug|Any CPU - {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Debug|x86.Build.0 = Debug|Any CPU - {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Release|Any CPU.Build.0 = Release|Any CPU - {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Release|x64.ActiveCfg = Release|Any CPU - {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Release|x64.Build.0 = Release|Any CPU - {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Release|x86.ActiveCfg = Release|Any CPU - {49AA63BF-3DBA-4490-9470-5AE0EB7F49F0}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {F3DF5AC5-8CDC-46D4-969D-1245A6880215} = {CE64E92B-E088-46FB-9028-7FB6B67DEC55} - {93324D12-DE1B-4C1B-934A-92AA140FF6F6} = {F3DF5AC5-8CDC-46D4-969D-1245A6880215} - {79981A5A-207A-4A16-A21B-5E80394082F6} = {F3DF5AC5-8CDC-46D4-969D-1245A6880215} - {05248A38-0F34-4E59-A3D1-B07097987AFB} = {CE64E92B-E088-46FB-9028-7FB6B67DEC55} - {12F8343D-20A6-4E24-B0F5-3A66F2228CF6} = {CE64E92B-E088-46FB-9028-7FB6B67DEC55} - {294D6FF8-9379-4AB8-A203-8D0CC85FBFBB} = {05248A38-0F34-4E59-A3D1-B07097987AFB} - {A1D828E4-6B83-4BA2-B8E9-B21CE3BE8A31} = {05248A38-0F34-4E59-A3D1-B07097987AFB} - {86BD3DF6-A3E9-4839-8036-813A20DC8AD6} = {CE64E92B-E088-46FB-9028-7FB6B67DEC55} - {ECCEA352-8953-49D6-8F87-8AB361499420} = {12F8343D-20A6-4E24-B0F5-3A66F2228CF6} - {D64AD07C-A711-42D8-8653-EDCD7A825A44} = {12F8343D-20A6-4E24-B0F5-3A66F2228CF6} - {B3866EEF-8F46-4302-ABAC-A95EE2F27331} = {79981A5A-207A-4A16-A21B-5E80394082F6} - {8C7DAF8E-F792-4092-8BBF-31A6B898B39A} = {93324D12-DE1B-4C1B-934A-92AA140FF6F6} - {B15705B5-041C-4F1E-8342-AD03182EDD42} = {93324D12-DE1B-4C1B-934A-92AA140FF6F6} - {89FE1C3B-29D3-48A8-8E7D-90C261D266C5} = {93324D12-DE1B-4C1B-934A-92AA140FF6F6} - {BCE4A428-8B97-4B56-AE45-496EE3906667} = {2B1F75CE-07A6-4C19-A2E3-F9E062CFDDFB} - {27BEF279-AE73-43DC-92A9-FD7021A999D0} = {2B1F75CE-07A6-4C19-A2E3-F9E062CFDDFB} - {34359707-CE66-4DF0-9EF4-D7544B615564} = {2B1F75CE-07A6-4C19-A2E3-F9E062CFDDFB} - {990CA37A-86D3-4FE6-B777-3CF0DDAC6BF6} = {D36E77BC-4568-4BC8-9506-1EFB7B1CD335} - {2119CE89-308D-4932-BFCE-8CDC0A05EB9E} = {D36E77BC-4568-4BC8-9506-1EFB7B1CD335} - {FE1B1E84-F993-4840-9CAB-9082EB523FDD} = {CE64E92B-E088-46FB-9028-7FB6B67DEC55} - {F17769D7-0E41-4E80-BDD4-282EBE7B5199} = {FE1B1E84-F993-4840-9CAB-9082EB523FDD} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {EA8248C2-3877-4AF7-8777-A17E7881E030} - EndGlobalSection -EndGlobal diff --git a/src/GetToken.http b/src/GetToken.http deleted file mode 100644 index 0de6481c71..0000000000 --- a/src/GetToken.http +++ /dev/null @@ -1,10 +0,0 @@ -@Host = https://localhost:7000 - -POST {{Host}}/api/token/ -Accept: application/json -Content-Type: application/json -tenant: root -{ - "email":"admin@root.com", - "password":"123Pa$$word!" -} diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/ActivityEventPayload.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/ActivityEventPayload.cs new file mode 100644 index 0000000000..6a3e431f8a --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/ActivityEventPayload.cs @@ -0,0 +1,13 @@ +namespace FSH.Modules.Auditing.Contracts; + +public sealed record ActivityEventPayload( + ActivityKind Kind, + string Name, // route template, command/query name, job id + int? StatusCode, + int DurationMs, + BodyCapture Captured, // Request/Response/Both/None + int RequestSize, + int ResponseSize, + object? RequestPreview, // truncated/filtered snapshot (JSON-friendly) + object? ResponsePreview +); diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/AuditEnums.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/AuditEnums.cs new file mode 100644 index 0000000000..73311fde9f --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/AuditEnums.cs @@ -0,0 +1,111 @@ +namespace FSH.Modules.Auditing.Contracts; + +/// +/// High-level classification of audit events. +/// +public enum AuditEventType +{ + None = 0, + EntityChange = 1, + Security = 2, + Activity = 3, + Exception = 4 +} + +/// +/// Severity scale aligned with standard logging levels. +/// +public enum AuditSeverity +{ + None = 0, + Trace = 1, + Debug = 2, + Information = 3, + Warning = 4, + Error = 5, + Critical = 6 +} + +/// +/// Security-related actions to track (login, token, role, etc.). +/// +public enum SecurityAction +{ + None = 0, + LoginSucceeded = 1, + LoginFailed = 2, + TokenIssued = 3, + TokenRevoked = 4, + PasswordChanged = 5, + RoleAssigned = 6, + RoleRevoked = 7, + PermissionDenied = 8, + PolicyFailed = 9 +} + +/// +/// Database operations that can trigger entity-change auditing. +/// +public enum EntityOperation +{ + None = 0, + Insert = 1, + Update = 2, + Delete = 3, + SoftDelete = 4, + Restore = 5 +} + +/// +/// Logical category of activity events. +/// +public enum ActivityKind +{ + None = 0, + Http = 1, + BackgroundJob = 2, + Command = 3, + Query = 4, + Integration = 5 +} + +/// +/// Area or subsystem where an exception originated. +/// +public enum ExceptionArea +{ + None = 0, + Api = 1, + Worker = 2, + Ui = 3, + Infra = 4, + Unknown = 255 +} + +/// +/// Indicates which HTTP bodies are captured in activity events. +/// +[Flags] +public enum BodyCapture +{ + None = 0, + Request = 1, + Response = 2, + Both = Request | Response +} + +/// +/// Compact, bitwise tags that provide additional audit metadata. +/// +[Flags] +public enum AuditTag +{ + None = 0, + PiiMasked = 1 << 0, + OutOfQuota = 1 << 1, + Sampled = 1 << 2, + RetainedLong = 1 << 3, + HealthCheck = 1 << 4, + Authentication = 1 << 5, + Authorization = 1 << 6 +} diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/AuditEnvelope.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/AuditEnvelope.cs new file mode 100644 index 0000000000..cfab578144 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/AuditEnvelope.cs @@ -0,0 +1,65 @@ +using System.Diagnostics; + +namespace FSH.Modules.Auditing.Contracts; + +/// +/// Concrete event instance ready to be published/persisted. +/// Carries normalized metadata + strongly-typed Payload. +/// +public sealed class AuditEnvelope : IAuditEvent +{ + public Guid Id { get; } + public DateTime OccurredAtUtc { get; } + public DateTime ReceivedAtUtc { get; } + + public AuditEventType EventType { get; } + public AuditSeverity Severity { get; } + + public string? TenantId { get; } + public string? UserId { get; } + public string? UserName { get; } + + public string? TraceId { get; } + public string? SpanId { get; } + public string? CorrelationId { get; } + public string? RequestId { get; } + public string? Source { get; } + + public AuditTag Tags { get; } + + public object Payload { get; } + + public AuditEnvelope( + Guid id, + DateTime occurredAtUtc, + DateTime receivedAtUtc, + AuditEventType eventType, + AuditSeverity severity, + string? tenantId, + string? userId, + string? userName, + string? traceId, + string? spanId, + string? correlationId, + string? requestId, + string? source, + AuditTag tags, + object payload) + { + Id = id; + OccurredAtUtc = occurredAtUtc.Kind == DateTimeKind.Utc ? occurredAtUtc : occurredAtUtc.ToUniversalTime(); + ReceivedAtUtc = receivedAtUtc.Kind == DateTimeKind.Utc ? receivedAtUtc : receivedAtUtc.ToUniversalTime(); + EventType = eventType; + Severity = severity; + TenantId = tenantId; + UserId = userId; + UserName = userName; + TraceId = traceId ?? Activity.Current?.TraceId.ToString(); + SpanId = spanId ?? Activity.Current?.SpanId.ToString(); + CorrelationId = correlationId; + RequestId = requestId; + Source = source; + Tags = tags; + Payload = payload ?? throw new ArgumentNullException(nameof(payload)); + } +} diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/AuditHttpOptions.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/AuditHttpOptions.cs new file mode 100644 index 0000000000..3123d15b3d --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/AuditHttpOptions.cs @@ -0,0 +1,23 @@ +namespace FSH.Modules.Auditing.Contracts; + +public sealed class AuditHttpOptions +{ + public bool CaptureBodies { get; set; } = true; + public int MaxRequestBytes { get; set; } = 8_192; + public int MaxResponseBytes { get; set; } = 16_384; + + public HashSet AllowedContentTypes { get; } = + new(StringComparer.OrdinalIgnoreCase) + { + "application/json", + "application/problem+json" + }; + + public HashSet ExcludePathStartsWith { get; } = + new(StringComparer.OrdinalIgnoreCase) + { + "/health", "/metrics", "/_framework", "/swagger", "/scalar", "/openapi" + }; + + public AuditSeverity MinExceptionSeverity { get; set; } = AuditSeverity.Error; +} diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/AuditingContractsMarker.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/AuditingContractsMarker.cs new file mode 100644 index 0000000000..5556f5fe9e --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/AuditingContractsMarker.cs @@ -0,0 +1,6 @@ +namespace FSH.Modules.Auditing.Contracts; + +// Marker type for contract assembly scanning (Mediator, etc.) +public sealed class AuditingContractsMarker +{ +} diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/Dtos/AuditDetailDto.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/Dtos/AuditDetailDto.cs new file mode 100644 index 0000000000..cc1350b3bc --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/Dtos/AuditDetailDto.cs @@ -0,0 +1,41 @@ +using FSH.Modules.Auditing.Contracts; +using System.Text.Json; + +namespace FSH.Modules.Auditing.Contracts.Dtos; + +public sealed class AuditDetailDto +{ + public Guid Id { get; set; } + + public DateTime OccurredAtUtc { get; set; } + + public DateTime ReceivedAtUtc { get; set; } + + public AuditEventType EventType { get; set; } + + public AuditSeverity Severity { get; set; } + + public string? TenantId { get; set; } + + public string? UserId { get; set; } + + public string? UserName { get; set; } + + public string? TraceId { get; set; } + + public string? SpanId { get; set; } + + public string? CorrelationId { get; set; } + + public string? RequestId { get; set; } + + public string? Source { get; set; } + + public AuditTag Tags { get; set; } + + /// + /// Masked, deserialized payload. Serialized back to JSON for clients. + /// + public JsonElement Payload { get; set; } +} + diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/Dtos/AuditSummaryAggregateDto.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/Dtos/AuditSummaryAggregateDto.cs new file mode 100644 index 0000000000..e3c1bf8a83 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/Dtos/AuditSummaryAggregateDto.cs @@ -0,0 +1,18 @@ +using FSH.Modules.Auditing.Contracts; + +namespace FSH.Modules.Auditing.Contracts.Dtos; + +public sealed class AuditSummaryAggregateDto +{ + public IDictionary EventsByType { get; init; } = + new Dictionary(); + + public IDictionary EventsBySeverity { get; init; } = + new Dictionary(); + + public IDictionary EventsBySource { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public IDictionary EventsByTenant { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); +} diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/Dtos/AuditSummaryDto.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/Dtos/AuditSummaryDto.cs new file mode 100644 index 0000000000..9ccbd8cbd4 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/Dtos/AuditSummaryDto.cs @@ -0,0 +1,31 @@ +using FSH.Modules.Auditing.Contracts; + +namespace FSH.Modules.Auditing.Contracts.Dtos; + +public sealed class AuditSummaryDto +{ + public Guid Id { get; set; } + + public DateTime OccurredAtUtc { get; set; } + + public AuditEventType EventType { get; set; } + + public AuditSeverity Severity { get; set; } + + public string? TenantId { get; set; } + + public string? UserId { get; set; } + + public string? UserName { get; set; } + + public string? TraceId { get; set; } + + public string? CorrelationId { get; set; } + + public string? RequestId { get; set; } + + public string? Source { get; set; } + + public AuditTag Tags { get; set; } +} + diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/EntityChangeEventPayload.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/EntityChangeEventPayload.cs new file mode 100644 index 0000000000..177523b68b --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/EntityChangeEventPayload.cs @@ -0,0 +1,12 @@ +namespace FSH.Modules.Auditing.Contracts; + +public sealed record EntityChangeEventPayload( + string DbContext, + string? Schema, + string Table, + string EntityName, + string Key, // unified string key (e.g., "Id:42" or "TenantId:1|UserId:42") + EntityOperation Operation, + IReadOnlyList Changes, + string? TransactionId +); diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/ExceptionEventPayload.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/ExceptionEventPayload.cs new file mode 100644 index 0000000000..526da8c353 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/ExceptionEventPayload.cs @@ -0,0 +1,10 @@ +namespace FSH.Modules.Auditing.Contracts; + +public sealed record ExceptionEventPayload( + ExceptionArea Area, + string ExceptionType, + string Message, + IReadOnlyList StackTop, // capped frames + IReadOnlyDictionary? Data, + string? RouteOrLocation +); diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/ExceptionSeverityClassifier.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/ExceptionSeverityClassifier.cs new file mode 100644 index 0000000000..324ef68c2a --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/ExceptionSeverityClassifier.cs @@ -0,0 +1,12 @@ +namespace FSH.Modules.Auditing.Contracts; + +public static class ExceptionSeverityClassifier +{ + public static AuditSeverity Classify(Exception ex) => + ex switch + { + OperationCanceledException => AuditSeverity.Information, + UnauthorizedAccessException => AuditSeverity.Warning, + _ => AuditSeverity.Error + }; +} diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditClient.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditClient.cs new file mode 100644 index 0000000000..755683ce24 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditClient.cs @@ -0,0 +1,27 @@ +namespace FSH.Modules.Auditing.Contracts; + +public interface IAuditClient +{ + // Core + ValueTask WriteEntityChangeAsync( + string dbContext, string? schema, string table, string entityName, string key, + EntityOperation operation, IReadOnlyList changes, + string? transactionId = null, AuditSeverity severity = AuditSeverity.Information, + string? source = null, CancellationToken ct = default); + + ValueTask WriteSecurityAsync( + SecurityAction action, + string? subjectId = null, string? clientId = null, string? authMethod = null, string? reasonCode = null, + IReadOnlyDictionary? claims = null, + AuditSeverity? severity = null, string? source = null, CancellationToken ct = default); + + ValueTask WriteActivityAsync( + ActivityKind kind, string name, int? statusCode, int durationMs, + BodyCapture captured = BodyCapture.None, int requestSize = 0, int responseSize = 0, + object? requestPreview = null, object? responsePreview = null, + AuditSeverity severity = AuditSeverity.Information, string? source = null, CancellationToken ct = default); + + ValueTask WriteExceptionAsync( + Exception ex, ExceptionArea area = ExceptionArea.None, string? routeOrLocation = null, + AuditSeverity? severity = null, string? source = null, CancellationToken ct = default); +} diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditEnricher.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditEnricher.cs new file mode 100644 index 0000000000..2e03306de5 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditEnricher.cs @@ -0,0 +1,10 @@ +namespace FSH.Modules.Auditing.Contracts; + +/// +/// Hook to augment events before they are published (e.g., add tenant/user/trace, normalize fields, enforce caps). +/// +public interface IAuditEnricher +{ + /// Mutate/augment the event instance prior to serialization/publish. + void Enrich(IAuditEvent auditEvent); +} diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditEvent.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditEvent.cs new file mode 100644 index 0000000000..e403dceaf3 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditEvent.cs @@ -0,0 +1,35 @@ +namespace FSH.Modules.Auditing.Contracts; + +public interface IAuditEvent +{ + /// Event category (EntityChange, Security, Activity, Exception…) + AuditEventType EventType { get; } + + /// Severity level (None, Info, Error, …) + AuditSeverity Severity { get; } + + /// UTC time when the event actually occurred. + DateTime OccurredAtUtc { get; } + + /// Tenant identifier (optional in per-tenant DBs; still useful for exports). + string? TenantId { get; } + + /// Subject/User id and display name (when available). + string? UserId { get; } + string? UserName { get; } + + /// Correlation/trace identifiers for distributed tracing. + string? TraceId { get; } + string? SpanId { get; } + string? CorrelationId { get; } + string? RequestId { get; } + + /// Logical source (module/service) of the event. + string? Source { get; } + + /// Compact bitwise tags (e.g., PiiMasked, Sampled). + AuditTag Tags { get; } + + /// Strongly-typed payload (EntityChange, Security, Activity, Exception, etc.). + object Payload { get; } +} diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditMaskingService.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditMaskingService.cs new file mode 100644 index 0000000000..daf2ea8a3d --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditMaskingService.cs @@ -0,0 +1,9 @@ +namespace FSH.Modules.Auditing.Contracts; + +/// +/// Masks or hashes sensitive fields before persistence or externalization. +/// +public interface IAuditMaskingService +{ + object ApplyMasking(object payload); +} diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditMutatingEnricher.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditMutatingEnricher.cs new file mode 100644 index 0000000000..4bc630c3f2 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditMutatingEnricher.cs @@ -0,0 +1,9 @@ +namespace FSH.Modules.Auditing.Contracts; + +/// +/// Enricher that can return a modified event (e.g., fill missing fields, mask payload). +/// +public interface IAuditMutatingEnricher +{ + AuditEnvelope Enrich(AuditEnvelope envelope); +} diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditPublisher.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditPublisher.cs new file mode 100644 index 0000000000..c0f75e355b --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditPublisher.cs @@ -0,0 +1,13 @@ +namespace FSH.Modules.Auditing.Contracts; + +/// +/// Low-latency, non-blocking publisher. Implement with a bounded channel + background worker. +/// +public interface IAuditPublisher +{ + /// Publish an audit event. Implementations should avoid blocking the request path. + ValueTask PublishAsync(IAuditEvent auditEvent, CancellationToken ct = default); + + /// Ambient scope for the current operation (usually request-scoped). + IAuditScope CurrentScope { get; } +} diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditScope.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditScope.cs new file mode 100644 index 0000000000..0d52f2c1d2 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditScope.cs @@ -0,0 +1,35 @@ +namespace FSH.Modules.Auditing.Contracts; + +/// +/// Ambient context for the current operation/request. +/// Implementations typically pull from HttpContext, Tenant provider, and Activity.Current. +/// +public interface IAuditScope +{ + string? TenantId { get; } + string? UserId { get; } + string? UserName { get; } + string? TraceId { get; } + string? SpanId { get; } + string? CorrelationId { get; } + string? RequestId { get; } + string? Source { get; } + + /// Default tags to apply to all events in this scope. + AuditTag Tags { get; } + + /// Clone the scope with additional tags (non-destructive). + IAuditScope WithTags(AuditTag tags); + + /// Clone the scope overriding select fields (use null to keep existing). + IAuditScope WithProperties( + string? tenantId = null, + string? userId = null, + string? userName = null, + string? traceId = null, + string? spanId = null, + string? correlationId = null, + string? requestId = null, + string? source = null, + AuditTag? tags = null); +} diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditSerializer.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditSerializer.cs new file mode 100644 index 0000000000..7ed8578c1a --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditSerializer.cs @@ -0,0 +1,9 @@ +namespace FSH.Modules.Auditing.Contracts; + +/// +/// Deterministic JSON serialization for payloads (camelCase, enum-as-string, stable output). +/// +public interface IAuditSerializer +{ + string SerializePayload(object payload); +} diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditSink.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditSink.cs new file mode 100644 index 0000000000..181c3f304d --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/IAuditSink.cs @@ -0,0 +1,9 @@ +namespace FSH.Modules.Auditing.Contracts; + +/// +/// Destination for audit events (e.g., SQL, file, OTLP). Implementations must be efficient and batch-friendly. +/// +public interface IAuditSink +{ + Task WriteAsync(IReadOnlyList batch, CancellationToken ct); +} diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/ISecurityAudit.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/ISecurityAudit.cs new file mode 100644 index 0000000000..abe3c7b2e5 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/ISecurityAudit.cs @@ -0,0 +1,9 @@ +namespace FSH.Modules.Auditing.Contracts; + +public interface ISecurityAudit +{ + ValueTask LoginSucceededAsync(string userId, string userName, string clientId, string ip, string userAgent, CancellationToken ct = default); + ValueTask LoginFailedAsync(string subjectIdOrName, string clientId, string reason, string ip, CancellationToken ct = default); + ValueTask TokenIssuedAsync(string userId, string userName, string clientId, string tokenFingerprint, DateTime expiresUtc, CancellationToken ct = default); + ValueTask TokenRevokedAsync(string userId, string clientId, string reason, CancellationToken ct = default); +} diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/Modules.Auditing.Contracts.csproj b/src/Modules/Auditing/Modules.Auditing.Contracts/Modules.Auditing.Contracts.csproj new file mode 100644 index 0000000000..8ad7539c97 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/Modules.Auditing.Contracts.csproj @@ -0,0 +1,15 @@ + + + + FSH.Modules.Auditing.Contracts + FSH.Modules.Auditing.Contracts + FullStackHero.Modules.Auditing.Contracts + $(NoWarn);S2094 + + + + + + + + diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/PropertyChange.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/PropertyChange.cs new file mode 100644 index 0000000000..20d665c17a --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/PropertyChange.cs @@ -0,0 +1,12 @@ +namespace FSH.Modules.Auditing.Contracts; + +/// +/// A single property delta for entity-change auditing. +/// +public sealed record PropertyChange( + string Name, + string? DataType, // e.g., "string", "int", "datetime" + object? OldValue, + object? NewValue, + bool IsSensitive // true => value already masked/hashed +); diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/SecurityEventPayload.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/SecurityEventPayload.cs new file mode 100644 index 0000000000..8b0906dd21 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/SecurityEventPayload.cs @@ -0,0 +1,10 @@ +namespace FSH.Modules.Auditing.Contracts; + +public sealed record SecurityEventPayload( + SecurityAction Action, + string? SubjectId, + string? ClientId, + string? AuthMethod, // Password, OIDC, etc. + string? ReasonCode, // InvalidPassword, LockedOut, etc. + IReadOnlyDictionary? ClaimsSnapshot +); diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/v1/GetAuditById/GetAuditByIdQuery.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/v1/GetAuditById/GetAuditByIdQuery.cs new file mode 100644 index 0000000000..4e3a989e0e --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/v1/GetAuditById/GetAuditByIdQuery.cs @@ -0,0 +1,7 @@ +using FSH.Modules.Auditing.Contracts.Dtos; +using Mediator; + +namespace FSH.Modules.Auditing.Contracts.v1.GetAuditById; + +public sealed record GetAuditByIdQuery(Guid Id) : IQuery; + diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/v1/GetAuditSummary/GetAuditSummaryQuery.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/v1/GetAuditSummary/GetAuditSummaryQuery.cs new file mode 100644 index 0000000000..078ad6fb4e --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/v1/GetAuditSummary/GetAuditSummaryQuery.cs @@ -0,0 +1,14 @@ +using FSH.Modules.Auditing.Contracts.Dtos; +using Mediator; + +namespace FSH.Modules.Auditing.Contracts.v1.GetAuditSummary; + +public sealed class GetAuditSummaryQuery : IQuery +{ + public DateTime? FromUtc { get; init; } + + public DateTime? ToUtc { get; init; } + + public string? TenantId { get; init; } +} + diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/v1/GetAudits/GetAuditsQuery.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/v1/GetAudits/GetAuditsQuery.cs new file mode 100644 index 0000000000..57a59b2a93 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/v1/GetAudits/GetAuditsQuery.cs @@ -0,0 +1,38 @@ +using FSH.Framework.Shared.Persistence; +using FSH.Modules.Auditing.Contracts; +using FSH.Modules.Auditing.Contracts.Dtos; +using Mediator; + +namespace FSH.Modules.Auditing.Contracts.v1.GetAudits; + +public sealed class GetAuditsQuery : IPagedQuery, IQuery> +{ + public int? PageNumber { get; set; } + + public int? PageSize { get; set; } + + public string? Sort { get; set; } + + public DateTime? FromUtc { get; set; } + + public DateTime? ToUtc { get; set; } + + public string? TenantId { get; set; } + + public string? UserId { get; set; } + + public AuditEventType? EventType { get; set; } + + public AuditSeverity? Severity { get; set; } + + public AuditTag? Tags { get; set; } + + public string? Source { get; set; } + + public string? CorrelationId { get; set; } + + public string? TraceId { get; set; } + + public string? Search { get; set; } +} + diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/v1/GetAuditsByCorrelation/GetAuditsByCorrelationQuery.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/v1/GetAuditsByCorrelation/GetAuditsByCorrelationQuery.cs new file mode 100644 index 0000000000..e122680cb0 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/v1/GetAuditsByCorrelation/GetAuditsByCorrelationQuery.cs @@ -0,0 +1,14 @@ +using FSH.Modules.Auditing.Contracts.Dtos; +using Mediator; + +namespace FSH.Modules.Auditing.Contracts.v1.GetAuditsByCorrelation; + +public sealed class GetAuditsByCorrelationQuery : IQuery> +{ + public string CorrelationId { get; init; } = default!; + + public DateTime? FromUtc { get; init; } + + public DateTime? ToUtc { get; init; } +} + diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/v1/GetAuditsByTrace/GetAuditsByTraceQuery.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/v1/GetAuditsByTrace/GetAuditsByTraceQuery.cs new file mode 100644 index 0000000000..128ba675b6 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/v1/GetAuditsByTrace/GetAuditsByTraceQuery.cs @@ -0,0 +1,14 @@ +using FSH.Modules.Auditing.Contracts.Dtos; +using Mediator; + +namespace FSH.Modules.Auditing.Contracts.v1.GetAuditsByTrace; + +public sealed class GetAuditsByTraceQuery : IQuery> +{ + public string TraceId { get; init; } = default!; + + public DateTime? FromUtc { get; init; } + + public DateTime? ToUtc { get; init; } +} + diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/v1/GetExceptionAudits/GetExceptionAuditsQuery.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/v1/GetExceptionAudits/GetExceptionAuditsQuery.cs new file mode 100644 index 0000000000..61a416e077 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/v1/GetExceptionAudits/GetExceptionAuditsQuery.cs @@ -0,0 +1,21 @@ +using FSH.Modules.Auditing.Contracts; +using FSH.Modules.Auditing.Contracts.Dtos; +using Mediator; + +namespace FSH.Modules.Auditing.Contracts.v1.GetExceptionAudits; + +public sealed class GetExceptionAuditsQuery : IQuery> +{ + public ExceptionArea? Area { get; init; } + + public AuditSeverity? Severity { get; init; } + + public string? ExceptionType { get; init; } + + public string? RouteOrLocation { get; init; } + + public DateTime? FromUtc { get; init; } + + public DateTime? ToUtc { get; init; } +} + diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/v1/GetSecurityAudits/GetSecurityAuditsQuery.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/v1/GetSecurityAudits/GetSecurityAuditsQuery.cs new file mode 100644 index 0000000000..2c61ef5e56 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/v1/GetSecurityAudits/GetSecurityAuditsQuery.cs @@ -0,0 +1,19 @@ +using FSH.Modules.Auditing.Contracts; +using FSH.Modules.Auditing.Contracts.Dtos; +using Mediator; + +namespace FSH.Modules.Auditing.Contracts.v1.GetSecurityAudits; + +public sealed class GetSecurityAuditsQuery : IQuery> +{ + public SecurityAction? Action { get; init; } + + public string? UserId { get; init; } + + public string? TenantId { get; init; } + + public DateTime? FromUtc { get; init; } + + public DateTime? ToUtc { get; init; } +} + diff --git a/src/Modules/Auditing/Modules.Auditing/AssemblyInfo.cs b/src/Modules/Auditing/Modules.Auditing/AssemblyInfo.cs new file mode 100644 index 0000000000..b6543a4200 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using FSH.Framework.Web.Modules; + +[assembly: FshModule(typeof(FSH.Modules.Auditing.AuditingModule), 300)] diff --git a/src/Modules/Auditing/Modules.Auditing/AuditingModule.cs b/src/Modules/Auditing/Modules.Auditing/AuditingModule.cs new file mode 100644 index 0000000000..337bbc0878 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/AuditingModule.cs @@ -0,0 +1,73 @@ +using Asp.Versioning; +using FSH.Framework.Persistence; +using FSH.Framework.Web.Modules; +using FSH.Modules.Auditing.Contracts; +using FSH.Modules.Auditing.Features.v1.GetAuditById; +using FSH.Modules.Auditing.Features.v1.GetAudits; +using FSH.Modules.Auditing.Features.v1.GetAuditsByCorrelation; +using FSH.Modules.Auditing.Features.v1.GetAuditsByTrace; +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.Persistence; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; + +namespace FSH.Modules.Auditing; + +public class AuditingModule : IModule +{ + public void ConfigureServices(IHostApplicationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + var httpOpts = builder.Configuration.GetSection("Auditing").Get() ?? new AuditHttpOptions(); + builder.Services.AddSingleton(httpOpts); + builder.Services.AddHttpContextAccessor(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddHeroDbContext(); + builder.Services.AddScoped(); + builder.Services.AddSingleton(); + builder.Services.AddHealthChecks() + .AddDbContextCheck( + name: "db:auditing", + failureStatus: HealthStatus.Unhealthy); + + // Enrichers used by Audit.Configure (scoped, run on request thread) + builder.Services.AddScoped(); + builder.Services.AddHostedService(); + builder.Services.AddScoped(); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + + builder.Services.AddSingleton(); + builder.Services.AddHostedService(); + } + + public void MapEndpoints(IEndpointRouteBuilder endpoints) + { + var apiVersionSet = endpoints.NewApiVersionSet() + .HasApiVersion(new ApiVersion(1)) + .ReportApiVersions() + .Build(); + + var group = endpoints + .MapGroup("api/v{version:apiVersion}/audits") + .WithTags("Audits") + .WithApiVersionSet(apiVersionSet); + + group.MapGetAuditsEndpoint(); + group.MapGetAuditByIdEndpoint(); + group.MapGetAuditsByCorrelationEndpoint(); + group.MapGetAuditsByTraceEndpoint(); + group.MapGetSecurityAuditsEndpoint(); + group.MapGetExceptionAuditsEndpoint(); + group.MapGetAuditSummaryEndpoint(); + } +} diff --git a/src/Modules/Auditing/Modules.Auditing/Core/Audit.cs b/src/Modules/Auditing/Modules.Auditing/Core/Audit.cs new file mode 100644 index 0000000000..ea9368027d --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Core/Audit.cs @@ -0,0 +1,206 @@ +using FSH.Modules.Auditing.Contracts; +using System.Diagnostics; + +namespace FSH.Modules.Auditing; + +/// +/// Fluent entry-point to create and publish audit events. +/// Configure once at startup with a publisher, serializer, and optional enrichers. +/// +public static class Audit +{ + public static IAuditPublisher Publisher { get; private set; } = new NoopPublisher(); + public static IAuditSerializer Serializer { get; private set; } = new SystemTextJsonAuditSerializer(); + private static readonly List _enrichers = new(); + + public static void Configure( + IAuditPublisher publisher, + IAuditSerializer? serializer = null, + IEnumerable? enrichers = null) + { + Publisher = publisher ?? throw new ArgumentNullException(nameof(publisher)); + if (serializer is not null) Serializer = serializer; + _enrichers.Clear(); + if (enrichers is not null) _enrichers.AddRange(enrichers); + } + + // --- Factory methods ------------------------------------------------------- + + public static Builder ForEntityChange( + string dbContext, string? schema, string table, string entityName, string key, + EntityOperation operation, IEnumerable changes) + => new Builder( + eventType: AuditEventType.EntityChange, + severity: AuditSeverity.Information, + payload: new EntityChangeEventPayload(dbContext, schema, table, entityName, key, operation, changes.ToArray(), TransactionId: null)); + + public static Builder ForSecurity(SecurityAction action) + => new Builder( + eventType: AuditEventType.Security, + severity: action is SecurityAction.LoginFailed or SecurityAction.PermissionDenied or SecurityAction.PolicyFailed + ? AuditSeverity.Warning : AuditSeverity.Information, + payload: new SecurityEventPayload(action, null, null, null, null, null)); + + public static Builder ForActivity(Contracts.ActivityKind kind, string name) + => new Builder( + eventType: AuditEventType.Activity, + severity: AuditSeverity.Information, + payload: new ActivityEventPayload(kind, name, null, 0, BodyCapture.None, 0, 0, null, null)); + + public static Builder ForException(Exception ex, ExceptionArea area = ExceptionArea.None, string? routeOrLocation = null, AuditSeverity? severity = null) + { + ArgumentNullException.ThrowIfNull(ex); + return new Builder( + eventType: AuditEventType.Exception, + severity: severity ?? DefaultSeverity(ex), + payload: new ExceptionEventPayload(area, + ex.GetType().FullName ?? "Exception", + ex.Message ?? string.Empty, + StackTop(ex, maxFrames: 20), + ToDict(ex.Data), + routeOrLocation)); + } + + private static AuditSeverity DefaultSeverity(Exception ex) + { + if (ex is OperationCanceledException) + return AuditSeverity.Information; + if (ex is UnauthorizedAccessException) + return AuditSeverity.Warning; + return AuditSeverity.Error; + } + + private static List StackTop(Exception ex, int maxFrames) + { + var frames = new List(maxFrames); + var trace = new StackTrace(ex, true); + foreach (var f in trace.GetFrames() ?? Array.Empty()) + { + if (frames.Count >= maxFrames) break; + var method = f.GetMethod(); + var name = method is null ? "" : $"{method.DeclaringType?.FullName}.{method.Name}"; + var file = f.GetFileName(); + var line = f.GetFileLineNumber(); + frames.Add(file is null ? name : $"{name} ({file}:{line})"); + } + return frames; + } + + private static Dictionary? ToDict(System.Collections.IDictionary? data) + { + if (data is null || data.Count == 0) return null; + var dict = new Dictionary(data.Count); + foreach (var k in data.Keys) + { + var key = k?.ToString() ?? "key"; + dict[key] = data[key]; + } + return dict; + } + + // --- Builder --------------------------------------------------------------- + + public sealed class Builder + { + private readonly AuditEventType _type; + private AuditSeverity _severity; + private object _payload; + + private string? _tenantId; + private string? _userId; + private string? _userName; + private string? _traceId = Activity.Current?.TraceId.ToString(); + private string? _spanId = Activity.Current?.SpanId.ToString(); + private string? _correlationId; + private string? _requestId; + private string? _source; + private AuditTag _tags = AuditTag.None; + private DateTime _occurredAtUtc = DateTime.UtcNow; + + internal Builder(AuditEventType eventType, AuditSeverity severity, object payload) + { + _type = eventType; + _severity = severity; + _payload = payload; + } + + public Builder WithSeverity(AuditSeverity severity) { _severity = severity; return this; } + public Builder WithTenant(string? tenantId) { _tenantId = tenantId; return this; } + public Builder WithUser(string? userId, string? userName = null) { _userId = userId; _userName = userName; return this; } + public Builder WithTrace(string? traceId, string? spanId = null) { _traceId = traceId; _spanId = spanId; return this; } + public Builder WithCorrelation(string? correlationId) { _correlationId = correlationId; return this; } + public Builder WithRequestId(string? requestId) { _requestId = requestId; return this; } + public Builder WithSource(string? source) { _source = source; return this; } + public Builder WithTags(AuditTag tags) { _tags |= tags; return this; } + public Builder At(DateTime utc) { _occurredAtUtc = utc.Kind == DateTimeKind.Utc ? utc : utc.ToUniversalTime(); return this; } + + // Typed updaters -------------------------------------------------------- + + public Builder WithEntityTransactionId(string? transactionId) + { + if (_payload is EntityChangeEventPayload p) _payload = p with { TransactionId = transactionId }; + return this; + } + + public Builder WithSecurityContext( + string? subjectId = null, + string? clientId = null, + string? authMethod = null, + string? reasonCode = null, + IReadOnlyDictionary? claims = null) + { + if (_payload is SecurityEventPayload p) + _payload = p with { SubjectId = subjectId, ClientId = clientId, AuthMethod = authMethod, ReasonCode = reasonCode, ClaimsSnapshot = claims }; + return this; + } + + public Builder WithActivityResult( + int? statusCode, int durationMs, + BodyCapture captured = BodyCapture.None, + int requestSize = 0, int responseSize = 0, + object? requestPreview = null, object? responsePreview = null) + { + if (_payload is ActivityEventPayload p) + _payload = p with { StatusCode = statusCode, DurationMs = durationMs, Captured = captured, RequestSize = requestSize, ResponseSize = responseSize, RequestPreview = requestPreview, ResponsePreview = responsePreview }; + return this; + } + + // Finalize + publish ---------------------------------------------------- + + public async ValueTask WriteAsync(CancellationToken ct = default) + { + var env = new AuditEnvelope( + id: Guid.CreateVersion7(), + occurredAtUtc: _occurredAtUtc, + receivedAtUtc: DateTime.UtcNow, + eventType: _type, + severity: _severity, + tenantId: _tenantId, + userId: _userId, + userName: _userName, + traceId: _traceId, + spanId: _spanId, + correlationId: _correlationId, + requestId: _requestId, + source: _source, + tags: _tags, + payload: _payload + ); + + // Enrich prior to publish + foreach (var enricher in _enrichers) + enricher.Enrich(env); + + await Publisher.PublishAsync(env, ct); + } + + public void Write(CancellationToken ct = default) => WriteAsync(ct).AsTask().GetAwaiter().GetResult(); + } + + // --- tiny safe defaults so dev builds run --------------------------------- + private sealed class NoopPublisher : IAuditPublisher + { + public IAuditScope CurrentScope { get; } = new DefaultAuditScope(null, null, null, null, null, null, null, null, AuditTag.None); + public ValueTask PublishAsync(IAuditEvent auditEvent, CancellationToken ct = default) => ValueTask.CompletedTask; + } +} diff --git a/src/Modules/Auditing/Modules.Auditing/Core/AuditBackgroundWorker.cs b/src/Modules/Auditing/Modules.Auditing/Core/AuditBackgroundWorker.cs new file mode 100644 index 0000000000..52a7d7a51c --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Core/AuditBackgroundWorker.cs @@ -0,0 +1,110 @@ +using FSH.Modules.Auditing.Contracts; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace FSH.Modules.Auditing; + +/// +/// Drains the channel and writes to the configured sink in batches. +/// +public sealed class AuditBackgroundWorker : BackgroundService +{ + private readonly ChannelAuditPublisher _publisher; + private readonly IAuditSink _sink; + private readonly ILogger _logger; + + private readonly int _batchSize; + private readonly TimeSpan _flushInterval; + + public AuditBackgroundWorker( + ChannelAuditPublisher publisher, + IAuditSink sink, + ILogger logger, + int batchSize = 200, + int flushIntervalMs = 1000) + { + _publisher = publisher; + _sink = sink; + _logger = logger; + _batchSize = Math.Max(1, batchSize); + _flushInterval = TimeSpan.FromMilliseconds(Math.Max(50, flushIntervalMs)); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var reader = _publisher.Reader; + var batch = new List(_batchSize); + + // Single delay task we reuse/reset to avoid concurrent waits. + Task delayTask = Task.Delay(_flushInterval, stoppingToken); + + try + { + while (!stoppingToken.IsCancellationRequested) + { + // Greedily drain whatever is available, up to batch size. + while (batch.Count < _batchSize && reader.TryRead(out var item)) + batch.Add(item); + + // If we've filled the batch, flush immediately. + if (batch.Count >= _batchSize) + { + await FlushAsync(batch, stoppingToken); + delayTask = Task.Delay(_flushInterval, stoppingToken); // reset window after flush + continue; + } + + // If we have nothing yet, wait for either data to arrive or the flush window to elapse. + var readTask = reader.WaitToReadAsync(stoppingToken).AsTask(); + var winner = await Task.WhenAny(readTask, delayTask); + + if (winner == readTask) + { + // If channel is completed, exit the loop. + if (!await readTask.ConfigureAwait(false)) + break; + + // Loop back to drain newly available items (no flush yet). + continue; + } + + // Timer window elapsed: flush whatever we have (if any) and open a new window. + if (batch.Count > 0) + await FlushAsync(batch, stoppingToken); + + delayTask = Task.Delay(_flushInterval, stoppingToken); // start a fresh window + } + } + catch (OperationCanceledException) { /* shutting down */ } + catch (Exception ex) + { + _logger.LogError(ex, "Audit background worker crashed."); + } + + // Best-effort final flush on shutdown. + if (batch.Count > 0 && !stoppingToken.IsCancellationRequested) + { + try { await _sink.WriteAsync(batch, stoppingToken); } + catch (Exception ex) { _logger.LogError(ex, "Final audit flush failed."); } + } + } + + private async Task FlushAsync(List batch, CancellationToken ct) + { + try + { + await _sink.WriteAsync(batch, ct); + } + catch (Exception ex) + { + // Don't crash the worker; log and keep going. + _logger.LogError(ex, "Audit background flush failed."); + await Task.Delay(250, ct); + } + finally + { + batch.Clear(); + } + } +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Core/AuditingConfigurator.cs b/src/Modules/Auditing/Modules.Auditing/Core/AuditingConfigurator.cs new file mode 100644 index 0000000000..9340475b26 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Core/AuditingConfigurator.cs @@ -0,0 +1,30 @@ +// Add this hosted service class once in your auditing module +using FSH.Modules.Auditing.Contracts; +using Microsoft.Extensions.Hosting; + +namespace FSH.Modules.Auditing; + +public sealed class AuditingConfigurator : IHostedService +{ + private readonly IAuditPublisher _publisher; + private readonly IAuditSerializer _serializer; + private readonly IEnumerable _enrichers; + + public AuditingConfigurator( + IAuditPublisher publisher, + IAuditSerializer serializer, + IEnumerable enrichers) + { + _publisher = publisher; + _serializer = serializer; + _enrichers = enrichers; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + Audit.Configure(_publisher, _serializer, _enrichers); + return Task.CompletedTask; + } + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Core/ChannelAuditPublisher.cs b/src/Modules/Auditing/Modules.Auditing/Core/ChannelAuditPublisher.cs new file mode 100644 index 0000000000..3ab608d7f8 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Core/ChannelAuditPublisher.cs @@ -0,0 +1,90 @@ +using FSH.Modules.Auditing.Contracts; +using Microsoft.AspNetCore.Http; +using System.Threading.Channels; + +namespace FSH.Modules.Auditing; + +/// +/// Non-blocking publisher using a bounded channel. Writer is used on request path; reader is drained by a background worker. +/// +public sealed class ChannelAuditPublisher : IAuditPublisher +{ + private static readonly IAuditScope DefaultScope = new DefaultAuditScope(null, null, null, null, null, null, null, null, AuditTag.None); + private readonly Channel _channel; + private readonly IHttpContextAccessor _httpContextAccessor; + + public IAuditScope CurrentScope => + _httpContextAccessor.HttpContext?.RequestServices.GetService(typeof(IAuditScope)) as IAuditScope + ?? DefaultScope; + + public ChannelAuditPublisher(IHttpContextAccessor httpContextAccessor, int capacity = 50_000) + { + _httpContextAccessor = httpContextAccessor; + + // Drop oldest to keep latency predictable under pressure. + _channel = Channel.CreateBounded(new BoundedChannelOptions(capacity) + { + AllowSynchronousContinuations = false, + SingleReader = true, + SingleWriter = false, + FullMode = BoundedChannelFullMode.DropOldest + }); + } + + public ValueTask PublishAsync(IAuditEvent auditEvent, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(auditEvent); + + var scope = CurrentScope; + + if (auditEvent is not AuditEnvelope env) + { + // wrap into an envelope if a custom IAuditEvent was passed (rare) + env = new AuditEnvelope( + id: Guid.CreateVersion7(), + occurredAtUtc: auditEvent.OccurredAtUtc, + receivedAtUtc: DateTime.UtcNow, + eventType: auditEvent.EventType, + severity: auditEvent.Severity, + tenantId: auditEvent.TenantId, + userId: auditEvent.UserId, + userName: auditEvent.UserName, + traceId: auditEvent.TraceId, + spanId: auditEvent.SpanId, + correlationId: auditEvent.CorrelationId, + requestId: auditEvent.RequestId, + source: auditEvent.Source, + tags: auditEvent.Tags, + payload: auditEvent.Payload); + } + + // Backfill tenant/user context from the current scope if missing. + if (string.IsNullOrWhiteSpace(env.TenantId) || (string.IsNullOrWhiteSpace(env.UserId) && scope.UserId is not null)) + { + env = new AuditEnvelope( + id: env.Id, + occurredAtUtc: env.OccurredAtUtc, + receivedAtUtc: env.ReceivedAtUtc, + eventType: env.EventType, + severity: env.Severity, + tenantId: string.IsNullOrWhiteSpace(env.TenantId) ? scope.TenantId : env.TenantId, + userId: string.IsNullOrWhiteSpace(env.UserId) ? scope.UserId : env.UserId, + userName: string.IsNullOrWhiteSpace(env.UserId) && scope.UserId is not null + ? scope.UserName ?? env.UserName + : env.UserName, + traceId: env.TraceId, + spanId: env.SpanId, + correlationId: env.CorrelationId, + requestId: env.RequestId, + source: env.Source, + tags: env.Tags, + payload: env.Payload); + } + + return _channel.Writer.TryWrite(env) + ? ValueTask.CompletedTask + : ValueTask.FromCanceled(ct); // optional: swallow based on config + } + + internal ChannelReader Reader => _channel.Reader; +} diff --git a/src/Modules/Auditing/Modules.Auditing/Core/DefaultAuditClient.cs b/src/Modules/Auditing/Modules.Auditing/Core/DefaultAuditClient.cs new file mode 100644 index 0000000000..5800834b00 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Core/DefaultAuditClient.cs @@ -0,0 +1,59 @@ +using FSH.Modules.Auditing.Contracts; + +namespace FSH.Modules.Auditing; + +public sealed class DefaultAuditClient : IAuditClient +{ + public ValueTask WriteEntityChangeAsync( + string dbContext, string? schema, string table, string entityName, string key, + EntityOperation operation, IReadOnlyList changes, + string? transactionId = null, AuditSeverity severity = AuditSeverity.Information, + string? source = null, CancellationToken ct = default) + { + return Audit.ForEntityChange(dbContext, schema, table, entityName, key, operation, changes) + .WithEntityTransactionId(transactionId) + .WithSource(source) + .WithSeverity(severity) + .WriteAsync(ct); + } + + public ValueTask WriteSecurityAsync( + SecurityAction action, + string? subjectId = null, string? clientId = null, string? authMethod = null, string? reasonCode = null, + IReadOnlyDictionary? claims = null, + AuditSeverity? severity = null, string? source = null, CancellationToken ct = default) + { + return Audit.ForSecurity(action) + .WithSecurityContext(subjectId, clientId, authMethod, reasonCode, claims) + .WithSource(source) + .WithSeverity(severity ?? DefaultSeverity(action)) + .WriteAsync(ct); + + static AuditSeverity DefaultSeverity(SecurityAction a) + => a is SecurityAction.LoginFailed or SecurityAction.PermissionDenied or SecurityAction.PolicyFailed + ? AuditSeverity.Warning : AuditSeverity.Information; + } + + public ValueTask WriteActivityAsync( + ActivityKind kind, string name, int? statusCode, int durationMs, + BodyCapture captured = BodyCapture.None, int requestSize = 0, int responseSize = 0, + object? requestPreview = null, object? responsePreview = null, + AuditSeverity severity = AuditSeverity.Information, string? source = null, CancellationToken ct = default) + { + return Audit.ForActivity(kind, name) + .WithActivityResult(statusCode, durationMs, captured, requestSize, responseSize, requestPreview, responsePreview) + .WithSource(source) + .WithSeverity(severity) + .WriteAsync(ct); + } + + public ValueTask WriteExceptionAsync( + Exception ex, ExceptionArea area = ExceptionArea.None, string? routeOrLocation = null, + AuditSeverity? severity = null, string? source = null, CancellationToken ct = default) + { + return Audit.ForException(ex, area, routeOrLocation, severity) + .WithSource(source) + .WriteAsync(ct); + } +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Core/DefaultAuditScope.cs b/src/Modules/Auditing/Modules.Auditing/Core/DefaultAuditScope.cs new file mode 100644 index 0000000000..c44acbe7ae --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Core/DefaultAuditScope.cs @@ -0,0 +1,45 @@ +using FSH.Modules.Auditing.Contracts; + +namespace FSH.Modules.Auditing; + +/// +/// Immutable, minimal scope implementation. Create per request/operation. +/// +public sealed record DefaultAuditScope( + string? TenantId, + string? UserId, + string? UserName, + string? TraceId, + string? SpanId, + string? CorrelationId, + string? RequestId, + string? Source, + AuditTag Tags +) : IAuditScope +{ + public IAuditScope WithTags(AuditTag tags) => this with { Tags = this.Tags | tags }; + + public IAuditScope WithProperties( + string? tenantId = null, + string? userId = null, + string? userName = null, + string? traceId = null, + string? spanId = null, + string? correlationId = null, + string? requestId = null, + string? source = null, + AuditTag? tags = null) + => this with + { + TenantId = tenantId ?? this.TenantId, + UserId = userId ?? this.UserId, + UserName = userName ?? this.UserName, + TraceId = traceId ?? this.TraceId, + SpanId = spanId ?? this.SpanId, + CorrelationId = correlationId ?? this.CorrelationId, + RequestId = requestId ?? this.RequestId, + Source = source ?? this.Source, + Tags = tags ?? this.Tags + }; +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Core/HttpAuditScope.cs b/src/Modules/Auditing/Modules.Auditing/Core/HttpAuditScope.cs new file mode 100644 index 0000000000..ea12732673 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Core/HttpAuditScope.cs @@ -0,0 +1,36 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Auditing.Contracts; +using Microsoft.AspNetCore.Http; +using System.Diagnostics; +using System.Security.Claims; + +namespace FSH.Modules.Auditing; + +public sealed class HttpAuditScope : IAuditScope +{ + private readonly IHttpContextAccessor _http; + private readonly IMultiTenantContextAccessor _tenant; + + public HttpAuditScope(IHttpContextAccessor httpContextAccessor, IMultiTenantContextAccessor tenantAccessor) + => (_http, _tenant) = (httpContextAccessor, tenantAccessor); + + public string? TenantId => + _tenant.MultiTenantContext?.TenantInfo?.Id + ?? _http.HttpContext?.User?.FindFirstValue(MultitenancyConstants.Identifier) + ?? _http.HttpContext?.Request?.Headers[MultitenancyConstants.Identifier].FirstOrDefault() + ?? _http.HttpContext?.Items["TenantId"] as string; + public string? UserId => _http.HttpContext?.User?.FindFirstValue(ClaimTypes.NameIdentifier) ?? _http.HttpContext?.User?.FindFirstValue("sub"); + public string? UserName => _http.HttpContext?.User?.Identity?.Name ?? _http.HttpContext?.User?.FindFirstValue("name"); + public string? TraceId => Activity.Current?.TraceId.ToString(); + public string? SpanId => Activity.Current?.SpanId.ToString(); + public string? CorrelationId => _http.HttpContext?.TraceIdentifier; + public string? RequestId => _http.HttpContext?.TraceIdentifier; + public string? Source => _http.HttpContext?.GetEndpoint()?.DisplayName ?? "API"; + + public AuditTag Tags => AuditTag.None; + + public IAuditScope WithTags(AuditTag tags) => this; // immutable view + public IAuditScope WithProperties(string? tenantId = null, string? userId = null, string? userName = null, string? traceId = null, + string? spanId = null, string? correlationId = null, string? requestId = null, string? source = null, AuditTag? tags = null) => this; +} diff --git a/src/Modules/Auditing/Modules.Auditing/Core/SecurityAudit.cs b/src/Modules/Auditing/Modules.Auditing/Core/SecurityAudit.cs new file mode 100644 index 0000000000..2238ccea00 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Core/SecurityAudit.cs @@ -0,0 +1,33 @@ +using FSH.Modules.Auditing.Contracts; + +namespace FSH.Modules.Auditing; + +public sealed class SecurityAudit : ISecurityAudit +{ + private readonly IAuditClient _audit; + public SecurityAudit(IAuditClient audit) => _audit = audit; + + public ValueTask LoginSucceededAsync(string userId, string userName, string clientId, string ip, string userAgent, CancellationToken ct = default) + => _audit.WriteSecurityAsync(SecurityAction.LoginSucceeded, + subjectId: userId, clientId: clientId, authMethod: "Password", reasonCode: "", claims: new Dictionary + { ["ip"] = ip, ["userAgent"] = userAgent }, + severity: AuditSeverity.Information, source: "Identity", ct); + + public ValueTask LoginFailedAsync(string subjectIdOrName, string clientId, string reason, string ip, CancellationToken ct = default) + => _audit.WriteSecurityAsync(SecurityAction.LoginFailed, + subjectId: subjectIdOrName, clientId: clientId, authMethod: "Password", reasonCode: reason, + claims: new Dictionary { ["ip"] = ip }, + severity: AuditSeverity.Warning, source: "Identity", ct); + + public ValueTask TokenIssuedAsync(string userId, string userName, string clientId, string tokenFingerprint, DateTime expiresUtc, CancellationToken ct = default) + => _audit.WriteSecurityAsync(SecurityAction.TokenIssued, + subjectId: userId, clientId: clientId, authMethod: "Password", reasonCode: "", + claims: new Dictionary { ["fingerprint"] = tokenFingerprint, ["expiresAt"] = expiresUtc }, + severity: AuditSeverity.Information, source: "Identity", ct); + + public ValueTask TokenRevokedAsync(string userId, string clientId, string reason, CancellationToken ct = default) + => _audit.WriteSecurityAsync(SecurityAction.TokenRevoked, + subjectId: userId, clientId: clientId, authMethod: "", reasonCode: reason, claims: null, + severity: AuditSeverity.Information, source: "Identity", ct); +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditById/GetAuditByIdEndpoint.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditById/GetAuditByIdEndpoint.cs new file mode 100644 index 0000000000..eb2a66cee7 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditById/GetAuditByIdEndpoint.cs @@ -0,0 +1,25 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Auditing.Contracts.v1.GetAuditById; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Auditing.Features.v1.GetAuditById; + +public static class GetAuditByIdEndpoint +{ + public static RouteHandlerBuilder MapGetAuditByIdEndpoint(this IEndpointRouteBuilder group) + { + return group.MapGet( + "/{id:guid}", + async (Guid id, IMediator mediator, CancellationToken cancellationToken) => + await mediator.Send(new GetAuditByIdQuery(id), cancellationToken)) + .WithName("GetAuditById") + .WithSummary("Get audit event by ID") + .WithDescription("Retrieve full details for a single audit event.") + .RequirePermission(AuditingPermissionConstants.View); + } +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditById/GetAuditByIdQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditById/GetAuditByIdQueryHandler.cs new file mode 100644 index 0000000000..7385ca2685 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditById/GetAuditByIdQueryHandler.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using FSH.Modules.Auditing.Contracts; +using FSH.Modules.Auditing.Contracts.Dtos; +using FSH.Modules.Auditing.Contracts.v1.GetAuditById; +using FSH.Modules.Auditing.Persistence; +using Mediator; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Auditing.Features.v1.GetAuditById; + +public sealed class GetAuditByIdQueryHandler : IQueryHandler +{ + private readonly AuditDbContext _dbContext; + + public GetAuditByIdQueryHandler(AuditDbContext dbContext) + { + _dbContext = dbContext; + } + + public async ValueTask Handle(GetAuditByIdQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + + var record = await _dbContext.AuditRecords + .AsNoTracking() + .FirstOrDefaultAsync(a => a.Id == query.Id, cancellationToken) + .ConfigureAwait(false); + + if (record is null) + { + throw new KeyNotFoundException($"Audit record {query.Id} not found."); + } + + JsonElement payload; + try + { + using var document = JsonDocument.Parse(record.PayloadJson); + payload = document.RootElement.Clone(); + } + catch + { + payload = JsonDocument.Parse("{}").RootElement.Clone(); + } + + return new AuditDetailDto + { + Id = record.Id, + OccurredAtUtc = record.OccurredAtUtc, + ReceivedAtUtc = record.ReceivedAtUtc, + EventType = (AuditEventType)record.EventType, + Severity = (AuditSeverity)record.Severity, + TenantId = record.TenantId, + UserId = record.UserId, + UserName = record.UserName, + TraceId = record.TraceId, + SpanId = record.SpanId, + CorrelationId = record.CorrelationId, + RequestId = record.RequestId, + Source = record.Source, + Tags = (AuditTag)record.Tags, + Payload = payload + }; + } +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryEndpoint.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryEndpoint.cs new file mode 100644 index 0000000000..1ee8b52a49 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryEndpoint.cs @@ -0,0 +1,26 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Auditing.Contracts.v1.GetAuditSummary; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Auditing.Features.v1.GetAuditSummary; + +public static class GetAuditSummaryEndpoint +{ + public static RouteHandlerBuilder MapGetAuditSummaryEndpoint(this IEndpointRouteBuilder group) + { + return group.MapGet( + "/summary", + async ([AsParameters] GetAuditSummaryQuery query, IMediator mediator, CancellationToken cancellationToken) => + await mediator.Send(query, cancellationToken)) + .WithName("GetAuditSummary") + .WithSummary("Get audit summary") + .WithDescription("Retrieve aggregate counts of audit events by type, severity, source, and tenant.") + .RequirePermission(AuditingPermissionConstants.View); + } +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryHandler.cs new file mode 100644 index 0000000000..9867923559 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryHandler.cs @@ -0,0 +1,68 @@ +using FSH.Modules.Auditing.Contracts; +using FSH.Modules.Auditing.Contracts.Dtos; +using FSH.Modules.Auditing.Contracts.v1.GetAuditSummary; +using FSH.Modules.Auditing.Persistence; +using Mediator; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Auditing.Features.v1.GetAuditSummary; + +public sealed class GetAuditSummaryQueryHandler : IQueryHandler +{ + private readonly AuditDbContext _dbContext; + + public GetAuditSummaryQueryHandler(AuditDbContext dbContext) + { + _dbContext = dbContext; + } + + public async ValueTask Handle(GetAuditSummaryQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + + IQueryable audits = _dbContext.AuditRecords.AsNoTracking(); + + if (query.FromUtc.HasValue) + { + audits = audits.Where(a => a.OccurredAtUtc >= query.FromUtc.Value); + } + + if (query.ToUtc.HasValue) + { + audits = audits.Where(a => a.OccurredAtUtc <= query.ToUtc.Value); + } + + if (!string.IsNullOrWhiteSpace(query.TenantId)) + { + audits = audits.Where(a => a.TenantId == query.TenantId); + } + + var list = await audits.ToListAsync(cancellationToken).ConfigureAwait(false); + + var aggregate = new AuditSummaryAggregateDto(); + + foreach (var record in list) + { + var type = (AuditEventType)record.EventType; + aggregate.EventsByType[type] = aggregate.EventsByType.TryGetValue(type, out var c) ? c + 1 : 1; + + var severity = (AuditSeverity)record.Severity; + aggregate.EventsBySeverity[severity] = aggregate.EventsBySeverity.TryGetValue(severity, out var s) ? s + 1 : 1; + + if (!string.IsNullOrWhiteSpace(record.Source)) + { + var key = record.Source!; + aggregate.EventsBySource[key] = aggregate.EventsBySource.TryGetValue(key, out var cs) ? cs + 1 : 1; + } + + if (!string.IsNullOrWhiteSpace(record.TenantId)) + { + var tenantKey = record.TenantId!; + aggregate.EventsByTenant[tenantKey] = aggregate.EventsByTenant.TryGetValue(tenantKey, out var ct) ? ct + 1 : 1; + } + } + + return aggregate; + } +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryValidator.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryValidator.cs new file mode 100644 index 0000000000..edfdadbc1d --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; +using FSH.Modules.Auditing.Contracts.v1.GetAuditSummary; + +namespace FSH.Modules.Auditing.Features.v1.GetAuditSummary; + +public sealed class GetAuditSummaryQueryValidator : AbstractValidator +{ + public GetAuditSummaryQueryValidator() + { + 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."); + } +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsEndpoint.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsEndpoint.cs new file mode 100644 index 0000000000..d1334e79eb --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsEndpoint.cs @@ -0,0 +1,26 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Auditing.Contracts.v1.GetAudits; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Auditing.Features.v1.GetAudits; + +public static class GetAuditsEndpoint +{ + public static RouteHandlerBuilder MapGetAuditsEndpoint(this IEndpointRouteBuilder group) + { + return group.MapGet( + "/", + async ([AsParameters] GetAuditsQuery query, IMediator mediator, CancellationToken cancellationToken) => + await mediator.Send(query, cancellationToken)) + .WithName("GetAudits") + .WithSummary("List and search audit events") + .WithDescription("Retrieve audit events with pagination and filters.") + .RequirePermission(AuditingPermissionConstants.View); + } +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryHandler.cs new file mode 100644 index 0000000000..2941973b10 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryHandler.cs @@ -0,0 +1,108 @@ +using FSH.Framework.Persistence; +using FSH.Framework.Shared.Persistence; +using FSH.Modules.Auditing.Contracts; +using FSH.Modules.Auditing.Contracts.Dtos; +using FSH.Modules.Auditing.Contracts.v1.GetAudits; +using FSH.Modules.Auditing.Persistence; +using Mediator; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Auditing.Features.v1.GetAudits; + +public sealed class GetAuditsQueryHandler : IQueryHandler> +{ + private readonly AuditDbContext _dbContext; + + public GetAuditsQueryHandler(AuditDbContext dbContext) + { + _dbContext = dbContext; + } + + public async ValueTask> Handle(GetAuditsQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + + IQueryable audits = _dbContext.AuditRecords.AsNoTracking(); + + if (query.FromUtc.HasValue) + { + audits = audits.Where(a => a.OccurredAtUtc >= query.FromUtc.Value); + } + + if (query.ToUtc.HasValue) + { + audits = audits.Where(a => a.OccurredAtUtc <= query.ToUtc.Value); + } + + if (!string.IsNullOrWhiteSpace(query.TenantId)) + { + audits = audits.Where(a => a.TenantId == query.TenantId); + } + + if (!string.IsNullOrWhiteSpace(query.UserId)) + { + audits = audits.Where(a => a.UserId == query.UserId); + } + + if (query.EventType.HasValue) + { + audits = audits.Where(a => a.EventType == (int)query.EventType.Value); + } + + if (query.Severity.HasValue) + { + audits = audits.Where(a => a.Severity == (byte)query.Severity.Value); + } + + if (query.Tags.HasValue && query.Tags.Value != AuditTag.None) + { + long tagMask = (long)query.Tags.Value; + audits = audits.Where(a => (a.Tags & tagMask) != 0); + } + + if (!string.IsNullOrWhiteSpace(query.Source)) + { + audits = audits.Where(a => a.Source == query.Source); + } + + if (!string.IsNullOrWhiteSpace(query.CorrelationId)) + { + audits = audits.Where(a => a.CorrelationId == query.CorrelationId); + } + + if (!string.IsNullOrWhiteSpace(query.TraceId)) + { + audits = audits.Where(a => a.TraceId == query.TraceId); + } + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + string term = query.Search; + audits = audits.Where(a => + (a.PayloadJson != null && EF.Functions.ILike(a.PayloadJson, $"%{term}%")) || + (a.Source != null && EF.Functions.ILike(a.Source, $"%{term}%")) || + (a.UserName != null && EF.Functions.ILike(a.UserName, $"%{term}%"))); + } + + audits = audits.OrderByDescending(a => a.OccurredAtUtc); + + IQueryable projected = audits.Select(a => new AuditSummaryDto + { + Id = a.Id, + OccurredAtUtc = a.OccurredAtUtc, + EventType = (AuditEventType)a.EventType, + Severity = (AuditSeverity)a.Severity, + TenantId = a.TenantId, + UserId = a.UserId, + UserName = a.UserName, + TraceId = a.TraceId, + CorrelationId = a.CorrelationId, + RequestId = a.RequestId, + Source = a.Source, + Tags = (AuditTag)a.Tags + }); + + return await projected.ToPagedResponseAsync(query, cancellationToken).ConfigureAwait(false); + } +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryValidator.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryValidator.cs new file mode 100644 index 0000000000..2d0b13fdb7 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using FSH.Modules.Auditing.Contracts.v1.GetAudits; + +namespace FSH.Modules.Auditing.Features.v1.GetAudits; + +public sealed class GetAuditsQueryValidator : AbstractValidator +{ + public GetAuditsQueryValidator() + { + RuleFor(q => q.PageNumber) + .GreaterThan(0) + .When(q => q.PageNumber.HasValue); + + RuleFor(q => q.PageSize) + .InclusiveBetween(1, 100) + .When(q => q.PageSize.HasValue); + + 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."); + } +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByCorrelation/GetAuditsByCorrelationEndpoint.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByCorrelation/GetAuditsByCorrelationEndpoint.cs new file mode 100644 index 0000000000..6a7ffc7171 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByCorrelation/GetAuditsByCorrelationEndpoint.cs @@ -0,0 +1,30 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Auditing.Contracts.v1.GetAuditsByCorrelation; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Auditing.Features.v1.GetAuditsByCorrelation; + +public static class GetAuditsByCorrelationEndpoint +{ + public static RouteHandlerBuilder MapGetAuditsByCorrelationEndpoint(this IEndpointRouteBuilder group) + { + return group.MapGet( + "/by-correlation/{correlationId}", + async (string correlationId, DateTime? fromUtc, DateTime? toUtc, IMediator mediator, CancellationToken cancellationToken) => + await mediator.Send(new GetAuditsByCorrelationQuery + { + CorrelationId = correlationId, + FromUtc = fromUtc, + ToUtc = toUtc + }, cancellationToken)) + .WithName("GetAuditsByCorrelation") + .WithSummary("Get audit events by correlation id") + .WithDescription("Retrieve audit events associated with a given correlation id.") + .RequirePermission(AuditingPermissionConstants.View); + } +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByCorrelation/GetAuditsByCorrelationQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByCorrelation/GetAuditsByCorrelationQueryHandler.cs new file mode 100644 index 0000000000..64bf5af0b2 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByCorrelation/GetAuditsByCorrelationQueryHandler.cs @@ -0,0 +1,60 @@ +using FSH.Modules.Auditing.Contracts; +using FSH.Modules.Auditing.Contracts.Dtos; +using FSH.Modules.Auditing.Contracts.v1.GetAuditsByCorrelation; +using FSH.Modules.Auditing.Persistence; +using Mediator; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Auditing.Features.v1.GetAuditsByCorrelation; + +public sealed class GetAuditsByCorrelationQueryHandler : IQueryHandler> +{ + private readonly AuditDbContext _dbContext; + + public GetAuditsByCorrelationQueryHandler(AuditDbContext dbContext) + { + _dbContext = dbContext; + } + + public async ValueTask> Handle(GetAuditsByCorrelationQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + + IQueryable audits = _dbContext.AuditRecords + .AsNoTracking() + .Where(a => a.CorrelationId == query.CorrelationId); + + if (query.FromUtc.HasValue) + { + audits = audits.Where(a => a.OccurredAtUtc >= query.FromUtc.Value); + } + + if (query.ToUtc.HasValue) + { + audits = audits.Where(a => a.OccurredAtUtc <= query.ToUtc.Value); + } + + var list = await audits + .OrderBy(a => a.OccurredAtUtc) + .Select(a => new AuditSummaryDto + { + Id = a.Id, + OccurredAtUtc = a.OccurredAtUtc, + EventType = (AuditEventType)a.EventType, + Severity = (AuditSeverity)a.Severity, + TenantId = a.TenantId, + UserId = a.UserId, + UserName = a.UserName, + TraceId = a.TraceId, + CorrelationId = a.CorrelationId, + RequestId = a.RequestId, + Source = a.Source, + Tags = (AuditTag)a.Tags + }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return list; + } +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByCorrelation/GetAuditsByCorrelationQueryValidator.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByCorrelation/GetAuditsByCorrelationQueryValidator.cs new file mode 100644 index 0000000000..bbf68e4b36 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByCorrelation/GetAuditsByCorrelationQueryValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; +using FSH.Modules.Auditing.Contracts.v1.GetAuditsByCorrelation; + +namespace FSH.Modules.Auditing.Features.v1.GetAuditsByCorrelation; + +public sealed class GetAuditsByCorrelationQueryValidator : AbstractValidator +{ + public GetAuditsByCorrelationQueryValidator() + { + 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."); + } +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByTrace/GetAuditsByTraceEndpoint.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByTrace/GetAuditsByTraceEndpoint.cs new file mode 100644 index 0000000000..52048c6181 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByTrace/GetAuditsByTraceEndpoint.cs @@ -0,0 +1,30 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Auditing.Contracts.v1.GetAuditsByTrace; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Auditing.Features.v1.GetAuditsByTrace; + +public static class GetAuditsByTraceEndpoint +{ + public static RouteHandlerBuilder MapGetAuditsByTraceEndpoint(this IEndpointRouteBuilder group) + { + return group.MapGet( + "/by-trace/{traceId}", + async (string traceId, DateTime? fromUtc, DateTime? toUtc, IMediator mediator, CancellationToken cancellationToken) => + await mediator.Send(new GetAuditsByTraceQuery + { + TraceId = traceId, + FromUtc = fromUtc, + ToUtc = toUtc + }, cancellationToken)) + .WithName("GetAuditsByTrace") + .WithSummary("Get audit events by trace id") + .WithDescription("Retrieve audit events associated with a given trace id.") + .RequirePermission(AuditingPermissionConstants.View); + } +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByTrace/GetAuditsByTraceQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByTrace/GetAuditsByTraceQueryHandler.cs new file mode 100644 index 0000000000..a4b74b2927 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByTrace/GetAuditsByTraceQueryHandler.cs @@ -0,0 +1,60 @@ +using FSH.Modules.Auditing.Contracts; +using FSH.Modules.Auditing.Contracts.Dtos; +using FSH.Modules.Auditing.Contracts.v1.GetAuditsByTrace; +using FSH.Modules.Auditing.Persistence; +using Mediator; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Auditing.Features.v1.GetAuditsByTrace; + +public sealed class GetAuditsByTraceQueryHandler : IQueryHandler> +{ + private readonly AuditDbContext _dbContext; + + public GetAuditsByTraceQueryHandler(AuditDbContext dbContext) + { + _dbContext = dbContext; + } + + public async ValueTask> Handle(GetAuditsByTraceQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + + IQueryable audits = _dbContext.AuditRecords + .AsNoTracking() + .Where(a => a.TraceId == query.TraceId); + + if (query.FromUtc.HasValue) + { + audits = audits.Where(a => a.OccurredAtUtc >= query.FromUtc.Value); + } + + if (query.ToUtc.HasValue) + { + audits = audits.Where(a => a.OccurredAtUtc <= query.ToUtc.Value); + } + + var list = await audits + .OrderBy(a => a.OccurredAtUtc) + .Select(a => new AuditSummaryDto + { + Id = a.Id, + OccurredAtUtc = a.OccurredAtUtc, + EventType = (AuditEventType)a.EventType, + Severity = (AuditSeverity)a.Severity, + TenantId = a.TenantId, + UserId = a.UserId, + UserName = a.UserName, + TraceId = a.TraceId, + CorrelationId = a.CorrelationId, + RequestId = a.RequestId, + Source = a.Source, + Tags = (AuditTag)a.Tags + }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return list; + } +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByTrace/GetAuditsByTraceQueryValidator.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByTrace/GetAuditsByTraceQueryValidator.cs new file mode 100644 index 0000000000..35fa199c4c --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByTrace/GetAuditsByTraceQueryValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; +using FSH.Modules.Auditing.Contracts.v1.GetAuditsByTrace; + +namespace FSH.Modules.Auditing.Features.v1.GetAuditsByTrace; + +public sealed class GetAuditsByTraceQueryValidator : AbstractValidator +{ + public GetAuditsByTraceQueryValidator() + { + 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."); + } +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsEndpoint.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsEndpoint.cs new file mode 100644 index 0000000000..b6b5662d82 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsEndpoint.cs @@ -0,0 +1,26 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Auditing.Contracts.v1.GetExceptionAudits; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Auditing.Features.v1.GetExceptionAudits; + +public static class GetExceptionAuditsEndpoint +{ + public static RouteHandlerBuilder MapGetExceptionAuditsEndpoint(this IEndpointRouteBuilder group) + { + return group.MapGet( + "/exceptions", + async ([AsParameters] GetExceptionAuditsQuery query, IMediator mediator, CancellationToken cancellationToken) => + await mediator.Send(query, cancellationToken)) + .WithName("GetExceptionAudits") + .WithSummary("Get exception audit events") + .WithDescription("Retrieve audit events related to exceptions.") + .RequirePermission(AuditingPermissionConstants.View); + } +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryHandler.cs new file mode 100644 index 0000000000..ddd2d9161f --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryHandler.cs @@ -0,0 +1,84 @@ +using FSH.Modules.Auditing.Contracts; +using FSH.Modules.Auditing.Contracts.Dtos; +using FSH.Modules.Auditing.Contracts.v1.GetExceptionAudits; +using FSH.Modules.Auditing.Persistence; +using Mediator; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Auditing.Features.v1.GetExceptionAudits; + +public sealed class GetExceptionAuditsQueryHandler : IQueryHandler> +{ + private readonly AuditDbContext _dbContext; + + public GetExceptionAuditsQueryHandler(AuditDbContext dbContext) + { + _dbContext = dbContext; + } + + public async ValueTask> Handle(GetExceptionAuditsQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + + IQueryable audits = _dbContext.AuditRecords + .AsNoTracking() + .Where(a => a.EventType == (int)AuditEventType.Exception); + + if (query.FromUtc.HasValue) + { + audits = audits.Where(a => a.OccurredAtUtc >= query.FromUtc.Value); + } + + if (query.ToUtc.HasValue) + { + audits = audits.Where(a => a.OccurredAtUtc <= query.ToUtc.Value); + } + + if (query.Severity.HasValue) + { + audits = audits.Where(a => a.Severity == (byte)query.Severity.Value); + } + + if (query.Area.HasValue && query.Area.Value != ExceptionArea.None) + { + string areaValue = query.Area.Value.ToString(); + audits = audits.Where(a => a.PayloadJson != null && + EF.Functions.ILike(a.PayloadJson, $"%\"area\":\"{areaValue}\"%")); + } + + if (!string.IsNullOrWhiteSpace(query.ExceptionType)) + { + audits = audits.Where(a => a.PayloadJson != null && + EF.Functions.ILike(a.PayloadJson, $"%\"exceptionType\":\"{query.ExceptionType}%")); + } + + if (!string.IsNullOrWhiteSpace(query.RouteOrLocation)) + { + audits = audits.Where(a => a.PayloadJson != null && + EF.Functions.ILike(a.PayloadJson, $"%\"routeOrLocation\":\"{query.RouteOrLocation}%")); + } + + var list = await audits + .OrderByDescending(a => a.OccurredAtUtc) + .Select(a => new AuditSummaryDto + { + Id = a.Id, + OccurredAtUtc = a.OccurredAtUtc, + EventType = (AuditEventType)a.EventType, + Severity = (AuditSeverity)a.Severity, + TenantId = a.TenantId, + UserId = a.UserId, + UserName = a.UserName, + TraceId = a.TraceId, + CorrelationId = a.CorrelationId, + RequestId = a.RequestId, + Source = a.Source, + Tags = (AuditTag)a.Tags + }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return list; + } +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryValidator.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryValidator.cs new file mode 100644 index 0000000000..7f98302669 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; +using FSH.Modules.Auditing.Contracts.v1.GetExceptionAudits; + +namespace FSH.Modules.Auditing.Features.v1.GetExceptionAudits; + +public sealed class GetExceptionAuditsQueryValidator : AbstractValidator +{ + public GetExceptionAuditsQueryValidator() + { + 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."); + } +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsEndpoint.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsEndpoint.cs new file mode 100644 index 0000000000..230128f0fe --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsEndpoint.cs @@ -0,0 +1,26 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Auditing.Contracts.v1.GetSecurityAudits; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Auditing.Features.v1.GetSecurityAudits; + +public static class GetSecurityAuditsEndpoint +{ + public static RouteHandlerBuilder MapGetSecurityAuditsEndpoint(this IEndpointRouteBuilder group) + { + return group.MapGet( + "/security", + async ([AsParameters] GetSecurityAuditsQuery query, IMediator mediator, CancellationToken cancellationToken) => + await mediator.Send(query, cancellationToken)) + .WithName("GetSecurityAudits") + .WithSummary("Get security-related audit events") + .WithDescription("Retrieve security audit events such as login, logout, and permission denials.") + .RequirePermission(AuditingPermissionConstants.View); + } +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryHandler.cs new file mode 100644 index 0000000000..5ee851a3a6 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryHandler.cs @@ -0,0 +1,77 @@ +using FSH.Modules.Auditing.Contracts; +using FSH.Modules.Auditing.Contracts.Dtos; +using FSH.Modules.Auditing.Contracts.v1.GetSecurityAudits; +using FSH.Modules.Auditing.Persistence; +using Mediator; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Auditing.Features.v1.GetSecurityAudits; + +public sealed class GetSecurityAuditsQueryHandler : IQueryHandler> +{ + private readonly AuditDbContext _dbContext; + + public GetSecurityAuditsQueryHandler(AuditDbContext dbContext) + { + _dbContext = dbContext; + } + + public async ValueTask> Handle(GetSecurityAuditsQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + + IQueryable audits = _dbContext.AuditRecords + .AsNoTracking() + .Where(a => a.EventType == (int)AuditEventType.Security); + + if (query.FromUtc.HasValue) + { + audits = audits.Where(a => a.OccurredAtUtc >= query.FromUtc.Value); + } + + if (query.ToUtc.HasValue) + { + audits = audits.Where(a => a.OccurredAtUtc <= query.ToUtc.Value); + } + + if (!string.IsNullOrWhiteSpace(query.UserId)) + { + audits = audits.Where(a => a.UserId == query.UserId); + } + + if (!string.IsNullOrWhiteSpace(query.TenantId)) + { + audits = audits.Where(a => a.TenantId == query.TenantId); + } + + if (query.Action.HasValue && query.Action.Value != SecurityAction.None) + { + string actionValue = query.Action.Value.ToString(); + audits = audits.Where(a => a.PayloadJson != null && + EF.Functions.ILike(a.PayloadJson, $"%\"action\":\"{actionValue}\"%")); + } + + var list = await audits + .OrderByDescending(a => a.OccurredAtUtc) + .Select(a => new AuditSummaryDto + { + Id = a.Id, + OccurredAtUtc = a.OccurredAtUtc, + EventType = (AuditEventType)a.EventType, + Severity = (AuditSeverity)a.Severity, + TenantId = a.TenantId, + UserId = a.UserId, + UserName = a.UserName, + TraceId = a.TraceId, + CorrelationId = a.CorrelationId, + RequestId = a.RequestId, + Source = a.Source, + Tags = (AuditTag)a.Tags + }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return list; + } +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryValidator.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryValidator.cs new file mode 100644 index 0000000000..e8e6d5a0f0 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; +using FSH.Modules.Auditing.Contracts.v1.GetSecurityAudits; + +namespace FSH.Modules.Auditing.Features.v1.GetSecurityAudits; + +public sealed class GetSecurityAuditsQueryValidator : AbstractValidator +{ + public GetSecurityAuditsQueryValidator() + { + 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."); + } +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Infrastructure/Hosting/ServiceCollectionExtensions.cs b/src/Modules/Auditing/Modules.Auditing/Infrastructure/Hosting/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..2404875f61 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Infrastructure/Hosting/ServiceCollectionExtensions.cs @@ -0,0 +1,46 @@ +using FSH.Framework.Persistence; +using FSH.Modules.Auditing.Contracts; +using FSH.Modules.Auditing.Persistence; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace FSH.Modules.Auditing; + +public static class ServiceCollectionExtensions +{ + /// + /// Registers auditing core: Channel publisher, background worker, serializer, scope, and HTTP options. + /// + public static IServiceCollection AddAuditingCore(this IServiceCollection services, IConfiguration config, Action? configure = null) + { + services.AddHttpContextAccessor(); + services.AddScoped(); + services.AddScoped(); + services.AddHeroDbContext(); + services.AddSingleton(); + + // Request-scoped scope reader (HttpContext-backed) + services.AddScoped(); + + // Publisher/sink/worker wiring: publisher is singleton and resolves current scope from HttpContext. + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + + services.AddHostedService(); + services.AddSingleton(); + + var opts = new AuditHttpOptions(); + configure?.Invoke(opts); + services.AddSingleton(opts); + + return services; + } + + /// + /// Adds the HTTP auditing middleware to the pipeline. + /// Place early (after routing) but before endpoints. + /// + public static IApplicationBuilder UseAuditHttp(this IApplicationBuilder app) + => app.UseMiddleware(); +} diff --git a/src/Modules/Auditing/Modules.Auditing/Infrastructure/Http/AuditHttpMiddleware.cs b/src/Modules/Auditing/Modules.Auditing/Infrastructure/Http/AuditHttpMiddleware.cs new file mode 100644 index 0000000000..570ae4c151 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Infrastructure/Http/AuditHttpMiddleware.cs @@ -0,0 +1,118 @@ +// Modules.Auditing/AuditHttpMiddleware.cs +using FSH.Modules.Auditing.Contracts; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using System.Diagnostics; + +namespace FSH.Modules.Auditing; + +public sealed class AuditHttpMiddleware +{ + private readonly RequestDelegate _next; + private readonly AuditHttpOptions _opts; + private readonly IAuditPublisher _publisher; + + public AuditHttpMiddleware(RequestDelegate next, AuditHttpOptions opts, IAuditPublisher publisher) + => (_next, _opts, _publisher) = (next, opts, publisher); + + public async Task InvokeAsync(HttpContext ctx) + { + ArgumentNullException.ThrowIfNull(ctx); + + if (ShouldSkip(ctx)) + { + await _next(ctx); + return; + } + + var masker = ctx.RequestServices.GetService(); + var sw = Stopwatch.StartNew(); + + object? reqPreview = null; + int reqSize = 0; + if (_opts.CaptureBodies && + ContentTypeHelper.IsJsonLike(ctx.Request.ContentType, _opts.AllowedContentTypes)) + { + (reqPreview, reqSize) = await HttpBodyReader.ReadRequestAsync(ctx, _opts.MaxRequestBytes, ctx.RequestAborted); + if (reqPreview is not null && masker is not null) + { + reqPreview = masker.ApplyMasking(reqPreview); + } + } + + var originalBody = ctx.Response.Body; + await using var tee = new MemoryStream(); + await using var respBuffer = new MemoryStream(); + ctx.Response.Body = tee; + + try + { + await _next(ctx); + sw.Stop(); + + object? respPreview = null; + int respSize = 0; + + if (_opts.CaptureBodies && + ContentTypeHelper.IsJsonLike(ctx.Response.ContentType, _opts.AllowedContentTypes)) + { + tee.Position = 0; + await tee.CopyToAsync(respBuffer, ctx.RequestAborted); + (respPreview, respSize) = await HttpBodyReader.ReadResponseAsync( + respBuffer, _opts.MaxResponseBytes, ctx.RequestAborted); + if (respPreview is not null && masker is not null) + { + respPreview = masker.ApplyMasking(respPreview); + } + } + + respBuffer.Position = 0; + ctx.Response.Body = originalBody; + if (respBuffer.Length > 0) + await respBuffer.CopyToAsync(originalBody, ctx.RequestAborted); + + await Audit.ForActivity(Contracts.ActivityKind.Http, ctx.Request.Path) + .WithActivityResult( + statusCode: ctx.Response.StatusCode, + durationMs: (int)sw.Elapsed.TotalMilliseconds, + captured: _opts.CaptureBodies ? BodyCapture.Both : BodyCapture.None, + requestSize: reqSize, + responseSize: respSize, + requestPreview: reqPreview, + responsePreview: respPreview) + .WithSource("api") + .WithTenant((_publisher.CurrentScope?.TenantId) ?? null) + .WithUser(_publisher.CurrentScope?.UserId, _publisher.CurrentScope?.UserName) + .WithCorrelation(_publisher.CurrentScope?.CorrelationId ?? ctx.TraceIdentifier) + .WithRequestId(_publisher.CurrentScope?.RequestId ?? ctx.TraceIdentifier) + .WriteAsync(ctx.RequestAborted); + } + catch (Exception ex) + { + sw.Stop(); + + var sev = ExceptionSeverityClassifier.Classify(ex); + if (sev >= _opts.MinExceptionSeverity) + { + await Audit.ForException(ex, ExceptionArea.Api, + routeOrLocation: ctx.Request.Path, severity: sev) + .WithSource("api") + .WithTenant((_publisher.CurrentScope?.TenantId) ?? null) + .WithUser(_publisher.CurrentScope?.UserId, _publisher.CurrentScope?.UserName) + .WithCorrelation(_publisher.CurrentScope?.CorrelationId ?? ctx.TraceIdentifier) + .WithRequestId(_publisher.CurrentScope?.RequestId ?? ctx.TraceIdentifier) + .WriteAsync(ctx.RequestAborted); + } + + ctx.Response.Body = originalBody; + throw; + } + } + + private bool ShouldSkip(HttpContext ctx) + { + var path = ctx.Request.Path.Value ?? string.Empty; + return _opts.ExcludePathStartsWith.Any(prefix => + path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/src/Modules/Auditing/Modules.Auditing/Infrastructure/Http/ContentTypeHelper.cs b/src/Modules/Auditing/Modules.Auditing/Infrastructure/Http/ContentTypeHelper.cs new file mode 100644 index 0000000000..13ee481770 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Infrastructure/Http/ContentTypeHelper.cs @@ -0,0 +1,20 @@ +using Microsoft.Net.Http.Headers; + +namespace FSH.Modules.Auditing; + +internal static class ContentTypeHelper +{ + public static bool IsJsonLike(string? contentType, ISet allowed) + { + if (string.IsNullOrWhiteSpace(contentType)) return false; + + // Prefer robust parse; fallback to naive split if needed. + if (MediaTypeHeaderValue.TryParse(contentType, out var mt)) + return allowed.Contains(mt.MediaType.Value ?? string.Empty); + + var semi = contentType.IndexOf(';', StringComparison.Ordinal); + var type = semi >= 0 ? contentType[..semi] : contentType; + return allowed.Contains(type.Trim()); + } +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Infrastructure/Http/HttpBodyReader.cs b/src/Modules/Auditing/Modules.Auditing/Infrastructure/Http/HttpBodyReader.cs new file mode 100644 index 0000000000..a3a16dbaea --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Infrastructure/Http/HttpBodyReader.cs @@ -0,0 +1,97 @@ +using Microsoft.AspNetCore.Http; +using System.Text; +using System.Text.Json; + +namespace FSH.Modules.Auditing; + +internal static class HttpBodyReader +{ + public static async Task<(object? preview, int size)> ReadRequestAsync(HttpContext ctx, int maxBytes, CancellationToken ct) + { + if (ctx.Request.Body is null || ctx.Request.ContentLength == 0) return (null, 0); + + ctx.Request.EnableBuffering(); + using var ms = new MemoryStream(); + var copied = await CopyCappedAsync(ctx.Request.Body, ms, maxBytes, ct); + ctx.Request.Body.Position = 0; + + return DeserializePreview(ms, copied); + } + + public static async Task<(object? preview, int size)> ReadResponseAsync(Stream source, int maxBytes, CancellationToken ct) + { + // source is the response-body tee stream we control + if (source.Length == 0) return (null, 0); + source.Position = 0; + + using var ms = new MemoryStream(); + var copied = await CopyCappedAsync(source, ms, maxBytes, ct); + return DeserializePreview(ms, copied); + } + + private static async Task CopyCappedAsync(Stream src, Stream dst, int maxBytes, CancellationToken ct) + { + var buf = new byte[8 * 1024]; + int total = 0, read; + while ((read = await src.ReadAsync(buf, ct)) > 0) + { + var toWrite = Math.Min(read, Math.Max(0, maxBytes - total)); + if (toWrite > 0) await dst.WriteAsync(buf.AsMemory(0, toWrite), ct); + total += read; + if (total >= maxBytes) break; + } + return total; + } + + private static (object? preview, int size) DeserializePreview(MemoryStream ms, int totalBytes) + { + try + { + ms.Position = 0; + using var doc = JsonDocument.Parse(ms.ToArray()); + return (ToPlain(doc.RootElement), totalBytes); + } + catch + { + // not JSON; return UTF8 snippet + ms.Position = 0; + var text = Encoding.UTF8.GetString(ms.ToArray()); + var snippet = text.Length > 2000 ? text[..2000] + ".(truncated)" : text; + return (new { text = snippet }, totalBytes); + } + } + + private static object? ToPlain(JsonElement e) + { + return e.ValueKind switch + { + JsonValueKind.Object => e.EnumerateObject() + .ToDictionary(p => p.Name, p => ToPlain(p.Value)), + + JsonValueKind.Array => e.EnumerateArray() + .Select(ToPlain) + .ToList(), + + JsonValueKind.String => e.GetString(), + + JsonValueKind.Number => GetNumericValue(e), + + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => null + }; + } + + private static object GetNumericValue(JsonElement e) + { + if (e.TryGetInt64(out var longValue)) + return longValue; + + if (e.TryGetDouble(out var doubleValue)) + return doubleValue; + + return e.GetRawText(); + } + +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Infrastructure/Http/HttpContextRoutingExtensions.cs b/src/Modules/Auditing/Modules.Auditing/Infrastructure/Http/HttpContextRoutingExtensions.cs new file mode 100644 index 0000000000..2c82ac11a7 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Infrastructure/Http/HttpContextRoutingExtensions.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Auditing; + +internal static class HttpContextRoutingExtensions +{ + public static string? GetRoutePattern(this HttpContext ctx) + => ctx.GetEndpoint() switch + { + RouteEndpoint re => re.RoutePattern.RawText, + _ => null + }; +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Infrastructure/Serialization/JsonMaskingService.cs b/src/Modules/Auditing/Modules.Auditing/Infrastructure/Serialization/JsonMaskingService.cs new file mode 100644 index 0000000000..014525f1b2 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Infrastructure/Serialization/JsonMaskingService.cs @@ -0,0 +1,58 @@ +using FSH.Modules.Auditing.Contracts; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace FSH.Modules.Auditing; + +/// +/// Simple masking by field-name convention or attributes. +/// +public sealed class JsonMaskingService : IAuditMaskingService +{ + private static readonly HashSet _maskKeywords = new(StringComparer.OrdinalIgnoreCase) + { + "password", "secret", "token", "otp", "pin", "accessToken", "refreshToken" + }; + + public object ApplyMasking(object payload) + { + try + { + var json = JsonSerializer.SerializeToNode(payload); + if (json is null) return payload; + MaskNode(json); + return json; + } + catch + { + return payload; // safe fallback + } + } + + private static void MaskNode(JsonNode node) + { + if (node is JsonObject obj) + { + foreach (var kvp in obj.ToList()) + { + if (ShouldMask(kvp.Key)) + { + obj[kvp.Key] = "****"; + } + else if (kvp.Value is not null) + { + MaskNode(kvp.Value); + } + } + } + else if (node is JsonArray arr) + { + foreach (var el in arr) + if (el is not null) MaskNode(el); + } + } + + private static bool ShouldMask(string key) + => _maskKeywords.Any(k => key.Contains(k, StringComparison.OrdinalIgnoreCase)); +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Infrastructure/Serialization/SystemTextJsonAuditSerializer.cs b/src/Modules/Auditing/Modules.Auditing/Infrastructure/Serialization/SystemTextJsonAuditSerializer.cs new file mode 100644 index 0000000000..a301fb3322 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Infrastructure/Serialization/SystemTextJsonAuditSerializer.cs @@ -0,0 +1,19 @@ +using FSH.Modules.Auditing.Contracts; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace FSH.Modules.Auditing; + +public sealed class SystemTextJsonAuditSerializer : IAuditSerializer +{ + private static readonly JsonSerializerOptions Opts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() }, + WriteIndented = false + }; + + public string SerializePayload(object payload) => JsonSerializer.Serialize(payload, Opts); +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Modules.Auditing.csproj b/src/Modules/Auditing/Modules.Auditing/Modules.Auditing.csproj new file mode 100644 index 0000000000..21deaec0a8 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Modules.Auditing.csproj @@ -0,0 +1,15 @@ + + + + FSH.Modules.Auditing + FSH.Modules.Auditing + FullStackHero.Modules.Auditing + $(NoWarn);CA1031;CA1812;CA1859;S3267 + + + + + + + + diff --git a/src/Modules/Auditing/Modules.Auditing/Persistence/AuditDbContext.cs b/src/Modules/Auditing/Modules.Auditing/Persistence/AuditDbContext.cs new file mode 100644 index 0000000000..69a28bc2b2 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Persistence/AuditDbContext.cs @@ -0,0 +1,25 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Persistence.Context; +using FSH.Framework.Shared.Multitenancy; +using FSH.Framework.Shared.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace FSH.Modules.Auditing.Persistence; + +public sealed class AuditDbContext : BaseDbContext +{ + public AuditDbContext( + IMultiTenantContextAccessor multiTenantContextAccessor, + DbContextOptions options, + IOptions settings, + IHostEnvironment environment) : base(multiTenantContextAccessor, options, settings, environment) { } + + public DbSet AuditRecords => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ArgumentNullException.ThrowIfNull(modelBuilder); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AuditDbContext).Assembly); + } +} diff --git a/src/Modules/Auditing/Modules.Auditing/Persistence/AuditDbInitializer.cs b/src/Modules/Auditing/Modules.Auditing/Persistence/AuditDbInitializer.cs new file mode 100644 index 0000000000..357366f1cb --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Persistence/AuditDbInitializer.cs @@ -0,0 +1,24 @@ +using FSH.Framework.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace FSH.Modules.Auditing.Persistence; + +internal sealed class AuditDbInitializer( + ILogger logger, + AuditDbContext context) : IDbInitializer +{ + public async Task MigrateAsync(CancellationToken cancellationToken) + { + if ((await context.Database.GetPendingMigrationsAsync(cancellationToken).ConfigureAwait(false)).Any()) + { + await context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false); + logger.LogInformation("[{Tenant}] applied database migrations for audit module", context.TenantInfo?.Identifier); + } + } + + public Task SeedAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Modules/Auditing/Modules.Auditing/Persistence/AuditRecord.cs b/src/Modules/Auditing/Modules.Auditing/Persistence/AuditRecord.cs new file mode 100644 index 0000000000..deeff2b162 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Persistence/AuditRecord.cs @@ -0,0 +1,25 @@ +namespace FSH.Modules.Auditing; + +public sealed class AuditRecord +{ + public Guid Id { get; set; } + public DateTime OccurredAtUtc { get; set; } + public DateTime ReceivedAtUtc { get; set; } + + public int EventType { get; set; } + public byte Severity { get; set; } + + public string? TenantId { get; set; } + public string? UserId { get; set; } + public string? UserName { get; set; } + public string? TraceId { get; set; } + public string? SpanId { get; set; } + public string? CorrelationId { get; set; } + public string? RequestId { get; set; } + public string? Source { get; set; } + + public long Tags { get; set; } + + public string PayloadJson { get; set; } = default!; +} + diff --git a/src/Modules/Auditing/Modules.Auditing/Persistence/AuditRecordConfiguration.cs b/src/Modules/Auditing/Modules.Auditing/Persistence/AuditRecordConfiguration.cs new file mode 100644 index 0000000000..43de26906d --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Persistence/AuditRecordConfiguration.cs @@ -0,0 +1,23 @@ +using Finbuckle.MultiTenant.EntityFrameworkCore.Extensions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FSH.Modules.Auditing.Persistence; + +public class AuditRecordConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.ToTable("AuditRecords", "audit"); + builder.IsMultiTenant(); + builder.HasKey(x => x.Id); + builder.Property(x => x.EventType).HasConversion(); + builder.Property(x => x.Severity).HasConversion(); + builder.Property(x => x.Tags).HasConversion(); + builder.Property(x => x.PayloadJson).HasColumnType("jsonb"); + builder.HasIndex(x => x.TenantId); + builder.HasIndex(x => x.EventType); + builder.HasIndex(x => x.OccurredAtUtc); + } +} diff --git a/src/Modules/Auditing/Modules.Auditing/Persistence/AuditingSaveChangesInterceptor.cs b/src/Modules/Auditing/Modules.Auditing/Persistence/AuditingSaveChangesInterceptor.cs new file mode 100644 index 0000000000..d589b8a7fa --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Persistence/AuditingSaveChangesInterceptor.cs @@ -0,0 +1,65 @@ +using FSH.Modules.Auditing.Contracts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace FSH.Modules.Auditing.Persistence; + +/// +/// Captures EF Core entity changes at SaveChanges to produce an EntityChange event. +/// +public sealed class AuditingSaveChangesInterceptor : SaveChangesInterceptor +{ + private readonly IAuditPublisher _publisher; + + public AuditingSaveChangesInterceptor(IAuditPublisher publisher) => _publisher = publisher; + + public override async ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(eventData); + var ctx = eventData.Context; + if (ctx is null) return result; + + var entries = ctx.ChangeTracker.Entries() + .Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted) + .ToArray(); + + if (entries.Length == 0) return result; + + var diffs = EntityDiffBuilder.Build(entries); + + if (diffs.Count > 0) + { + foreach (var group in diffs.GroupBy(d => (d.DbContext, d.Schema, d.Table, d.EntityName, d.Key, d.Operation))) + { + var payload = new EntityChangeEventPayload( + DbContext: group.Key.DbContext, + Schema: group.Key.Schema, + Table: group.Key.Table, + EntityName: group.Key.EntityName, + Key: group.Key.Key, + Operation: group.Key.Operation, + Changes: group.SelectMany(g => g.Changes).ToList(), + TransactionId: ctx.Database.CurrentTransaction?.TransactionId.ToString()); + + var env = new AuditEnvelope( + id: Guid.CreateVersion7(), + occurredAtUtc: DateTime.UtcNow, + receivedAtUtc: DateTime.UtcNow, + eventType: AuditEventType.EntityChange, + severity: AuditSeverity.Information, + tenantId: null, userId: null, userName: null, + traceId: null, spanId: null, correlationId: null, requestId: null, + source: ctx.GetType().Name, + tags: AuditTag.None, + payload: payload); + + await _publisher.PublishAsync(env, cancellationToken); + } + } + + return result; + } +} diff --git a/src/Modules/Auditing/Modules.Auditing/Persistence/EntityDiffBuilder.cs b/src/Modules/Auditing/Modules.Auditing/Persistence/EntityDiffBuilder.cs new file mode 100644 index 0000000000..439df89f47 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Persistence/EntityDiffBuilder.cs @@ -0,0 +1,140 @@ +using FSH.Modules.Auditing.Contracts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace FSH.Modules.Auditing.Persistence; + +/// +/// Builds property-level diffs for EF Core entries. Skips navigations by default. +/// +internal static class EntityDiffBuilder +{ + internal sealed record Diff( + string DbContext, + string? Schema, + string Table, + string EntityName, + string Key, + EntityOperation Operation, + IReadOnlyList Changes); + + public static List Build(IEnumerable entries) + { + var list = new List(); + + foreach (var e in entries) + { + var entityType = e.Metadata; + var table = entityType.GetTableName() ?? entityType.GetDefaultTableName() ?? entityType.DisplayName(); + var schema = entityType.GetSchema(); + var key = BuildKey(e); + var op = e.State switch + { + EntityState.Added => EntityOperation.Insert, + EntityState.Modified => DetectSoftDelete(e) ? EntityOperation.SoftDelete : EntityOperation.Update, + EntityState.Deleted => EntityOperation.Delete, + _ => EntityOperation.None + }; + + var changes = new List(); + foreach (var p in e.Properties) + { + if (p.Metadata.IsShadowProperty() && !p.Metadata.IsPrimaryKey()) continue; + if (p.Metadata.IsConcurrencyToken) continue; + if (p.Metadata.IsIndexerProperty()) continue; + if (p.Metadata.IsKey()) continue; // keys are in "key" string already + if (!p.Metadata.IsNullable && p.Metadata.ClrType.IsClass && p.Metadata.IsForeignKey()) continue; // nav FKs often noisy + + // Include only scalar types + if (!IsScalar(p.Metadata.ClrType)) continue; + + var name = p.Metadata.Name; + var typeName = ToSimpleTypeName(p.Metadata.ClrType); + + object? oldVal = null; + object? newVal = null; + var isModified = false; + + switch (e.State) + { + case EntityState.Added: + newVal = p.CurrentValue; + isModified = true; + break; + + case EntityState.Modified: + oldVal = p.OriginalValue; + newVal = p.CurrentValue; + isModified = p.IsModified && !Equals(oldVal, newVal); + break; + + case EntityState.Deleted: + oldVal = p.OriginalValue; + isModified = true; + break; + } + + if (isModified) + { + changes.Add(new PropertyChange( + Name: name, + DataType: typeName, + OldValue: oldVal, + NewValue: newVal, + IsSensitive: IsSensitive(name))); + } + } + + if (changes.Count > 0) + { + list.Add(new Diff( + DbContext: e.Context.GetType().Name, + Schema: schema, + Table: table!, + EntityName: entityType.ClrType.Name, + Key: key, + Operation: op, + Changes: changes)); + } + } + + return list; + } + + private static string BuildKey(EntityEntry entry) + { + var keyProps = entry.Properties.Where(p => p.Metadata.IsPrimaryKey()).ToArray(); + if (keyProps.Length == 0) return $""; + return string.Join("|", keyProps.Select(k => $"{k.Metadata.Name}:{k.CurrentValue ?? k.OriginalValue}")); + } + + private static bool DetectSoftDelete(EntityEntry entry) + { + // Convention: boolean property named "IsDeleted" flipped to true + var prop = entry.Properties.FirstOrDefault(p => p.Metadata.Name.Equals("IsDeleted", StringComparison.OrdinalIgnoreCase) + && p.Metadata.ClrType == typeof(bool)); + if (prop is null) return false; + var orig = prop.OriginalValue as bool? ?? false; + var curr = prop.CurrentValue as bool? ?? false; + return !orig && curr; + } + + private static bool IsSensitive(string propertyName) + { + // Simple heuristic. Replace with attribute-based masking later. + return propertyName.Contains("password", StringComparison.OrdinalIgnoreCase) + || propertyName.Contains("secret", StringComparison.OrdinalIgnoreCase) + || propertyName.Contains("token", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsScalar(Type t) + { + t = Nullable.GetUnderlyingType(t) ?? t; + return t.IsPrimitive || t.IsEnum || t == typeof(string) || t == typeof(decimal) || + t == typeof(DateTime) || t == typeof(DateTimeOffset) || t == typeof(Guid) || + t == typeof(TimeSpan) || t == typeof(byte[]) || t == typeof(bool); + } + + private static string ToSimpleTypeName(Type t) + => (Nullable.GetUnderlyingType(t) ?? t).Name; +} diff --git a/src/Modules/Auditing/Modules.Auditing/Persistence/SqlAuditSink.cs b/src/Modules/Auditing/Modules.Auditing/Persistence/SqlAuditSink.cs new file mode 100644 index 0000000000..5287902a48 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Persistence/SqlAuditSink.cs @@ -0,0 +1,73 @@ +using Finbuckle.MultiTenant; +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Auditing.Contracts; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace FSH.Modules.Auditing.Persistence; + +/// +/// Persists audit envelopes into SQL using EF Core. +/// +public sealed class SqlAuditSink : IAuditSink +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IAuditSerializer _serializer; + private readonly ILogger _log; + + public SqlAuditSink(IServiceScopeFactory scopeFactory, IAuditSerializer serializer, ILogger log) + => (_scopeFactory, _serializer, _log) = (scopeFactory, serializer, log); + + public async Task WriteAsync(IReadOnlyList batch, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(batch); + if (batch.Count == 0) return; + + // Process per-tenant so MultiTenantDbContext has an ambient tenant context. + foreach (var group in batch.GroupBy(e => e.TenantId)) + { + using var scope = _scopeFactory.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService>(); + + var tenantInfo = group.Key is null + ? await store.GetAsync(MultitenancyConstants.Root.Id).ConfigureAwait(false) + : await store.GetAsync(group.Key).ConfigureAwait(false); + + if (tenantInfo is null) + { + _log.LogWarning("Skipping audit write for tenant {TenantId} because tenant was not found.", group.Key ?? ""); + continue; + } + + scope.ServiceProvider.GetRequiredService() + .MultiTenantContext = new MultiTenantContext(tenantInfo); + + var db = scope.ServiceProvider.GetRequiredService(); + + var records = group.Select(e => new AuditRecord + { + Id = e.Id, + OccurredAtUtc = e.OccurredAtUtc, + ReceivedAtUtc = e.ReceivedAtUtc, + EventType = (int)e.EventType, + Severity = (byte)e.Severity, + TenantId = e.TenantId, + UserId = e.UserId, + UserName = e.UserName, + TraceId = e.TraceId, + SpanId = e.SpanId, + CorrelationId = e.CorrelationId, + RequestId = e.RequestId, + Source = e.Source, + Tags = (long)e.Tags, + PayloadJson = _serializer.SerializePayload(e.Payload) + }).ToList(); + + db.AuditRecords.AddRange(records); + await db.SaveChangesAsync(ct).ConfigureAwait(false); + + _log.LogInformation("Wrote {Count} audit records for tenant {TenantId}.", records.Count, tenantInfo.Id); + } + } +} diff --git a/src/Modules/Identity/Modules.Identity.Contracts/DTOs/GroupDto.cs b/src/Modules/Identity/Modules.Identity.Contracts/DTOs/GroupDto.cs new file mode 100644 index 0000000000..17cb6f3407 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/DTOs/GroupDto.cs @@ -0,0 +1,14 @@ +namespace FSH.Modules.Identity.Contracts.DTOs; + +public class GroupDto +{ + public Guid Id { get; set; } + public string Name { get; set; } = default!; + public string? Description { get; set; } + public bool IsDefault { get; set; } + public bool IsSystemGroup { get; set; } + public int MemberCount { get; set; } + public IReadOnlyCollection? RoleIds { get; set; } + public IReadOnlyCollection? RoleNames { get; set; } + public DateTime CreatedAt { get; set; } +} diff --git a/src/Modules/Identity/Modules.Identity.Contracts/DTOs/GroupMemberDto.cs b/src/Modules/Identity/Modules.Identity.Contracts/DTOs/GroupMemberDto.cs new file mode 100644 index 0000000000..a228dfd933 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/DTOs/GroupMemberDto.cs @@ -0,0 +1,12 @@ +namespace FSH.Modules.Identity.Contracts.DTOs; + +public class GroupMemberDto +{ + public string UserId { get; set; } = default!; + public string? UserName { get; set; } + public string? Email { get; set; } + public string? FirstName { get; set; } + public string? LastName { get; set; } + public DateTime AddedAt { get; set; } + public string? AddedBy { get; set; } +} diff --git a/src/Modules/Identity/Modules.Identity.Contracts/DTOs/RoleDto.cs b/src/Modules/Identity/Modules.Identity.Contracts/DTOs/RoleDto.cs new file mode 100644 index 0000000000..275bdd9430 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/DTOs/RoleDto.cs @@ -0,0 +1,9 @@ +namespace FSH.Modules.Identity.Contracts.DTOs; + +public class RoleDto +{ + public string Id { get; set; } = default!; + public string Name { get; set; } = default!; + public string? Description { get; set; } + public IReadOnlyCollection? Permissions { get; set; } +} \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity.Contracts/DTOs/TokenDto.cs b/src/Modules/Identity/Modules.Identity.Contracts/DTOs/TokenDto.cs new file mode 100644 index 0000000000..5fc2b8f41f --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/DTOs/TokenDto.cs @@ -0,0 +1,3 @@ +namespace FSH.Modules.Identity.Contracts.DTOs; + +public record TokenDto(string Token, string RefreshToken, DateTime RefreshTokenExpiryTime); \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity.Contracts/DTOs/TokenResponse.cs b/src/Modules/Identity/Modules.Identity.Contracts/DTOs/TokenResponse.cs new file mode 100644 index 0000000000..56ad579e39 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/DTOs/TokenResponse.cs @@ -0,0 +1,7 @@ +namespace FSH.Modules.Identity.Contracts.DTOs; + +public sealed record TokenResponse( + string AccessToken, + string RefreshToken, + DateTime RefreshTokenExpiresAt, + DateTime AccessTokenExpiresAt); \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity.Contracts/DTOs/UserDto.cs b/src/Modules/Identity/Modules.Identity.Contracts/DTOs/UserDto.cs new file mode 100644 index 0000000000..bb1053f6b4 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/DTOs/UserDto.cs @@ -0,0 +1,21 @@ +namespace FSH.Modules.Identity.Contracts.DTOs; +public class UserDto +{ + public string? Id { get; set; } + + public string? UserName { get; set; } + + public string? FirstName { get; set; } + + public string? LastName { get; set; } + + public string? Email { get; set; } + + public bool IsActive { get; set; } = true; + + public bool EmailConfirmed { get; set; } + + public string? PhoneNumber { get; set; } + + public string? ImageUrl { get; set; } +} \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity.Contracts/DTOs/UserRoleDto.cs b/src/Modules/Identity/Modules.Identity.Contracts/DTOs/UserRoleDto.cs new file mode 100644 index 0000000000..440248218e --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/DTOs/UserRoleDto.cs @@ -0,0 +1,9 @@ +namespace FSH.Modules.Identity.Contracts.DTOs; + +public class UserRoleDto +{ + public string? RoleId { get; set; } + public string? RoleName { get; set; } + public string? Description { get; set; } + public bool Enabled { get; set; } +} \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity.Contracts/DTOs/UserSessionDto.cs b/src/Modules/Identity/Modules.Identity.Contracts/DTOs/UserSessionDto.cs new file mode 100644 index 0000000000..f2f40bc32a --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/DTOs/UserSessionDto.cs @@ -0,0 +1,20 @@ +namespace FSH.Modules.Identity.Contracts.DTOs; + +public class UserSessionDto +{ + public Guid Id { get; set; } + public string? UserId { get; set; } + public string? UserName { get; set; } + public string? UserEmail { get; set; } + public string? IpAddress { get; set; } + public string? DeviceType { get; set; } + public string? Browser { get; set; } + public string? BrowserVersion { get; set; } + public string? OperatingSystem { get; set; } + public string? OsVersion { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime LastActivityAt { get; set; } + public DateTime ExpiresAt { get; set; } + public bool IsActive { get; set; } + public bool IsCurrentSession { get; set; } +} diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Events/TokenGeneratedIntegrationEvent.cs b/src/Modules/Identity/Modules.Identity.Contracts/Events/TokenGeneratedIntegrationEvent.cs new file mode 100644 index 0000000000..c7ef90757c --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/Events/TokenGeneratedIntegrationEvent.cs @@ -0,0 +1,23 @@ +using FSH.Framework.Eventing.Abstractions; + +namespace FSH.Modules.Identity.Contracts.Events; + +/// +/// Integration event raised when a JWT token is generated for a user. +/// Intended primarily as a sample event to exercise the eventing/outbox pipeline. +/// +public sealed record TokenGeneratedIntegrationEvent( + Guid Id, + DateTime OccurredOnUtc, + string? TenantId, + string CorrelationId, + string Source, + string UserId, + string Email, + string ClientId, + string IpAddress, + string UserAgent, + string TokenFingerprint, + DateTime AccessTokenExpiresAtUtc) + : IIntegrationEvent; + diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Events/UserRegisteredIntegrationEvent.cs b/src/Modules/Identity/Modules.Identity.Contracts/Events/UserRegisteredIntegrationEvent.cs new file mode 100644 index 0000000000..adfa3ceef7 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/Events/UserRegisteredIntegrationEvent.cs @@ -0,0 +1,19 @@ +using FSH.Framework.Eventing.Abstractions; + +namespace FSH.Modules.Identity.Contracts.Events; + +/// +/// Integration event raised when a new user is registered. +/// +public sealed record UserRegisteredIntegrationEvent( + Guid Id, + DateTime OccurredOnUtc, + string? TenantId, + string CorrelationId, + string Source, + string UserId, + string Email, + string FirstName, + string LastName) + : IIntegrationEvent; + diff --git a/src/Modules/Identity/Modules.Identity.Contracts/IdentityContractsMarker.cs b/src/Modules/Identity/Modules.Identity.Contracts/IdentityContractsMarker.cs new file mode 100644 index 0000000000..57f542a425 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/IdentityContractsMarker.cs @@ -0,0 +1,6 @@ +namespace FSH.Modules.Identity.Contracts; + +// Marker type for contract assembly scanning (Mediator, etc.) +public sealed class IdentityContractsMarker +{ +} diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Modules.Identity.Contracts.csproj b/src/Modules/Identity/Modules.Identity.Contracts/Modules.Identity.Contracts.csproj new file mode 100644 index 0000000000..7003eb5f96 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/Modules.Identity.Contracts.csproj @@ -0,0 +1,19 @@ + + + + FSH.Modules.Identity.Contracts + FSH.Modules.Identity.Contracts + FullStackHero.Modules.Identity.Contracts + $(NoWarn);CA1002;CA1056;CS1572;S2094 + + + + + + + + + + + + diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/IGroupRoleService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/IGroupRoleService.cs new file mode 100644 index 0000000000..f6f29faf8c --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/IGroupRoleService.cs @@ -0,0 +1,15 @@ +namespace FSH.Modules.Identity.Contracts.Services; + +/// +/// Service for retrieving roles derived from group memberships. +/// +public interface IGroupRoleService +{ + /// + /// Gets all role names that a user has through their group memberships. + /// + /// The user ID to get group roles for. + /// Cancellation token. + /// List of distinct role names from all groups the user belongs to. + Task> GetUserGroupRolesAsync(string userId, CancellationToken ct = default); +} diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/IIdentityService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/IIdentityService.cs new file mode 100644 index 0000000000..6be11c16d4 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/IIdentityService.cs @@ -0,0 +1,28 @@ +using System.Security.Claims; + +namespace FSH.Modules.Identity.Contracts.Services; + +public interface IIdentityService +{ + /// + /// Validates the provided user credentials and returns a unique subject ID with associated claims. + /// + /// User email or username + /// User password + /// Optional tenant ID + /// Cancellation token + /// Subject ID and claims, or null if invalid + Task<(string Subject, IEnumerable Claims)?> + ValidateCredentialsAsync(string email, string password, CancellationToken ct = default); + + /// + /// Validates a refresh token and returns its claims if valid. + /// + Task<(string Subject, IEnumerable Claims)?> + ValidateRefreshTokenAsync(string refreshToken, CancellationToken ct = default); + + /// + /// Persists a hashed refresh token for the specified subject. + /// + Task StoreRefreshTokenAsync(string subject, string refreshToken, DateTime expiresAtUtc, CancellationToken ct = default); +} diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/IRoleService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/IRoleService.cs new file mode 100644 index 0000000000..83412a3040 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/IRoleService.cs @@ -0,0 +1,14 @@ +using FSH.Modules.Identity.Contracts.DTOs; + +namespace FSH.Modules.Identity.Contracts.Services; + +public interface IRoleService +{ + Task> GetRolesAsync(); + Task GetRoleAsync(string id); + Task CreateOrUpdateRoleAsync(string roleId, string name, string description); + Task DeleteRoleAsync(string id); + Task GetWithPermissionsAsync(string id, CancellationToken cancellationToken); + + Task UpdatePermissionsAsync(string roleId, List permissions); +} \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/ISessionService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/ISessionService.cs new file mode 100644 index 0000000000..1beddfa1cf --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/ISessionService.cs @@ -0,0 +1,72 @@ +using FSH.Modules.Identity.Contracts.DTOs; + +namespace FSH.Modules.Identity.Contracts.Services; + +public interface ISessionService +{ + Task CreateSessionAsync( + string userId, + string refreshTokenHash, + string ipAddress, + string userAgent, + DateTime expiresAt, + CancellationToken cancellationToken = default); + + Task> GetUserSessionsAsync( + string userId, + CancellationToken cancellationToken = default); + + Task> GetUserSessionsForAdminAsync( + string userId, + CancellationToken cancellationToken = default); + + Task GetSessionAsync( + Guid sessionId, + CancellationToken cancellationToken = default); + + Task RevokeSessionAsync( + Guid sessionId, + string revokedBy, + string? reason = null, + CancellationToken cancellationToken = default); + + Task RevokeAllSessionsAsync( + string userId, + string revokedBy, + Guid? exceptSessionId = null, + string? reason = null, + CancellationToken cancellationToken = default); + + Task RevokeAllSessionsForAdminAsync( + string userId, + string revokedBy, + string? reason = null, + CancellationToken cancellationToken = default); + + Task RevokeSessionForAdminAsync( + Guid sessionId, + string revokedBy, + string? reason = null, + CancellationToken cancellationToken = default); + + Task UpdateSessionActivityAsync( + string refreshTokenHash, + CancellationToken cancellationToken = default); + + Task UpdateSessionRefreshTokenAsync( + string oldRefreshTokenHash, + string newRefreshTokenHash, + DateTime newExpiresAt, + CancellationToken cancellationToken = default); + + Task ValidateSessionAsync( + string refreshTokenHash, + CancellationToken cancellationToken = default); + + Task GetSessionIdByRefreshTokenAsync( + string refreshTokenHash, + CancellationToken cancellationToken = default); + + Task CleanupExpiredSessionsAsync( + CancellationToken cancellationToken = default); +} diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/ITokenService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/ITokenService.cs new file mode 100644 index 0000000000..0bc09b0bff --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/ITokenService.cs @@ -0,0 +1,16 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using System.Security.Claims; + +namespace FSH.Modules.Identity.Contracts.Services; + +public interface ITokenService +{ + /// + /// Issues a new access and refresh token for the specified subject. + /// + Task IssueAsync( + string subject, + IEnumerable claims, + string? tenant = null, + CancellationToken ct = default); +} \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserService.cs new file mode 100644 index 0000000000..2b2d263d8c --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserService.cs @@ -0,0 +1,34 @@ +using FSH.Framework.Storage.DTOs; +using FSH.Modules.Identity.Contracts.DTOs; +using System.Security.Claims; + +namespace FSH.Modules.Identity.Contracts.Services; + +public interface IUserService +{ + Task ExistsWithNameAsync(string name); + Task ExistsWithEmailAsync(string email, string? exceptId = null); + Task ExistsWithPhoneNumberAsync(string phoneNumber, string? exceptId = null); + Task> GetListAsync(CancellationToken cancellationToken); + Task GetCountAsync(CancellationToken cancellationToken); + Task GetAsync(string userId, CancellationToken cancellationToken); + Task ToggleStatusAsync(bool activateUser, string userId, CancellationToken cancellationToken); + Task GetOrCreateFromPrincipalAsync(ClaimsPrincipal principal); + 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); + Task DeleteAsync(string userId); + Task ConfirmEmailAsync(string userId, string code, string tenant, CancellationToken cancellationToken); + Task ConfirmPhoneNumberAsync(string userId, string code); + + // permisions + Task HasPermissionAsync(string userId, string permission, CancellationToken cancellationToken = default); + + // passwords + Task ForgotPasswordAsync(string email, string origin, CancellationToken cancellationToken); + Task ResetPasswordAsync(string email, string password, string token, CancellationToken cancellationToken); + Task?> GetPermissionsAsync(string userId, CancellationToken cancellationToken); + + Task ChangePasswordAsync(string password, string newPassword, string confirmNewPassword, string userId); + Task AssignRolesAsync(string userId, List userRoles, CancellationToken cancellationToken); + Task> GetUserRolesAsync(string userId, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Groups/AddUsersToGroup/AddUsersToGroupCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Groups/AddUsersToGroup/AddUsersToGroupCommand.cs new file mode 100644 index 0000000000..30e7d53d1b --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Groups/AddUsersToGroup/AddUsersToGroupCommand.cs @@ -0,0 +1,7 @@ +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Groups.AddUsersToGroup; + +public sealed record AddUsersToGroupCommand(Guid GroupId, List UserIds) : ICommand; + +public sealed record AddUsersToGroupResponse(int AddedCount, List AlreadyMemberUserIds); diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Groups/CreateGroup/CreateGroupCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Groups/CreateGroup/CreateGroupCommand.cs new file mode 100644 index 0000000000..98823ced39 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Groups/CreateGroup/CreateGroupCommand.cs @@ -0,0 +1,10 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup; + +public sealed record CreateGroupCommand( + string Name, + string? Description, + bool IsDefault, + List? RoleIds) : ICommand; diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Groups/DeleteGroup/DeleteGroupCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Groups/DeleteGroup/DeleteGroupCommand.cs new file mode 100644 index 0000000000..dfb65685a1 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Groups/DeleteGroup/DeleteGroupCommand.cs @@ -0,0 +1,5 @@ +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Groups.DeleteGroup; + +public sealed record DeleteGroupCommand(Guid Id) : ICommand; diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Groups/GetGroupById/GetGroupByIdQuery.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Groups/GetGroupById/GetGroupByIdQuery.cs new file mode 100644 index 0000000000..ac8cb60325 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Groups/GetGroupById/GetGroupByIdQuery.cs @@ -0,0 +1,6 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Groups.GetGroupById; + +public sealed record GetGroupByIdQuery(Guid Id) : IQuery; diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Groups/GetGroupMembers/GetGroupMembersQuery.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Groups/GetGroupMembers/GetGroupMembersQuery.cs new file mode 100644 index 0000000000..c0e5610c78 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Groups/GetGroupMembers/GetGroupMembersQuery.cs @@ -0,0 +1,6 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Groups.GetGroupMembers; + +public sealed record GetGroupMembersQuery(Guid GroupId) : IQuery>; diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Groups/GetGroups/GetGroupsQuery.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Groups/GetGroups/GetGroupsQuery.cs new file mode 100644 index 0000000000..a75cf8c1b2 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Groups/GetGroups/GetGroupsQuery.cs @@ -0,0 +1,6 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Groups.GetGroups; + +public sealed record GetGroupsQuery(string? SearchTerm = null) : IQuery>; diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommand.cs new file mode 100644 index 0000000000..ee491f4c9c --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommand.cs @@ -0,0 +1,5 @@ +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Groups.RemoveUserFromGroup; + +public sealed record RemoveUserFromGroupCommand(Guid GroupId, string UserId) : ICommand; diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Groups/UpdateGroup/UpdateGroupCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Groups/UpdateGroup/UpdateGroupCommand.cs new file mode 100644 index 0000000000..fbe15a1ef5 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Groups/UpdateGroup/UpdateGroupCommand.cs @@ -0,0 +1,11 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Groups.UpdateGroup; + +public sealed record UpdateGroupCommand( + Guid Id, + string Name, + string? Description, + bool IsDefault, + List? RoleIds) : ICommand; diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Roles/DeleteRole/DeleteRoleCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Roles/DeleteRole/DeleteRoleCommand.cs new file mode 100644 index 0000000000..a02621fd70 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Roles/DeleteRole/DeleteRoleCommand.cs @@ -0,0 +1,6 @@ +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Roles.DeleteRole; + +public sealed record DeleteRoleCommand(string Id) : ICommand; + diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Roles/GetRole/GetRoleQuery.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Roles/GetRole/GetRoleQuery.cs new file mode 100644 index 0000000000..e563b631a5 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Roles/GetRole/GetRoleQuery.cs @@ -0,0 +1,7 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Roles.GetRole; + +public sealed record GetRoleQuery(string Id) : IQuery; + diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Roles/GetRoleWithPermissions/GetRoleWithPermissionsQuery.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Roles/GetRoleWithPermissions/GetRoleWithPermissionsQuery.cs new file mode 100644 index 0000000000..8123f6c521 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Roles/GetRoleWithPermissions/GetRoleWithPermissionsQuery.cs @@ -0,0 +1,7 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Roles.GetRoleWithPermissions; + +public sealed record GetRoleWithPermissionsQuery(string Id) : IQuery; + diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Roles/GetRoles/GetRolesQuery.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Roles/GetRoles/GetRolesQuery.cs new file mode 100644 index 0000000000..43766b2da9 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Roles/GetRoles/GetRolesQuery.cs @@ -0,0 +1,7 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Roles.GetRoles; + +public sealed record GetRolesQuery : IQuery>; + diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Roles/UpdatePermissions/UpdatePermissionsCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Roles/UpdatePermissions/UpdatePermissionsCommand.cs new file mode 100644 index 0000000000..d545168e3e --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Roles/UpdatePermissions/UpdatePermissionsCommand.cs @@ -0,0 +1,16 @@ +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Roles.UpdatePermissions; + +public class UpdatePermissionsCommand : ICommand +{ + /// + /// The ID of the role to update. + /// + public string RoleId { get; init; } = default!; + + /// + /// The list of permissions to assign to the role. + /// + public List Permissions { get; init; } = []; +} diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Roles/UpsertRole/UpsertRoleCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Roles/UpsertRole/UpsertRoleCommand.cs new file mode 100644 index 0000000000..302cb5be27 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Roles/UpsertRole/UpsertRoleCommand.cs @@ -0,0 +1,11 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Roles.UpsertRole; + +public class UpsertRoleCommand : ICommand +{ + public string Id { get; set; } = default!; + public string Name { get; set; } = default!; + public string? Description { get; set; } +} diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommand.cs new file mode 100644 index 0000000000..5465d92cf7 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommand.cs @@ -0,0 +1,5 @@ +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Sessions.AdminRevokeAllSessions; + +public sealed record AdminRevokeAllSessionsCommand(Guid UserId, string? Reason = null) : ICommand; diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommand.cs new file mode 100644 index 0000000000..1d3d17fd84 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommand.cs @@ -0,0 +1,5 @@ +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Sessions.AdminRevokeSession; + +public sealed record AdminRevokeSessionCommand(Guid UserId, Guid SessionId, string? Reason = null) : ICommand; diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Sessions/GetMySessions/GetMySessionsQuery.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Sessions/GetMySessions/GetMySessionsQuery.cs new file mode 100644 index 0000000000..93b2b6fdcf --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Sessions/GetMySessions/GetMySessionsQuery.cs @@ -0,0 +1,6 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Sessions.GetMySessions; + +public sealed record GetMySessionsQuery : IQuery>; diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Sessions/GetUserSessions/GetUserSessionsQuery.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Sessions/GetUserSessions/GetUserSessionsQuery.cs new file mode 100644 index 0000000000..c980fd1803 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Sessions/GetUserSessions/GetUserSessionsQuery.cs @@ -0,0 +1,6 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Sessions.GetUserSessions; + +public sealed record GetUserSessionsQuery(Guid UserId) : IQuery>; diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Sessions/RevokeAllSessions/RevokeAllSessionsCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Sessions/RevokeAllSessions/RevokeAllSessionsCommand.cs new file mode 100644 index 0000000000..8adc6b6015 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Sessions/RevokeAllSessions/RevokeAllSessionsCommand.cs @@ -0,0 +1,5 @@ +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Sessions.RevokeAllSessions; + +public sealed record RevokeAllSessionsCommand(Guid? ExceptSessionId = null) : ICommand; diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Sessions/RevokeSession/RevokeSessionCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Sessions/RevokeSession/RevokeSessionCommand.cs new file mode 100644 index 0000000000..685c033e55 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Sessions/RevokeSession/RevokeSessionCommand.cs @@ -0,0 +1,5 @@ +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Sessions.RevokeSession; + +public sealed record RevokeSessionCommand(Guid SessionId) : ICommand; diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Tokens/RefreshToken/RefreshTokenCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Tokens/RefreshToken/RefreshTokenCommand.cs new file mode 100644 index 0000000000..c9031bd206 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Tokens/RefreshToken/RefreshTokenCommand.cs @@ -0,0 +1,6 @@ +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Tokens.RefreshToken; + +public record RefreshTokenCommand(string Token, string RefreshToken) + : ICommand; \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Tokens/RefreshToken/RefreshTokenCommandResponse.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Tokens/RefreshToken/RefreshTokenCommandResponse.cs new file mode 100644 index 0000000000..944aba7b43 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Tokens/RefreshToken/RefreshTokenCommandResponse.cs @@ -0,0 +1,6 @@ +namespace FSH.Modules.Identity.Contracts.v1.Tokens.RefreshToken; + +public sealed record RefreshTokenCommandResponse( + string Token, + string RefreshToken, + DateTime RefreshTokenExpiryTime); \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Tokens/TokenGeneration/GenerateTokenCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Tokens/TokenGeneration/GenerateTokenCommand.cs new file mode 100644 index 0000000000..460fc40765 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Tokens/TokenGeneration/GenerateTokenCommand.cs @@ -0,0 +1,9 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Tokens.TokenGeneration; + +public record GenerateTokenCommand( + string Email, + string Password) + : ICommand; \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/AssignUserRoles/AssignUserRolesCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/AssignUserRoles/AssignUserRolesCommand.cs new file mode 100644 index 0000000000..35e9617a30 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/AssignUserRoles/AssignUserRolesCommand.cs @@ -0,0 +1,10 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Users.AssignUserRoles; + +public sealed class AssignUserRolesCommand : ICommand +{ + public required string UserId { get; init; } + public List UserRoles { get; init; } = new(); +} \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/AssignUserRoles/AssignUserRolesCommandResponse.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/AssignUserRoles/AssignUserRolesCommandResponse.cs new file mode 100644 index 0000000000..3d7b46b4e5 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/AssignUserRoles/AssignUserRolesCommandResponse.cs @@ -0,0 +1,3 @@ +namespace FSH.Modules.Identity.Contracts.v1.Users.AssignUserRoles; + +public sealed record AssignUserRolesCommandResponse(string Result); \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/ChangePassword/ChangePasswordCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/ChangePassword/ChangePasswordCommand.cs new file mode 100644 index 0000000000..0f3b31a11a --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/ChangePassword/ChangePasswordCommand.cs @@ -0,0 +1,15 @@ +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Users.ChangePassword; + +public class ChangePasswordCommand : ICommand +{ + /// The user's current password. + public string Password { get; init; } = default!; + + /// The new password the user wants to set. + public string NewPassword { get; init; } = default!; + + /// Confirmation of the new password. + public string ConfirmNewPassword { get; init; } = default!; +} \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/ConfirmEmail/ConfirmEmailCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/ConfirmEmail/ConfirmEmailCommand.cs new file mode 100644 index 0000000000..2967998af9 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/ConfirmEmail/ConfirmEmailCommand.cs @@ -0,0 +1,6 @@ +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Users.ConfirmEmail; + +public sealed record ConfirmEmailCommand(string UserId, string Code, string Tenant) : ICommand; + diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/DeleteUser/DeleteUserCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/DeleteUser/DeleteUserCommand.cs new file mode 100644 index 0000000000..02c452924d --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/DeleteUser/DeleteUserCommand.cs @@ -0,0 +1,6 @@ +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Users.DeleteUser; + +public sealed record DeleteUserCommand(string Id) : ICommand; + diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/ForgotPassword/ForgotPasswordCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/ForgotPassword/ForgotPasswordCommand.cs new file mode 100644 index 0000000000..469a39fb68 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/ForgotPassword/ForgotPasswordCommand.cs @@ -0,0 +1,8 @@ +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Users.ForgotPassword; + +public class ForgotPasswordCommand : ICommand +{ + public string Email { get; set; } = default!; +} diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/GetUser/GetUserQuery.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/GetUser/GetUserQuery.cs new file mode 100644 index 0000000000..38f68ec6c2 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/GetUser/GetUserQuery.cs @@ -0,0 +1,7 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Users.GetUser; + +public sealed record GetUserQuery(string Id) : IQuery; + diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/GetUserGroups/GetUserGroupsQuery.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/GetUserGroups/GetUserGroupsQuery.cs new file mode 100644 index 0000000000..1538f93980 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/GetUserGroups/GetUserGroupsQuery.cs @@ -0,0 +1,6 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Users.GetUserGroups; + +public sealed record GetUserGroupsQuery(string UserId) : IQuery>; diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/GetUserPermissions/GetCurrentUserPermissionsQuery.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/GetUserPermissions/GetCurrentUserPermissionsQuery.cs new file mode 100644 index 0000000000..96c887f9d2 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/GetUserPermissions/GetCurrentUserPermissionsQuery.cs @@ -0,0 +1,6 @@ +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Users.GetUserPermissions; + +public sealed record GetCurrentUserPermissionsQuery(string UserId) : IQuery?>; + diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/GetUserProfile/GetCurrentUserProfileQuery.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/GetUserProfile/GetCurrentUserProfileQuery.cs new file mode 100644 index 0000000000..db4eae323c --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/GetUserProfile/GetCurrentUserProfileQuery.cs @@ -0,0 +1,7 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Users.GetUserProfile; + +public sealed record GetCurrentUserProfileQuery(string UserId) : IQuery; + diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/GetUserRoles/GetUserRolesQuery.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/GetUserRoles/GetUserRolesQuery.cs new file mode 100644 index 0000000000..633714fc47 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/GetUserRoles/GetUserRolesQuery.cs @@ -0,0 +1,7 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Users.GetUserRoles; + +public sealed record GetUserRolesQuery(string UserId) : IQuery>; + diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/GetUsers/GetUsersQuery.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/GetUsers/GetUsersQuery.cs new file mode 100644 index 0000000000..c807be83ad --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/GetUsers/GetUsersQuery.cs @@ -0,0 +1,7 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Users.GetUsers; + +public sealed record GetUsersQuery : IQuery>; + diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/RegisterUser/RegisterUserCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/RegisterUser/RegisterUserCommand.cs new file mode 100644 index 0000000000..96afb7eedf --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/RegisterUser/RegisterUserCommand.cs @@ -0,0 +1,18 @@ +using Mediator; +using System.Text.Json.Serialization; + +namespace FSH.Modules.Identity.Contracts.v1.Users.RegisterUser; + +public class RegisterUserCommand : ICommand +{ + public string FirstName { get; set; } = default!; + public string LastName { get; set; } = default!; + public string Email { get; set; } = default!; + public string UserName { get; set; } = default!; + public string Password { get; set; } = default!; + public string ConfirmPassword { get; set; } = default!; + public string? PhoneNumber { get; set; } + + [JsonIgnore] + public string? Origin { get; set; } +} \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/RegisterUser/RegisterUserResponse.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/RegisterUser/RegisterUserResponse.cs new file mode 100644 index 0000000000..0b82e4bd44 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/RegisterUser/RegisterUserResponse.cs @@ -0,0 +1,3 @@ +namespace FSH.Modules.Identity.Contracts.v1.Users.RegisterUser; + +public record RegisterUserResponse(string UserId); \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/ResetPassword/ResetPasswordCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/ResetPassword/ResetPasswordCommand.cs new file mode 100644 index 0000000000..47bbbf51b7 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/ResetPassword/ResetPasswordCommand.cs @@ -0,0 +1,12 @@ +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Users.ResetPassword; + +public class ResetPasswordCommand : ICommand +{ + public string Email { get; set; } = default!; + + public string Password { get; set; } = default!; + + public string Token { get; set; } = default!; +} diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/SearchUsers/SearchUsersQuery.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/SearchUsers/SearchUsersQuery.cs new file mode 100644 index 0000000000..91f3ec2410 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/SearchUsers/SearchUsersQuery.cs @@ -0,0 +1,22 @@ +using FSH.Framework.Shared.Persistence; +using FSH.Modules.Identity.Contracts.DTOs; +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Users.SearchUsers; + +public sealed class SearchUsersQuery : IPagedQuery, IQuery> +{ + public int? PageNumber { get; set; } + + public int? PageSize { get; set; } + + public string? Sort { get; set; } + + public string? Search { get; set; } + + public bool? IsActive { get; set; } + + public bool? EmailConfirmed { get; set; } + + public string? RoleId { get; set; } +} diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/ToggleUserStatus/ToggleUserStatusCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/ToggleUserStatus/ToggleUserStatusCommand.cs new file mode 100644 index 0000000000..76b7a6f9be --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/ToggleUserStatus/ToggleUserStatusCommand.cs @@ -0,0 +1,9 @@ +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Users.ToggleUserStatus; + +public class ToggleUserStatusCommand : ICommand +{ + public bool ActivateUser { get; set; } + public string? UserId { get; set; } +} 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 new file mode 100644 index 0000000000..20c73a5c25 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/UpdateUser/UpdateUserCommand.cs @@ -0,0 +1,15 @@ +using FSH.Framework.Storage.DTOs; +using Mediator; + +namespace FSH.Modules.Identity.Contracts.v1.Users.UpdateUser; + +public class UpdateUserCommand : ICommand +{ + public string Id { get; set; } = default!; + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string? PhoneNumber { get; set; } + public string? Email { get; set; } + public FileUploadRequest? Image { get; set; } + public bool DeleteCurrentImage { get; set; } +} diff --git a/src/Modules/Identity/Modules.Identity/AssemblyInfo.cs b/src/Modules/Identity/Modules.Identity/AssemblyInfo.cs new file mode 100644 index 0000000000..00dbbec746 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/AssemblyInfo.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; +using FSH.Framework.Web.Modules; + +[assembly: FshModule(typeof(FSH.Modules.Identity.IdentityModule), 100)] +[assembly: InternalsVisibleTo("Identity.Tests")] diff --git a/src/Modules/Identity/Modules.Identity/AuthenticationConstants.cs b/src/Modules/Identity/Modules.Identity/AuthenticationConstants.cs new file mode 100644 index 0000000000..0d820fcac0 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/AuthenticationConstants.cs @@ -0,0 +1,6 @@ +namespace FSH.Modules.Identity; + +public static class AuthenticationConstants +{ + public const string AuthenticationScheme = "Bearer"; +} \ 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 new file mode 100644 index 0000000000..674db1688e --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Authorization/Jwt/ConfigureJwtBearerOptions.cs @@ -0,0 +1,89 @@ +using FSH.Framework.Core.Exceptions; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using System.Security.Claims; +using System.Text; + +namespace FSH.Modules.Identity.Authorization.Jwt; + +public class ConfigureJwtBearerOptions : IConfigureNamedOptions +{ + private readonly JwtOptions _options; + + public ConfigureJwtBearerOptions(IOptions options, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(configuration); + + _options = options.Value; + } + + public void Configure(JwtBearerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + Configure(string.Empty, options); + } + + public void Configure(string? name, JwtBearerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (name != JwtBearerDefaults.AuthenticationScheme) + { + return; + } + + byte[] key = Encoding.ASCII.GetBytes(_options.SigningKey); + + options.RequireHttpsMetadata = true; + options.SaveToken = false; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(key), + ValidIssuer = _options.Issuer, + ValidateIssuer = true, + ValidateLifetime = true, + ValidAudience = _options.Audience, + ValidateAudience = true, + RoleClaimType = ClaimTypes.Role, + ClockSkew = TimeSpan.FromMinutes(2) + }; + options.Events = new JwtBearerEvents + { + OnChallenge = context => + { + context.HandleResponse(); + + var path = context.HttpContext.Request.Path; + + if (!context.Response.HasStarted) + { + var method = context.HttpContext.Request.Method; + + // You can include more details if needed like headers, etc. + throw new UnauthorizedException($"Unauthorized access to {method} {path}"); + } + + return Task.CompletedTask; + }, + OnForbidden = _ => throw new ForbiddenException(), + OnMessageReceived = context => + { + var accessToken = context.Request.Query["access_token"]; + + if (!string.IsNullOrEmpty(accessToken) && + context.HttpContext.Request.Path.StartsWithSegments("/notifications", StringComparison.OrdinalIgnoreCase)) + { + // Read the token out of the query string + context.Token = accessToken; + } + + return Task.CompletedTask; + } + }; + } +} diff --git a/src/Modules/Identity/Modules.Identity/Authorization/Jwt/Extensions.cs b/src/Modules/Identity/Modules.Identity/Authorization/Jwt/Extensions.cs new file mode 100644 index 0000000000..0affc1cc9f --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Authorization/Jwt/Extensions.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace FSH.Modules.Identity.Authorization.Jwt; + +internal static class Extensions +{ + internal static IServiceCollection ConfigureJwtAuth(this IServiceCollection services) + { + services.AddOptions() + .BindConfiguration(nameof(JwtOptions)) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddSingleton, ConfigureJwtBearerOptions>(); + services + .AddAuthentication(authentication => + { + authentication.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + authentication.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, null!); + + services.AddAuthorizationBuilder().AddRequiredPermissionPolicy(); + services.AddAuthorization(options => + { + options.FallbackPolicy = options.GetPolicy(RequiredPermissionDefaults.PolicyName); + }); + return services; + } +} \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Authorization/Jwt/JwtOptions.cs b/src/Modules/Identity/Modules.Identity/Authorization/Jwt/JwtOptions.cs new file mode 100644 index 0000000000..06f0ca6afc --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Authorization/Jwt/JwtOptions.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; + +namespace FSH.Modules.Identity.Authorization.Jwt; + +public class JwtOptions : IValidatableObject +{ + public string Issuer { get; init; } = string.Empty; + public string Audience { get; init; } = string.Empty; + public string SigningKey { get; init; } = string.Empty; + public int AccessTokenMinutes { get; init; } = 30; + public int RefreshTokenDays { get; init; } = 7; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (string.IsNullOrEmpty(SigningKey)) + { + yield return new ValidationResult("No Key defined in JwtOptions config", [nameof(SigningKey)]); + } + + if (!string.IsNullOrEmpty(SigningKey) && SigningKey.Length < 32) + { + yield return new ValidationResult("SigningKey must be at least 32 characters long.", [nameof(SigningKey)]); + } + + if (string.IsNullOrEmpty(Issuer)) + { + yield return new ValidationResult("No Issuer defined in JwtOptions config", [nameof(Issuer)]); + } + + if (string.IsNullOrEmpty(Audience)) + { + yield return new ValidationResult("No Audience defined in JwtOptions config", [nameof(Audience)]); + } + } +} diff --git a/src/Modules/Identity/Modules.Identity/Authorization/PathAwareAuthorizationHandler.cs b/src/Modules/Identity/Modules.Identity/Authorization/PathAwareAuthorizationHandler.cs new file mode 100644 index 0000000000..b06e8e1fe2 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Authorization/PathAwareAuthorizationHandler.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Policy; +using Microsoft.AspNetCore.Http; + +namespace FSH.Modules.Identity.Authorization; + +public class PathAwareAuthorizationHandler : IAuthorizationMiddlewareResultHandler +{ + private readonly AuthorizationMiddlewareResultHandler _fallback = new(); + + public async Task HandleAsync( + RequestDelegate next, + HttpContext context, + AuthorizationPolicy policy, + PolicyAuthorizationResult authorizeResult) + { + ArgumentNullException.ThrowIfNull(next); + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(policy); + ArgumentNullException.ThrowIfNull(authorizeResult); + + var path = context.Request.Path; + var allowedPaths = new[] + { + new PathString("/scalar"), + new PathString("/openapi"), + new PathString("/favicon.ico") + }; + if (allowedPaths.Any(p => path.StartsWithSegments(p, StringComparison.OrdinalIgnoreCase))) + { + // ✅ Respect routing + continue the pipeline + var endpoint = context.GetEndpoint(); + if (endpoint != null) + { + await next(context); + return; + } + + // If no endpoint is found, return 404 explicitly + context.Response.StatusCode = StatusCodes.Status404NotFound; + await context.Response.WriteAsync("Endpoint not found."); + return; + } + + await _fallback.HandleAsync(next, context, policy, authorizeResult); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Authorization/PermissionAuthorizationRequirement.cs b/src/Modules/Identity/Modules.Identity/Authorization/PermissionAuthorizationRequirement.cs new file mode 100644 index 0000000000..3dbb2d69c4 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Authorization/PermissionAuthorizationRequirement.cs @@ -0,0 +1,5 @@ +using Microsoft.AspNetCore.Authorization; + +namespace FSH.Modules.Identity.Authorization; + +public class PermissionAuthorizationRequirement : IAuthorizationRequirement; \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Auth/Policy/RequiredPermissionAuthorizationExtensions.cs b/src/Modules/Identity/Modules.Identity/Authorization/RequiredPermissionAuthorizationExtensions.cs similarity index 76% rename from src/api/framework/Infrastructure/Auth/Policy/RequiredPermissionAuthorizationExtensions.cs rename to src/Modules/Identity/Modules.Identity/Authorization/RequiredPermissionAuthorizationExtensions.cs index e92cdb2e68..5bbd6ee849 100644 --- a/src/api/framework/Infrastructure/Auth/Policy/RequiredPermissionAuthorizationExtensions.cs +++ b/src/Modules/Identity/Modules.Identity/Authorization/RequiredPermissionAuthorizationExtensions.cs @@ -1,9 +1,9 @@ -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -namespace FSH.Framework.Infrastructure.Auth.Policy; +namespace FSH.Modules.Identity.Authorization; + public static class RequiredPermissionDefaults { public const string PolicyName = "RequiredPermission"; @@ -13,15 +13,19 @@ public static class RequiredPermissionAuthorizationExtensions { public static AuthorizationPolicyBuilder RequireRequiredPermissions(this AuthorizationPolicyBuilder builder) { + ArgumentNullException.ThrowIfNull(builder); + return builder.AddRequirements(new PermissionAuthorizationRequirement()); } public static AuthorizationBuilder AddRequiredPermissionPolicy(this AuthorizationBuilder builder) { + ArgumentNullException.ThrowIfNull(builder); + builder.AddPolicy(RequiredPermissionDefaults.PolicyName, policy => { policy.RequireAuthenticatedUser(); - policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme); + policy.AddAuthenticationSchemes(AuthenticationConstants.AuthenticationScheme); policy.RequireRequiredPermissions(); }); diff --git a/src/api/framework/Infrastructure/Auth/Policy/RequiredPermissionAuthorizationHandler.cs b/src/Modules/Identity/Modules.Identity/Authorization/RequiredPermissionAuthorizationHandler.cs similarity index 81% rename from src/api/framework/Infrastructure/Auth/Policy/RequiredPermissionAuthorizationHandler.cs rename to src/Modules/Identity/Modules.Identity/Authorization/RequiredPermissionAuthorizationHandler.cs index 8903b660e8..ae9a13d9af 100644 --- a/src/api/framework/Infrastructure/Auth/Policy/RequiredPermissionAuthorizationHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Authorization/RequiredPermissionAuthorizationHandler.cs @@ -1,13 +1,17 @@ -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Starter.Shared.Authorization; +using FSH.Framework.Shared.Identity.Claims; +using FSH.Modules.Identity.Contracts.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; -namespace FSH.Framework.Infrastructure.Auth.Policy; +namespace FSH.Modules.Identity.Authorization; + public sealed class RequiredPermissionAuthorizationHandler(IUserService userService) : AuthorizationHandler { protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, PermissionAuthorizationRequirement requirement) { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(requirement); + var endpoint = context.Resource switch { HttpContext httpContext => httpContext.GetEndpoint(), diff --git a/src/Modules/Identity/Modules.Identity/Data/Configurations/GroupConfiguration.cs b/src/Modules/Identity/Modules.Identity/Data/Configurations/GroupConfiguration.cs new file mode 100644 index 0000000000..5b08420124 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Data/Configurations/GroupConfiguration.cs @@ -0,0 +1,50 @@ +using Finbuckle.MultiTenant.EntityFrameworkCore.Extensions; +using FSH.Modules.Identity.Features.v1.Groups; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FSH.Modules.Identity.Data.Configurations; + +public class GroupConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder + .ToTable("Groups", IdentityModuleConstants.SchemaName) + .IsMultiTenant(); + + builder.HasKey(g => g.Id); + + builder + .Property(g => g.Name) + .IsRequired() + .HasMaxLength(256); + + builder + .Property(g => g.Description) + .HasMaxLength(1024); + + builder + .Property(g => g.CreatedBy) + .HasMaxLength(450); + + builder + .Property(g => g.ModifiedBy) + .HasMaxLength(450); + + builder + .Property(g => g.DeletedBy) + .HasMaxLength(450); + + builder + .Property(g => g.CreatedAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + // Indexes + builder.HasIndex(g => g.Name); + builder.HasIndex(g => g.IsDefault); + builder.HasIndex(g => g.IsDeleted); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Data/Configurations/GroupRoleConfiguration.cs b/src/Modules/Identity/Modules.Identity/Data/Configurations/GroupRoleConfiguration.cs new file mode 100644 index 0000000000..8568f54b58 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Data/Configurations/GroupRoleConfiguration.cs @@ -0,0 +1,42 @@ +using Finbuckle.MultiTenant.EntityFrameworkCore.Extensions; +using FSH.Modules.Identity.Features.v1.Groups; +using FSH.Modules.Identity.Features.v1.Roles; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FSH.Modules.Identity.Data.Configurations; + +public class GroupRoleConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder + .ToTable("GroupRoles", IdentityModuleConstants.SchemaName) + .IsMultiTenant(); + + builder.HasKey(gr => new { gr.GroupId, gr.RoleId }); + + builder + .Property(gr => gr.RoleId) + .IsRequired() + .HasMaxLength(450); + + builder + .HasOne(gr => gr.Group) + .WithMany(g => g.GroupRoles) + .HasForeignKey(gr => gr.GroupId) + .OnDelete(DeleteBehavior.Cascade); + + builder + .HasOne(gr => gr.Role) + .WithMany() + .HasForeignKey(gr => gr.RoleId) + .OnDelete(DeleteBehavior.Cascade); + + // Indexes + builder.HasIndex(gr => gr.GroupId); + builder.HasIndex(gr => gr.RoleId); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Data/Configurations/PasswordHistoryConfiguration.cs b/src/Modules/Identity/Modules.Identity/Data/Configurations/PasswordHistoryConfiguration.cs new file mode 100644 index 0000000000..9f5a6d05fe --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Data/Configurations/PasswordHistoryConfiguration.cs @@ -0,0 +1,42 @@ +using FSH.Modules.Identity.Features.v1.Users; +using FSH.Modules.Identity.Features.v1.Users.PasswordHistory; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FSH.Modules.Identity.Data.Configurations; + +public class PasswordHistoryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder + .ToTable("PasswordHistory", IdentityModuleConstants.SchemaName) + .HasKey(ph => ph.Id); + + builder + .Property(ph => ph.UserId) + .IsRequired() + .HasMaxLength(256); + + builder + .Property(ph => ph.PasswordHash) + .IsRequired(); + + builder + .Property(ph => ph.CreatedAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + // Configure the foreign key relationship + builder + .HasOne(ph => ph.User) + .WithMany((FshUser u) => u.PasswordHistories) + .HasForeignKey(ph => ph.UserId) + .OnDelete(DeleteBehavior.Cascade); + + // Add index for efficient lookups + builder.HasIndex(ph => ph.UserId); + builder.HasIndex(ph => new { ph.UserId, ph.CreatedAt }); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Data/Configurations/UserGroupConfiguration.cs b/src/Modules/Identity/Modules.Identity/Data/Configurations/UserGroupConfiguration.cs new file mode 100644 index 0000000000..9d71ec4325 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Data/Configurations/UserGroupConfiguration.cs @@ -0,0 +1,50 @@ +using Finbuckle.MultiTenant.EntityFrameworkCore.Extensions; +using FSH.Modules.Identity.Features.v1.Groups; +using FSH.Modules.Identity.Features.v1.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FSH.Modules.Identity.Data.Configurations; + +public class UserGroupConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder + .ToTable("UserGroups", IdentityModuleConstants.SchemaName) + .IsMultiTenant(); + + builder.HasKey(ug => new { ug.UserId, ug.GroupId }); + + builder + .Property(ug => ug.UserId) + .IsRequired() + .HasMaxLength(450); + + builder + .Property(ug => ug.AddedBy) + .HasMaxLength(450); + + builder + .Property(ug => ug.AddedAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + builder + .HasOne(ug => ug.User) + .WithMany() + .HasForeignKey(ug => ug.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder + .HasOne(ug => ug.Group) + .WithMany(g => g.UserGroups) + .HasForeignKey(ug => ug.GroupId) + .OnDelete(DeleteBehavior.Cascade); + + // Indexes + builder.HasIndex(ug => ug.UserId); + builder.HasIndex(ug => ug.GroupId); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Data/Configurations/UserSessionConfiguration.cs b/src/Modules/Identity/Modules.Identity/Data/Configurations/UserSessionConfiguration.cs new file mode 100644 index 0000000000..178eeb74bf --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Data/Configurations/UserSessionConfiguration.cs @@ -0,0 +1,80 @@ +using FSH.Modules.Identity.Features.v1.Sessions; +using FSH.Modules.Identity.Features.v1.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FSH.Modules.Identity.Data.Configurations; + +public class UserSessionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder + .ToTable("UserSessions", IdentityModuleConstants.SchemaName) + .HasKey(s => s.Id); + + builder + .Property(s => s.UserId) + .IsRequired() + .HasMaxLength(450); + + builder + .Property(s => s.RefreshTokenHash) + .IsRequired() + .HasMaxLength(256); + + builder + .Property(s => s.IpAddress) + .IsRequired() + .HasMaxLength(45); + + builder + .Property(s => s.UserAgent) + .IsRequired() + .HasMaxLength(1024); + + builder + .Property(s => s.DeviceType) + .HasMaxLength(50); + + builder + .Property(s => s.Browser) + .HasMaxLength(100); + + builder + .Property(s => s.BrowserVersion) + .HasMaxLength(50); + + builder + .Property(s => s.OperatingSystem) + .HasMaxLength(100); + + builder + .Property(s => s.OsVersion) + .HasMaxLength(50); + + builder + .Property(s => s.RevokedBy) + .HasMaxLength(450); + + builder + .Property(s => s.RevokedReason) + .HasMaxLength(500); + + builder + .Property(s => s.CreatedAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + builder + .HasOne(s => s.User) + .WithMany() + .HasForeignKey(s => s.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(s => s.UserId); + builder.HasIndex(s => s.RefreshTokenHash); + builder.HasIndex(s => new { s.UserId, s.IsRevoked }); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Data/IdentityConfigurations.cs b/src/Modules/Identity/Modules.Identity/Data/IdentityConfigurations.cs new file mode 100644 index 0000000000..b408c83fb7 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Data/IdentityConfigurations.cs @@ -0,0 +1,98 @@ +using Finbuckle.MultiTenant.EntityFrameworkCore.Extensions; +using FSH.Modules.Identity.Features.v1.RoleClaims; +using FSH.Modules.Identity.Features.v1.Roles; +using FSH.Modules.Identity.Features.v1.Users; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FSH.Modules.Identity.Data; + +public class ApplicationUserConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder + .ToTable("Users", IdentityModuleConstants.SchemaName) + .IsMultiTenant(); + + builder + .Property(u => u.ObjectId) + .HasMaxLength(256); + } +} + +public class ApplicationRoleConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder + .ToTable("Roles", IdentityModuleConstants.SchemaName) + .IsMultiTenant() + .AdjustUniqueIndexes(); + } +} + +public class ApplicationRoleClaimConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder + .ToTable("RoleClaims", IdentityModuleConstants.SchemaName) + .IsMultiTenant(); + } +} + +public class IdentityUserRoleConfig : IEntityTypeConfiguration> +{ + public void Configure(EntityTypeBuilder> builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder + .ToTable("UserRoles", IdentityModuleConstants.SchemaName) + .IsMultiTenant(); + } +} + +public class IdentityUserClaimConfig : IEntityTypeConfiguration> +{ + public void Configure(EntityTypeBuilder> builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder + .ToTable("UserClaims", IdentityModuleConstants.SchemaName) + .IsMultiTenant(); + } +} + +public class IdentityUserLoginConfig : IEntityTypeConfiguration> +{ + public void Configure(EntityTypeBuilder> builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder + .ToTable("UserLogins", IdentityModuleConstants.SchemaName) + .IsMultiTenant(); + } +} + +public class IdentityUserTokenConfig : IEntityTypeConfiguration> +{ + public void Configure(EntityTypeBuilder> builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder + .ToTable("UserTokens", IdentityModuleConstants.SchemaName) + .IsMultiTenant(); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Data/IdentityDbContext.cs b/src/Modules/Identity/Modules.Identity/Data/IdentityDbContext.cs new file mode 100644 index 0000000000..7d4d710531 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Data/IdentityDbContext.cs @@ -0,0 +1,84 @@ +using Finbuckle.MultiTenant.Abstractions; +using Finbuckle.MultiTenant.Identity.EntityFrameworkCore; +using FSH.Framework.Eventing.Inbox; +using FSH.Framework.Eventing.Outbox; +using FSH.Framework.Persistence; +using FSH.Framework.Shared.Multitenancy; +using FSH.Framework.Shared.Persistence; +using FSH.Modules.Identity.Features.v1.RoleClaims; +using FSH.Modules.Identity.Features.v1.Roles; +using FSH.Modules.Identity.Features.v1.Users; +using FSH.Modules.Identity.Features.v1.Users.PasswordHistory; +using FSH.Modules.Identity.Features.v1.Sessions; +using FSH.Modules.Identity.Features.v1.Groups; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace FSH.Modules.Identity.Data; + +public class IdentityDbContext : MultiTenantIdentityDbContext, + IdentityUserRole, + IdentityUserLogin, + FshRoleClaim, + IdentityUserToken, + IdentityUserPasskey> +{ + private readonly DatabaseOptions _settings; + private new AppTenantInfo TenantInfo { get; set; } + private readonly IHostEnvironment _environment; + public DbSet OutboxMessages => Set(); + + public DbSet InboxMessages => Set(); + + public DbSet PasswordHistories => Set(); + + public DbSet UserSessions => Set(); + + public DbSet Groups => Set(); + + public DbSet GroupRoles => Set(); + + public DbSet UserGroups => Set(); + + public IdentityDbContext( + IMultiTenantContextAccessor multiTenantContextAccessor, + DbContextOptions options, + IOptions settings, + IHostEnvironment environment) : base(multiTenantContextAccessor, options) + { + ArgumentNullException.ThrowIfNull(multiTenantContextAccessor); + ArgumentNullException.ThrowIfNull(settings); + + _environment = environment; + _settings = settings.Value; + TenantInfo = multiTenantContextAccessor.MultiTenantContext.TenantInfo!; + } + + protected override void OnModelCreating(ModelBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + base.OnModelCreating(builder); + builder.ApplyConfigurationsFromAssembly(typeof(IdentityDbContext).Assembly); + + builder.ApplyConfiguration(new OutboxMessageConfiguration(IdentityModuleConstants.SchemaName)); + builder.ApplyConfiguration(new InboxMessageConfiguration(IdentityModuleConstants.SchemaName)); + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + if (!string.IsNullOrWhiteSpace(TenantInfo?.ConnectionString)) + { + optionsBuilder.ConfigureHeroDatabase( + _settings.Provider, + TenantInfo.ConnectionString, + _settings.MigrationsAssembly, + _environment.IsDevelopment()); + } + } +} diff --git a/src/Modules/Identity/Modules.Identity/Data/IdentityDbInitializer.cs b/src/Modules/Identity/Modules.Identity/Data/IdentityDbInitializer.cs new file mode 100644 index 0000000000..e774185194 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Data/IdentityDbInitializer.cs @@ -0,0 +1,211 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Persistence; +using FSH.Framework.Shared.Constants; +using FSH.Framework.Shared.Multitenancy; +using FSH.Framework.Web.Origin; +using FSH.Modules.Identity.Features.v1.Groups; +using FSH.Modules.Identity.Features.v1.RoleClaims; +using FSH.Modules.Identity.Features.v1.Roles; +using FSH.Modules.Identity.Features.v1.Users; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace FSH.Modules.Identity.Data; + +internal sealed class IdentityDbInitializer( + ILogger logger, + IdentityDbContext context, + RoleManager roleManager, + UserManager userManager, + TimeProvider timeProvider, + IMultiTenantContextAccessor multiTenantContextAccessor, + IOptions originSettings) : IDbInitializer +{ + public async Task MigrateAsync(CancellationToken cancellationToken) + { + if ((await context.Database.GetPendingMigrationsAsync(cancellationToken).ConfigureAwait(false)).Any()) + { + await context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false); + logger.LogInformation("[{Tenant}] applied database migrations for identity module", context.TenantInfo?.Identifier); + } + } + + public async Task SeedAsync(CancellationToken cancellationToken) + { + await SeedRolesAsync(); + await SeedSystemGroupsAsync(); + await SeedAdminUserAsync(); + } + + private async Task SeedRolesAsync() + { + foreach (string roleName in RoleConstants.DefaultRoles) + { + if (await roleManager.Roles.SingleOrDefaultAsync(r => r.Name == roleName) + is not FshRole role) + { + // create role + role = new FshRole(roleName, $"{roleName} Role for {multiTenantContextAccessor.MultiTenantContext.TenantInfo?.Id} Tenant"); + await roleManager.CreateAsync(role); + } + + // Assign permissions + if (roleName == RoleConstants.Basic) + { + await AssignPermissionsToRoleAsync(context, PermissionConstants.Basic, role); + } + else if (roleName == RoleConstants.Admin) + { + await AssignPermissionsToRoleAsync(context, PermissionConstants.Admin, role); + + if (multiTenantContextAccessor.MultiTenantContext.TenantInfo?.Id == MultitenancyConstants.Root.Id) + { + await AssignPermissionsToRoleAsync(context, PermissionConstants.Root, role); + } + } + } + } + + private async Task AssignPermissionsToRoleAsync(IdentityDbContext dbContext, IReadOnlyList permissions, FshRole role) + { + var currentClaims = await roleManager.GetClaimsAsync(role); + var newClaims = permissions + .Where(permission => !currentClaims.Any(c => c.Type == ClaimConstants.Permission && c.Value == permission.Name)) + .Select(permission => new FshRoleClaim + { + RoleId = role.Id, + ClaimType = ClaimConstants.Permission, + ClaimValue = permission.Name, + CreatedBy = "application", + CreatedOn = timeProvider.GetUtcNow() + }) + .ToList(); + + foreach (var claim in newClaims) + { + logger.LogInformation("Seeding {Role} Permission '{Permission}' for '{TenantId}' Tenant.", role.Name, claim.ClaimValue, multiTenantContextAccessor.MultiTenantContext.TenantInfo?.Id); + await dbContext.RoleClaims.AddAsync(claim); + } + + // Save changes to the database context + if (newClaims.Count != 0) + { + await dbContext.SaveChangesAsync(); + } + + } + + private async Task SeedSystemGroupsAsync() + { + var tenantId = multiTenantContextAccessor.MultiTenantContext.TenantInfo?.Id; + if (string.IsNullOrWhiteSpace(tenantId)) + { + return; + } + + // Seed "All Users" default group - all new users are automatically added to this group + const string allUsersGroupName = "All Users"; + var allUsersGroup = await context.Groups + .FirstOrDefaultAsync(g => g.Name == allUsersGroupName && g.IsSystemGroup); + + if (allUsersGroup is null) + { + allUsersGroup = new Group + { + Name = allUsersGroupName, + Description = "Default group for all users. New users are automatically added to this group.", + IsDefault = true, + IsSystemGroup = true, + CreatedAt = timeProvider.GetUtcNow().UtcDateTime, + CreatedBy = "System" + }; + + await context.Groups.AddAsync(allUsersGroup); + logger.LogInformation("Seeding '{GroupName}' system group for '{TenantId}' Tenant.", allUsersGroupName, tenantId); + } + + // Seed "Administrators" group with Admin role + const string administratorsGroupName = "Administrators"; + var administratorsGroup = await context.Groups + .FirstOrDefaultAsync(g => g.Name == administratorsGroupName && g.IsSystemGroup); + + if (administratorsGroup is null) + { + administratorsGroup = new Group + { + Name = administratorsGroupName, + Description = "System group for administrators with full administrative privileges.", + IsDefault = false, + IsSystemGroup = true, + CreatedAt = timeProvider.GetUtcNow().UtcDateTime, + CreatedBy = "System" + }; + + await context.Groups.AddAsync(administratorsGroup); + logger.LogInformation("Seeding '{GroupName}' system group for '{TenantId}' Tenant.", administratorsGroupName, tenantId); + } + + await context.SaveChangesAsync(); + + // Assign Admin role to Administrators group + var adminRole = await roleManager.FindByNameAsync(RoleConstants.Admin); + if (adminRole is not null) + { + var existingGroupRole = await context.GroupRoles + .FirstOrDefaultAsync(gr => gr.GroupId == administratorsGroup.Id && gr.RoleId == adminRole.Id); + + if (existingGroupRole is null) + { + context.GroupRoles.Add(new GroupRole + { + GroupId = administratorsGroup.Id, + RoleId = adminRole.Id + }); + + await context.SaveChangesAsync(); + logger.LogInformation("Assigned Admin role to '{GroupName}' group for '{TenantId}' Tenant.", administratorsGroupName, tenantId); + } + } + } + + private async Task SeedAdminUserAsync() + { + if (string.IsNullOrWhiteSpace(multiTenantContextAccessor.MultiTenantContext.TenantInfo?.Id) || string.IsNullOrWhiteSpace(multiTenantContextAccessor.MultiTenantContext.TenantInfo?.AdminEmail)) + { + return; + } + + if (await userManager.Users.FirstOrDefaultAsync(u => u.Email == multiTenantContextAccessor.MultiTenantContext.TenantInfo!.AdminEmail) + is not FshUser adminUser) + { + string adminUserName = $"{multiTenantContextAccessor.MultiTenantContext.TenantInfo?.Id.Trim()}.{RoleConstants.Admin}".ToUpperInvariant(); + adminUser = new FshUser + { + FirstName = multiTenantContextAccessor.MultiTenantContext.TenantInfo?.Id.Trim().ToUpperInvariant(), + LastName = RoleConstants.Admin, + Email = multiTenantContextAccessor.MultiTenantContext.TenantInfo?.AdminEmail, + UserName = adminUserName, + EmailConfirmed = true, + PhoneNumberConfirmed = true, + NormalizedEmail = multiTenantContextAccessor.MultiTenantContext.TenantInfo?.AdminEmail!.ToUpperInvariant(), + NormalizedUserName = adminUserName.ToUpperInvariant(), + ImageUrl = new Uri(originSettings.Value.OriginUrl! + MultitenancyConstants.Root.DefaultProfilePicture), + IsActive = true + }; + + logger.LogInformation("Seeding Default Admin User for '{TenantId}' Tenant.", multiTenantContextAccessor.MultiTenantContext.TenantInfo?.Id); + var password = new PasswordHasher(); + adminUser.PasswordHash = password.HashPassword(adminUser, MultitenancyConstants.DefaultPassword); + await userManager.CreateAsync(adminUser); + } + + // Assign role to user + if (!await userManager.IsInRoleAsync(adminUser, RoleConstants.Admin)) + { + logger.LogInformation("Assigning Admin Role to Admin User for '{TenantId}' Tenant.", multiTenantContextAccessor.MultiTenantContext.TenantInfo?.Id); + await userManager.AddToRoleAsync(adminUser, RoleConstants.Admin); + } + } +} \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Data/PasswordPolicyOptions.cs b/src/Modules/Identity/Modules.Identity/Data/PasswordPolicyOptions.cs new file mode 100644 index 0000000000..bab3d8bff6 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Data/PasswordPolicyOptions.cs @@ -0,0 +1,16 @@ +namespace FSH.Modules.Identity.Data; + +public class PasswordPolicyOptions +{ + /// Number of previous passwords to keep in history (prevent reuse) + public int PasswordHistoryCount { get; set; } = 5; + + /// Number of days before password expires and must be changed + public int PasswordExpiryDays { get; set; } = 90; + + /// Number of days before expiry to show warning to user + public int PasswordExpiryWarningDays { get; set; } = 14; + + /// Set to false to disable password expiry enforcement + public bool EnforcePasswordExpiry { get; set; } = true; +} diff --git a/src/Modules/Identity/Modules.Identity/Events/TokenGeneratedLogHandler.cs b/src/Modules/Identity/Modules.Identity/Events/TokenGeneratedLogHandler.cs new file mode 100644 index 0000000000..e97d1c3ec0 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Events/TokenGeneratedLogHandler.cs @@ -0,0 +1,37 @@ +using FSH.Framework.Eventing.Abstractions; +using FSH.Modules.Identity.Contracts.Events; +using Microsoft.Extensions.Logging; + +namespace FSH.Modules.Identity.Events; + +/// +/// Example handler that logs when a token is generated. +/// This is primarily intended to make it easier to test the integration event pipeline. +/// +public sealed class TokenGeneratedLogHandler + : IIntegrationEventHandler +{ + private readonly ILogger _logger; + + public TokenGeneratedLogHandler(ILogger logger) + { + _logger = logger; + } + + public Task HandleAsync(TokenGeneratedIntegrationEvent @event, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(@event); + + _logger.LogInformation( + "Token generated for user {UserId} ({Email}) with client {ClientId}, IP {IpAddress}, UserAgent {UserAgent}, expires at {ExpiresAtUtc} (fingerprint: {Fingerprint})", + @event.UserId, + @event.Email, + @event.ClientId, + @event.IpAddress, + @event.UserAgent, + @event.AccessTokenExpiresAtUtc, + @event.TokenFingerprint); + + return Task.CompletedTask; + } +} diff --git a/src/Modules/Identity/Modules.Identity/Events/UserRegisteredEmailHandler.cs b/src/Modules/Identity/Modules.Identity/Events/UserRegisteredEmailHandler.cs new file mode 100644 index 0000000000..07e441acf6 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Events/UserRegisteredEmailHandler.cs @@ -0,0 +1,37 @@ +using FSH.Framework.Eventing.Abstractions; +using FSH.Framework.Mailing; +using FSH.Framework.Mailing.Services; +using FSH.Modules.Identity.Contracts.Events; + +namespace FSH.Modules.Identity.Events; + +/// +/// Sends a welcome email when a new user registers. +/// +public sealed class UserRegisteredEmailHandler + : IIntegrationEventHandler +{ + private readonly IMailService _mailService; + + public UserRegisteredEmailHandler(IMailService mailService) + { + _mailService = mailService; + } + + public async Task HandleAsync(UserRegisteredIntegrationEvent @event, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(@event); + + if (string.IsNullOrWhiteSpace(@event.Email)) + { + return; + } + + var mail = new MailRequest( + to: new System.Collections.ObjectModel.Collection { @event.Email }, + subject: "Welcome!", + body: $"Hi {@event.FirstName}, thanks for registering."); + + await _mailService.SendAsync(mail, ct).ConfigureAwait(false); + } +} 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 new file mode 100644 index 0000000000..672cad619d --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandHandler.cs @@ -0,0 +1,71 @@ +using FSH.Framework.Core.Context; +using FSH.Framework.Core.Exceptions; +using FSH.Modules.Identity.Contracts.v1.Groups.AddUsersToGroup; +using FSH.Modules.Identity.Data; +using Mediator; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Identity.Features.v1.Groups.AddUsersToGroup; + +public sealed class AddUsersToGroupCommandHandler : ICommandHandler +{ + private readonly IdentityDbContext _dbContext; + private readonly ICurrentUser _currentUser; + + public AddUsersToGroupCommandHandler(IdentityDbContext dbContext, ICurrentUser currentUser) + { + _dbContext = dbContext; + _currentUser = currentUser; + } + + public async ValueTask Handle(AddUsersToGroupCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + // Validate group exists + var groupExists = await _dbContext.Groups + .AnyAsync(g => g.Id == command.GroupId, cancellationToken); + + if (!groupExists) + { + throw new NotFoundException($"Group with ID '{command.GroupId}' not found."); + } + + // Validate user IDs exist + var existingUserIds = await _dbContext.Users + .Where(u => command.UserIds.Contains(u.Id)) + .Select(u => u.Id) + .ToListAsync(cancellationToken); + + var invalidUserIds = command.UserIds.Except(existingUserIds).ToList(); + if (invalidUserIds.Count > 0) + { + throw new NotFoundException($"Users not found: {string.Join(", ", invalidUserIds)}"); + } + + // Get existing memberships + var existingMemberships = await _dbContext.UserGroups + .Where(ug => ug.GroupId == command.GroupId && command.UserIds.Contains(ug.UserId)) + .Select(ug => ug.UserId) + .ToListAsync(cancellationToken); + + var alreadyMemberUserIds = existingMemberships.ToList(); + var usersToAdd = command.UserIds.Except(existingMemberships).ToList(); + + // Add new memberships + var currentUserId = _currentUser.GetUserId().ToString(); + foreach (var userId in usersToAdd) + { + _dbContext.UserGroups.Add(new UserGroup + { + UserId = userId, + GroupId = command.GroupId, + AddedBy = currentUserId + }); + } + + await _dbContext.SaveChangesAsync(cancellationToken); + + return new AddUsersToGroupResponse(usersToAdd.Count, alreadyMemberUserIds); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupEndpoint.cs new file mode 100644 index 0000000000..7484718739 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupEndpoint.cs @@ -0,0 +1,25 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Groups.AddUsersToGroup; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Groups.AddUsersToGroup; + +public static class AddUsersToGroupEndpoint +{ + public static RouteHandlerBuilder MapAddUsersToGroupEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/groups/{groupId:guid}/members", (Guid groupId, IMediator mediator, [FromBody] AddUsersRequest request, CancellationToken cancellationToken) => + mediator.Send(new AddUsersToGroupCommand(groupId, request.UserIds), cancellationToken)) + .WithName("AddUsersToGroup") + .WithSummary("Add users to a group") + .RequirePermission(IdentityPermissionConstants.Groups.ManageMembers) + .WithDescription("Add one or more users to a group. Returns count of added users and list of users already in the group."); + } +} + +public sealed record AddUsersRequest(List UserIds); 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 new file mode 100644 index 0000000000..29e67ca7fd --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandHandler.cs @@ -0,0 +1,92 @@ +using FSH.Framework.Core.Context; +using FSH.Framework.Core.Exceptions; +using FSH.Modules.Identity.Contracts.DTOs; +using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup; +using FSH.Modules.Identity.Data; +using Mediator; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Identity.Features.v1.Groups.CreateGroup; + +public sealed class CreateGroupCommandHandler : ICommandHandler +{ + private readonly IdentityDbContext _dbContext; + private readonly ICurrentUser _currentUser; + + public CreateGroupCommandHandler(IdentityDbContext dbContext, ICurrentUser currentUser) + { + _dbContext = dbContext; + _currentUser = currentUser; + } + + public async ValueTask Handle(CreateGroupCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + // Validate name is unique within tenant + var nameExists = await _dbContext.Groups + .AnyAsync(g => g.Name == command.Name, cancellationToken); + + if (nameExists) + { + throw new CustomException($"Group with name '{command.Name}' already exists.", (IEnumerable?)null, System.Net.HttpStatusCode.Conflict); + } + + // Validate role IDs exist + if (command.RoleIds is { Count: > 0 }) + { + var existingRoleIds = await _dbContext.Roles + .Where(r => command.RoleIds.Contains(r.Id)) + .Select(r => r.Id) + .ToListAsync(cancellationToken); + + var invalidRoleIds = command.RoleIds.Except(existingRoleIds).ToList(); + if (invalidRoleIds.Count > 0) + { + throw new NotFoundException($"Roles not found: {string.Join(", ", invalidRoleIds)}"); + } + } + + var group = new Group + { + Name = command.Name, + Description = command.Description, + IsDefault = command.IsDefault, + IsSystemGroup = false, + CreatedBy = _currentUser.GetUserId().ToString() + }; + + // Add role assignments + if (command.RoleIds is { Count: > 0 }) + { + foreach (var roleId in command.RoleIds) + { + group.GroupRoles.Add(new GroupRole { GroupId = group.Id, RoleId = roleId }); + } + } + + _dbContext.Groups.Add(group); + await _dbContext.SaveChangesAsync(cancellationToken); + + // Get role names for response + var roleNames = command.RoleIds is { Count: > 0 } + ? await _dbContext.Roles + .Where(r => command.RoleIds.Contains(r.Id)) + .Select(r => r.Name!) + .ToListAsync(cancellationToken) + : []; + + return new GroupDto + { + Id = group.Id, + Name = group.Name, + Description = group.Description, + IsDefault = group.IsDefault, + IsSystemGroup = group.IsSystemGroup, + MemberCount = 0, + RoleIds = command.RoleIds?.AsReadOnly(), + RoleNames = roleNames.AsReadOnly(), + CreatedAt = group.CreatedAt + }; + } +} 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 new file mode 100644 index 0000000000..b3f69751f0 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup; + +namespace FSH.Modules.Identity.Features.v1.Groups.CreateGroup; + +public sealed class CreateGroupCommandValidator : AbstractValidator +{ + public CreateGroupCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Group name is required.") + .MaximumLength(256).WithMessage("Group name must not exceed 256 characters."); + + RuleFor(x => x.Description) + .MaximumLength(1024).WithMessage("Description must not exceed 1024 characters."); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupEndpoint.cs new file mode 100644 index 0000000000..07e3f0befe --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupEndpoint.cs @@ -0,0 +1,23 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Groups.CreateGroup; + +public static class CreateGroupEndpoint +{ + public static RouteHandlerBuilder MapCreateGroupEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/groups", (IMediator mediator, [FromBody] CreateGroupCommand request, CancellationToken cancellationToken) => + mediator.Send(request, cancellationToken)) + .WithName("CreateGroup") + .WithSummary("Create a new group") + .RequirePermission(IdentityPermissionConstants.Groups.Create) + .WithDescription("Create a new group with optional role assignments."); + } +} 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 new file mode 100644 index 0000000000..7df540127b --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandHandler.cs @@ -0,0 +1,43 @@ +using FSH.Framework.Core.Context; +using FSH.Framework.Core.Exceptions; +using FSH.Modules.Identity.Contracts.v1.Groups.DeleteGroup; +using FSH.Modules.Identity.Data; +using Mediator; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Identity.Features.v1.Groups.DeleteGroup; + +public sealed class DeleteGroupCommandHandler : ICommandHandler +{ + private readonly IdentityDbContext _dbContext; + private readonly ICurrentUser _currentUser; + + public DeleteGroupCommandHandler(IdentityDbContext dbContext, ICurrentUser currentUser) + { + _dbContext = dbContext; + _currentUser = currentUser; + } + + public async ValueTask Handle(DeleteGroupCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + var group = await _dbContext.Groups + .FirstOrDefaultAsync(g => g.Id == command.Id, cancellationToken) + ?? throw new NotFoundException($"Group with ID '{command.Id}' not found."); + + if (group.IsSystemGroup) + { + throw new ForbiddenException("System groups cannot be deleted."); + } + + // Soft delete + group.IsDeleted = true; + group.DeletedOnUtc = DateTimeOffset.UtcNow; + group.DeletedBy = _currentUser.GetUserId().ToString(); + + await _dbContext.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupEndpoint.cs new file mode 100644 index 0000000000..4344593137 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupEndpoint.cs @@ -0,0 +1,22 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Groups.DeleteGroup; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Groups.DeleteGroup; + +public static class DeleteGroupEndpoint +{ + public static RouteHandlerBuilder MapDeleteGroupEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapDelete("/groups/{id:guid}", (Guid id, IMediator mediator, CancellationToken cancellationToken) => + mediator.Send(new DeleteGroupCommand(id), cancellationToken)) + .WithName("DeleteGroup") + .WithSummary("Delete a group") + .RequirePermission(IdentityPermissionConstants.Groups.Delete) + .WithDescription("Soft delete a group. System groups cannot be deleted."); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupById/GetGroupByIdEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupById/GetGroupByIdEndpoint.cs new file mode 100644 index 0000000000..efedcdc998 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupById/GetGroupByIdEndpoint.cs @@ -0,0 +1,22 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Groups.GetGroupById; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Groups.GetGroupById; + +public static class GetGroupByIdEndpoint +{ + public static RouteHandlerBuilder MapGetGroupByIdEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/groups/{id:guid}", (Guid id, IMediator mediator, CancellationToken cancellationToken) => + mediator.Send(new GetGroupByIdQuery(id), cancellationToken)) + .WithName("GetGroupById") + .WithSummary("Get group by ID") + .RequirePermission(IdentityPermissionConstants.Groups.View) + .WithDescription("Retrieve a specific group by its ID including roles and member count."); + } +} 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 new file mode 100644 index 0000000000..d36548f06e --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupById/GetGroupByIdQueryHandler.cs @@ -0,0 +1,50 @@ +using FSH.Framework.Core.Exceptions; +using FSH.Modules.Identity.Contracts.DTOs; +using FSH.Modules.Identity.Contracts.v1.Groups.GetGroupById; +using FSH.Modules.Identity.Data; +using Mediator; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Identity.Features.v1.Groups.GetGroupById; + +public sealed class GetGroupByIdQueryHandler : IQueryHandler +{ + private readonly IdentityDbContext _dbContext; + + public GetGroupByIdQueryHandler(IdentityDbContext dbContext) + { + _dbContext = dbContext; + } + + public async ValueTask Handle(GetGroupByIdQuery query, CancellationToken cancellationToken) + { + var group = await _dbContext.Groups + .Include(g => g.GroupRoles) + .FirstOrDefaultAsync(g => g.Id == query.Id, cancellationToken) + ?? throw new NotFoundException($"Group with ID '{query.Id}' not found."); + + var memberCount = await _dbContext.UserGroups + .CountAsync(ug => ug.GroupId == group.Id, cancellationToken); + + var roleIds = group.GroupRoles.Select(gr => gr.RoleId).ToList(); + var roleNames = roleIds.Count > 0 + ? await _dbContext.Roles + .Where(r => roleIds.Contains(r.Id)) + .Select(r => r.Name!) + .ToListAsync(cancellationToken) + : []; + + return new GroupDto + { + Id = group.Id, + Name = group.Name, + Description = group.Description, + IsDefault = group.IsDefault, + IsSystemGroup = group.IsSystemGroup, + MemberCount = memberCount, + RoleIds = roleIds.AsReadOnly(), + RoleNames = roleNames.AsReadOnly(), + CreatedAt = group.CreatedAt + }; + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupMembers/GetGroupMembersEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupMembers/GetGroupMembersEndpoint.cs new file mode 100644 index 0000000000..4892976c01 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupMembers/GetGroupMembersEndpoint.cs @@ -0,0 +1,22 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Groups.GetGroupMembers; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Groups.GetGroupMembers; + +public static class GetGroupMembersEndpoint +{ + public static RouteHandlerBuilder MapGetGroupMembersEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/groups/{groupId:guid}/members", (Guid groupId, IMediator mediator, CancellationToken cancellationToken) => + mediator.Send(new GetGroupMembersQuery(groupId), cancellationToken)) + .WithName("GetGroupMembers") + .WithSummary("Get members of a group") + .RequirePermission(IdentityPermissionConstants.Groups.View) + .WithDescription("Retrieve all users that belong to a specific group."); + } +} 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 new file mode 100644 index 0000000000..8efd07f9fa --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupMembers/GetGroupMembersQueryHandler.cs @@ -0,0 +1,52 @@ +using FSH.Framework.Core.Exceptions; +using FSH.Modules.Identity.Contracts.DTOs; +using FSH.Modules.Identity.Contracts.v1.Groups.GetGroupMembers; +using FSH.Modules.Identity.Data; +using Mediator; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Identity.Features.v1.Groups.GetGroupMembers; + +public sealed class GetGroupMembersQueryHandler : IQueryHandler> +{ + private readonly IdentityDbContext _dbContext; + + public GetGroupMembersQueryHandler(IdentityDbContext dbContext) + { + _dbContext = dbContext; + } + + public async ValueTask> Handle(GetGroupMembersQuery query, CancellationToken cancellationToken) + { + // Validate group exists + var groupExists = await _dbContext.Groups + .AnyAsync(g => g.Id == query.GroupId, cancellationToken); + + if (!groupExists) + { + throw new NotFoundException($"Group with ID '{query.GroupId}' not found."); + } + + // Get memberships with user info + var memberships = await _dbContext.UserGroups + .Where(ug => ug.GroupId == query.GroupId) + .Join( + _dbContext.Users, + ug => ug.UserId, + u => u.Id, + (ug, u) => new GroupMemberDto + { + UserId = u.Id, + UserName = u.UserName, + Email = u.Email, + FirstName = u.FirstName, + LastName = u.LastName, + AddedAt = ug.AddedAt, + AddedBy = ug.AddedBy + }) + .OrderBy(m => m.UserName) + .ToListAsync(cancellationToken); + + return memberships; + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroups/GetGroupsEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroups/GetGroupsEndpoint.cs new file mode 100644 index 0000000000..fbe9eab2c1 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroups/GetGroupsEndpoint.cs @@ -0,0 +1,22 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Groups.GetGroups; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Groups.GetGroups; + +public static class GetGroupsEndpoint +{ + public static RouteHandlerBuilder MapGetGroupsEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/groups", (IMediator mediator, string? search, CancellationToken cancellationToken) => + mediator.Send(new GetGroupsQuery(search), cancellationToken)) + .WithName("ListGroups") + .WithSummary("List all groups") + .RequirePermission(IdentityPermissionConstants.Groups.View) + .WithDescription("Retrieve all groups for the current tenant with optional search filter."); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroups/GetGroupsQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroups/GetGroupsQueryHandler.cs new file mode 100644 index 0000000000..048e9cd1c5 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroups/GetGroupsQueryHandler.cs @@ -0,0 +1,71 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using FSH.Modules.Identity.Contracts.v1.Groups.GetGroups; +using FSH.Modules.Identity.Data; +using Mediator; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Identity.Features.v1.Groups.GetGroups; + +public sealed class GetGroupsQueryHandler : IQueryHandler> +{ + private readonly IdentityDbContext _dbContext; + + public GetGroupsQueryHandler(IdentityDbContext dbContext) + { + _dbContext = dbContext; + } + + public async ValueTask> Handle(GetGroupsQuery query, CancellationToken cancellationToken) + { + var groupsQuery = _dbContext.Groups + .Include(g => g.GroupRoles) + .AsQueryable(); + + // Apply search filter + if (!string.IsNullOrWhiteSpace(query.SearchTerm)) + { + var searchTerm = query.SearchTerm.ToLowerInvariant(); + groupsQuery = groupsQuery.Where(g => + g.Name.ToLower().Contains(searchTerm) || + (g.Description != null && g.Description.ToLower().Contains(searchTerm))); + } + + var groups = await groupsQuery + .OrderBy(g => g.Name) + .ToListAsync(cancellationToken); + + // Get member counts in one query + var groupIds = groups.Select(g => g.Id).ToList(); + var memberCounts = await _dbContext.UserGroups + .Where(ug => groupIds.Contains(ug.GroupId)) + .GroupBy(ug => ug.GroupId) + .Select(g => new { GroupId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.GroupId, x => x.Count, cancellationToken); + + // Get all role IDs from groups + var allRoleIds = groups + .SelectMany(g => g.GroupRoles.Select(gr => gr.RoleId)) + .Distinct() + .ToList(); + + var roleNames = await _dbContext.Roles + .Where(r => allRoleIds.Contains(r.Id)) + .ToDictionaryAsync(r => r.Id, r => r.Name!, cancellationToken); + + return groups.Select(g => new GroupDto + { + Id = g.Id, + Name = g.Name, + Description = g.Description, + IsDefault = g.IsDefault, + IsSystemGroup = g.IsSystemGroup, + MemberCount = memberCounts.GetValueOrDefault(g.Id, 0), + RoleIds = g.GroupRoles.Select(gr => gr.RoleId).ToList().AsReadOnly(), + RoleNames = g.GroupRoles + .Select(gr => roleNames.GetValueOrDefault(gr.RoleId, gr.RoleId)) + .ToList() + .AsReadOnly(), + CreatedAt = g.CreatedAt + }); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/Group.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/Group.cs new file mode 100644 index 0000000000..f8dff5fff0 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/Group.cs @@ -0,0 +1,26 @@ +using FSH.Framework.Core.Domain; +using FSH.Modules.Identity.Features.v1.Roles; + +namespace FSH.Modules.Identity.Features.v1.Groups; + +public class Group : ISoftDeletable +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = default!; + public string? Description { get; set; } + public bool IsDefault { get; set; } + public bool IsSystemGroup { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public string? CreatedBy { get; set; } + public DateTime? ModifiedAt { get; set; } + public string? ModifiedBy { get; set; } + + // ISoftDeletable implementation + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedOnUtc { get; set; } + public string? DeletedBy { get; set; } + + // Navigation properties + public virtual ICollection GroupRoles { get; set; } = []; + public virtual ICollection UserGroups { get; set; } = []; +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GroupRole.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GroupRole.cs new file mode 100644 index 0000000000..dc323046af --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GroupRole.cs @@ -0,0 +1,13 @@ +using FSH.Modules.Identity.Features.v1.Roles; + +namespace FSH.Modules.Identity.Features.v1.Groups; + +public class GroupRole +{ + public Guid GroupId { get; set; } + public string RoleId { get; set; } = default!; + + // Navigation properties + public virtual Group? Group { get; set; } + public virtual FshRole? Role { get; set; } +} 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 new file mode 100644 index 0000000000..19d91d985e --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandHandler.cs @@ -0,0 +1,35 @@ +using FSH.Framework.Core.Exceptions; +using FSH.Modules.Identity.Contracts.v1.Groups.RemoveUserFromGroup; +using FSH.Modules.Identity.Data; +using Mediator; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Identity.Features.v1.Groups.RemoveUserFromGroup; + +public sealed class RemoveUserFromGroupCommandHandler : ICommandHandler +{ + private readonly IdentityDbContext _dbContext; + + public RemoveUserFromGroupCommandHandler(IdentityDbContext dbContext) + { + _dbContext = dbContext; + } + + public async ValueTask Handle(RemoveUserFromGroupCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + var membership = await _dbContext.UserGroups + .FirstOrDefaultAsync(ug => ug.GroupId == command.GroupId && ug.UserId == command.UserId, cancellationToken); + + if (membership is null) + { + throw new NotFoundException($"User '{command.UserId}' is not a member of group '{command.GroupId}'."); + } + + _dbContext.UserGroups.Remove(membership); + await _dbContext.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupEndpoint.cs new file mode 100644 index 0000000000..df6c845ab3 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupEndpoint.cs @@ -0,0 +1,22 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Groups.RemoveUserFromGroup; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Groups.RemoveUserFromGroup; + +public static class RemoveUserFromGroupEndpoint +{ + public static RouteHandlerBuilder MapRemoveUserFromGroupEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapDelete("/groups/{groupId:guid}/members/{userId}", (Guid groupId, string userId, IMediator mediator, CancellationToken cancellationToken) => + mediator.Send(new RemoveUserFromGroupCommand(groupId, userId), cancellationToken)) + .WithName("RemoveUserFromGroup") + .WithSummary("Remove a user from a group") + .RequirePermission(IdentityPermissionConstants.Groups.ManageMembers) + .WithDescription("Remove a specific user from a group."); + } +} 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 new file mode 100644 index 0000000000..68c35007ce --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandHandler.cs @@ -0,0 +1,105 @@ +using FSH.Framework.Core.Context; +using FSH.Framework.Core.Exceptions; +using FSH.Modules.Identity.Contracts.DTOs; +using FSH.Modules.Identity.Contracts.v1.Groups.UpdateGroup; +using FSH.Modules.Identity.Data; +using Mediator; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Identity.Features.v1.Groups.UpdateGroup; + +public sealed class UpdateGroupCommandHandler : ICommandHandler +{ + private readonly IdentityDbContext _dbContext; + private readonly ICurrentUser _currentUser; + + public UpdateGroupCommandHandler(IdentityDbContext dbContext, ICurrentUser currentUser) + { + _dbContext = dbContext; + _currentUser = currentUser; + } + + public async ValueTask Handle(UpdateGroupCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + var group = await _dbContext.Groups + .Include(g => g.GroupRoles) + .FirstOrDefaultAsync(g => g.Id == command.Id, cancellationToken) + ?? throw new NotFoundException($"Group with ID '{command.Id}' not found."); + + // Validate name is unique within tenant (excluding self) + var nameExists = await _dbContext.Groups + .AnyAsync(g => g.Name == command.Name && g.Id != command.Id, cancellationToken); + + if (nameExists) + { + throw new CustomException($"Group with name '{command.Name}' already exists.", (IEnumerable?)null, System.Net.HttpStatusCode.Conflict); + } + + // Validate role IDs exist + if (command.RoleIds is { Count: > 0 }) + { + var existingRoleIds = await _dbContext.Roles + .Where(r => command.RoleIds.Contains(r.Id)) + .Select(r => r.Id) + .ToListAsync(cancellationToken); + + var invalidRoleIds = command.RoleIds.Except(existingRoleIds).ToList(); + if (invalidRoleIds.Count > 0) + { + throw new NotFoundException($"Roles not found: {string.Join(", ", invalidRoleIds)}"); + } + } + + // Update group properties + group.Name = command.Name; + group.Description = command.Description; + group.IsDefault = command.IsDefault; + group.ModifiedAt = DateTime.UtcNow; + group.ModifiedBy = _currentUser.GetUserId().ToString(); + + // Update role assignments + var currentRoleIds = group.GroupRoles.Select(gr => gr.RoleId).ToHashSet(); + var newRoleIds = command.RoleIds?.ToHashSet() ?? []; + + // Remove roles no longer assigned + var rolesToRemove = group.GroupRoles.Where(gr => !newRoleIds.Contains(gr.RoleId)).ToList(); + foreach (var role in rolesToRemove) + { + group.GroupRoles.Remove(role); + } + + // Add new role assignments + foreach (var roleId in newRoleIds.Where(id => !currentRoleIds.Contains(id))) + { + group.GroupRoles.Add(new GroupRole { GroupId = group.Id, RoleId = roleId }); + } + + await _dbContext.SaveChangesAsync(cancellationToken); + + // Get member count and role names for response + var memberCount = await _dbContext.UserGroups + .CountAsync(ug => ug.GroupId == group.Id, cancellationToken); + + var roleNames = newRoleIds.Count > 0 + ? await _dbContext.Roles + .Where(r => newRoleIds.Contains(r.Id)) + .Select(r => r.Name!) + .ToListAsync(cancellationToken) + : []; + + return new GroupDto + { + Id = group.Id, + Name = group.Name, + Description = group.Description, + IsDefault = group.IsDefault, + IsSystemGroup = group.IsSystemGroup, + MemberCount = memberCount, + RoleIds = newRoleIds.ToList().AsReadOnly(), + RoleNames = roleNames.AsReadOnly(), + CreatedAt = group.CreatedAt + }; + } +} 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 new file mode 100644 index 0000000000..c9bcb5ae2e --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using FSH.Modules.Identity.Contracts.v1.Groups.UpdateGroup; + +namespace FSH.Modules.Identity.Features.v1.Groups.UpdateGroup; + +public sealed class UpdateGroupCommandValidator : AbstractValidator +{ + public UpdateGroupCommandValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("Group ID is required."); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Group name is required.") + .MaximumLength(256).WithMessage("Group name must not exceed 256 characters."); + + RuleFor(x => x.Description) + .MaximumLength(1024).WithMessage("Description must not exceed 1024 characters."); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupEndpoint.cs new file mode 100644 index 0000000000..6eee9dd1d7 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupEndpoint.cs @@ -0,0 +1,29 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Groups.UpdateGroup; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Groups.UpdateGroup; + +public static class UpdateGroupEndpoint +{ + public static RouteHandlerBuilder MapUpdateGroupEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPut("/groups/{id:guid}", (Guid id, IMediator mediator, [FromBody] UpdateGroupRequest request, CancellationToken cancellationToken) => + mediator.Send(new UpdateGroupCommand(id, request.Name, request.Description, request.IsDefault, request.RoleIds), cancellationToken)) + .WithName("UpdateGroup") + .WithSummary("Update a group") + .RequirePermission(IdentityPermissionConstants.Groups.Update) + .WithDescription("Update a group's name, description, default status, and role assignments."); + } +} + +public sealed record UpdateGroupRequest( + string Name, + string? Description, + bool IsDefault, + List? RoleIds); diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UserGroup.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UserGroup.cs new file mode 100644 index 0000000000..db8e585b52 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UserGroup.cs @@ -0,0 +1,15 @@ +using FSH.Modules.Identity.Features.v1.Users; + +namespace FSH.Modules.Identity.Features.v1.Groups; + +public class UserGroup +{ + public string UserId { get; set; } = default!; + public Guid GroupId { get; set; } + public DateTime AddedAt { get; set; } = DateTime.UtcNow; + public string? AddedBy { get; set; } + + // Navigation properties + public virtual FshUser? User { get; set; } + public virtual Group? Group { get; set; } +} diff --git a/src/api/framework/Infrastructure/Identity/RoleClaims/FshRoleClaim.cs b/src/Modules/Identity/Modules.Identity/Features/v1/RoleClaims/FshRoleClaim.cs similarity index 75% rename from src/api/framework/Infrastructure/Identity/RoleClaims/FshRoleClaim.cs rename to src/Modules/Identity/Modules.Identity/Features/v1/RoleClaims/FshRoleClaim.cs index 210fa1bec6..d8a124ba5b 100644 --- a/src/api/framework/Infrastructure/Identity/RoleClaims/FshRoleClaim.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/RoleClaims/FshRoleClaim.cs @@ -1,8 +1,9 @@ using Microsoft.AspNetCore.Identity; -namespace FSH.Framework.Infrastructure.Identity.RoleClaims; +namespace FSH.Modules.Identity.Features.v1.RoleClaims; + public class FshRoleClaim : IdentityRoleClaim { public string? CreatedBy { get; init; } public DateTimeOffset CreatedOn { get; init; } -} +} \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandHandler.cs new file mode 100644 index 0000000000..901ee7e7b1 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandHandler.cs @@ -0,0 +1,24 @@ +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Roles.DeleteRole; +using Mediator; + +namespace FSH.Modules.Identity.Features.v1.Roles.DeleteRole; + +public sealed class DeleteRoleCommandHandler : ICommandHandler +{ + private readonly IRoleService _roleService; + + public DeleteRoleCommandHandler(IRoleService roleService) + { + _roleService = roleService; + } + + public async ValueTask Handle(DeleteRoleCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + await _roleService.DeleteRoleAsync(command.Id).ConfigureAwait(false); + + return Unit.Value; + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleEndpoint.cs new file mode 100644 index 0000000000..32fddd72da --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleEndpoint.cs @@ -0,0 +1,25 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Roles.DeleteRole; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Roles.DeleteRole; + +public static class DeleteRoleEndpoint +{ + public static RouteHandlerBuilder MapDeleteRoleEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapDelete("/roles/{id:guid}", async (string id, IMediator mediator, CancellationToken cancellationToken) => + { + await mediator.Send(new DeleteRoleCommand(id), cancellationToken); + return Results.NoContent(); + }) + .WithName("DeleteRole") + .WithSummary("Delete role by ID") + .RequirePermission(IdentityPermissionConstants.Roles.Delete) + .WithDescription("Remove an existing role by its unique identifier."); + } +} diff --git a/src/api/framework/Infrastructure/Identity/Roles/FshRole.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/FshRole.cs similarity index 75% rename from src/api/framework/Infrastructure/Identity/Roles/FshRole.cs rename to src/Modules/Identity/Modules.Identity/Features/v1/Roles/FshRole.cs index fa8baf6bc6..c972de1cfd 100644 --- a/src/api/framework/Infrastructure/Identity/Roles/FshRole.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/FshRole.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Identity; -namespace FSH.Framework.Infrastructure.Identity.Roles; +namespace FSH.Modules.Identity.Features.v1.Roles; + public class FshRole : IdentityRole { public string? Description { get; set; } @@ -8,6 +9,8 @@ public class FshRole : IdentityRole public FshRole(string name, string? description = null) : base(name) { + ArgumentNullException.ThrowIfNull(name); + Description = description; NormalizedName = name.ToUpperInvariant(); } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoleById/GetRoleByIdEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoleById/GetRoleByIdEndpoint.cs new file mode 100644 index 0000000000..c43e9cff1b --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoleById/GetRoleByIdEndpoint.cs @@ -0,0 +1,22 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Roles.GetRole; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Roles.GetRoleById; + +public static class GetRoleByIdEndpoint +{ + public static RouteHandlerBuilder MapGetRoleByIdEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/roles/{id:guid}", (string id, IMediator mediator, CancellationToken cancellationToken) => + mediator.Send(new GetRoleQuery(id), cancellationToken)) + .WithName("GetRole") + .WithSummary("Get role by ID") + .RequirePermission(IdentityPermissionConstants.Roles.View) + .WithDescription("Retrieve details of a specific role by its unique identifier."); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoleById/GetRoleByIdQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoleById/GetRoleByIdQueryHandler.cs new file mode 100644 index 0000000000..85b21daf4b --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoleById/GetRoleByIdQueryHandler.cs @@ -0,0 +1,22 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Roles.GetRole; +using Mediator; + +namespace FSH.Modules.Identity.Features.v1.Roles.GetRoleById; + +public sealed class GetRoleByIdQueryHandler : IQueryHandler +{ + private readonly IRoleService _roleService; + + public GetRoleByIdQueryHandler(IRoleService roleService) + { + _roleService = roleService; + } + + public async ValueTask Handle(GetRoleQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + return await _roleService.GetRoleAsync(query.Id).ConfigureAwait(false); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoleWithPermissions/GetRolePermissionsEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoleWithPermissions/GetRolePermissionsEndpoint.cs new file mode 100644 index 0000000000..e7e733c4fa --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoleWithPermissions/GetRolePermissionsEndpoint.cs @@ -0,0 +1,22 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Roles.GetRoleWithPermissions; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Roles.GetRoleWithPermissions; + +public static class GetRolePermissionsEndpoint +{ + public static RouteHandlerBuilder MapGetRolePermissionsEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/{id:guid}/permissions", (string id, IMediator mediator, CancellationToken cancellationToken) => + mediator.Send(new GetRoleWithPermissionsQuery(id), cancellationToken)) + .WithName("GetRolePermissions") + .WithSummary("Get role permissions") + .RequirePermission(IdentityPermissionConstants.Roles.View) + .WithDescription("Retrieve a role along with its assigned permissions."); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoleWithPermissions/GetRoleWithPermissionsQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoleWithPermissions/GetRoleWithPermissionsQueryHandler.cs new file mode 100644 index 0000000000..798240e740 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoleWithPermissions/GetRoleWithPermissionsQueryHandler.cs @@ -0,0 +1,22 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Roles.GetRoleWithPermissions; +using Mediator; + +namespace FSH.Modules.Identity.Features.v1.Roles.GetRoleWithPermissions; + +public sealed class GetRoleWithPermissionsQueryHandler : IQueryHandler +{ + private readonly IRoleService _roleService; + + public GetRoleWithPermissionsQueryHandler(IRoleService roleService) + { + _roleService = roleService; + } + + public async ValueTask Handle(GetRoleWithPermissionsQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + return await _roleService.GetWithPermissionsAsync(query.Id, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesEndpoint.cs new file mode 100644 index 0000000000..8fdac7403b --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesEndpoint.cs @@ -0,0 +1,22 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Roles.GetRoles; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Roles.GetRoles; + +public static class GetRolesEndpoint +{ + public static RouteHandlerBuilder MapGetRolesEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/roles", (IMediator mediator, CancellationToken cancellationToken) => + mediator.Send(new GetRolesQuery(), cancellationToken)) + .WithName("ListRoles") + .WithSummary("List all roles") + .RequirePermission(IdentityPermissionConstants.Roles.View) + .WithDescription("Retrieve all roles available for the current tenant."); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesQueryHandler.cs new file mode 100644 index 0000000000..b54e4aa178 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesQueryHandler.cs @@ -0,0 +1,21 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Roles.GetRoles; +using Mediator; + +namespace FSH.Modules.Identity.Features.v1.Roles.GetRoles; + +public sealed class GetRolesQueryHandler : IQueryHandler> +{ + private readonly IRoleService _roleService; + + public GetRolesQueryHandler(IRoleService roleService) + { + _roleService = roleService; + } + + public async ValueTask> Handle(GetRolesQuery query, CancellationToken cancellationToken) + { + return await _roleService.GetRolesAsync().ConfigureAwait(false); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs new file mode 100644 index 0000000000..d48b35772a --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs @@ -0,0 +1,134 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Core.Context; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Shared.Constants; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Identity.Contracts.DTOs; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Features.v1.RoleClaims; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Identity.Features.v1.Roles; + +public class RoleService(RoleManager roleManager, + IdentityDbContext context, + IMultiTenantContextAccessor multiTenantContextAccessor, + ICurrentUser currentUser) : IRoleService +{ + public async Task> GetRolesAsync() + { + if (roleManager is null) + throw new NotFoundException("RoleManager not resolved. Check Identity registration."); + + if (roleManager.Roles is null) + throw new NotFoundException("Role store not configured. Ensure .AddRoles() and EF stores."); + + + var roles = await roleManager.Roles + .Select(role => new RoleDto { Id = role.Id, Name = role.Name!, Description = role.Description }) + .ToListAsync(); + + return roles; + } + + public async Task GetRoleAsync(string id) + { + FshRole? role = await roleManager.FindByIdAsync(id); + + _ = role ?? throw new NotFoundException("role not found"); + + return new RoleDto { Id = role.Id, Name = role.Name!, Description = role.Description }; + } + + public async Task CreateOrUpdateRoleAsync(string roleId, string name, string description) + { + FshRole? role = await roleManager.FindByIdAsync(roleId); + + if (role != null) + { + role.Name = name; + role.Description = description; + await roleManager.UpdateAsync(role); + } + else + { + role = new FshRole(name, description); + await roleManager.CreateAsync(role); + } + + return new RoleDto { Id = role.Id, Name = role.Name!, Description = role.Description }; + } + + public async Task DeleteRoleAsync(string id) + { + FshRole? role = await roleManager.FindByIdAsync(id); + + _ = role ?? throw new NotFoundException("role not found"); + + await roleManager.DeleteAsync(role); + } + + public async Task GetWithPermissionsAsync(string id, CancellationToken cancellationToken) + { + var role = await GetRoleAsync(id); + _ = role ?? throw new NotFoundException("role not found"); + + role.Permissions = await context.RoleClaims + .Where(c => c.RoleId == id && c.ClaimType == ClaimConstants.Permission) + .Select(c => c.ClaimValue!) + .ToListAsync(cancellationToken); + + return role; + } + + public async Task UpdatePermissionsAsync(string roleId, List permissions) + { + ArgumentNullException.ThrowIfNull(permissions); + + var role = await roleManager.FindByIdAsync(roleId); + _ = role ?? throw new NotFoundException("role not found"); + if (role.Name == RoleConstants.Admin) + { + throw new CustomException("operation not permitted"); + } + + if (multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id != MultitenancyConstants.Root.Id) + { + // Remove Root Permissions if the Role is not created for Root Tenant. + permissions.RemoveAll(u => u.StartsWith("Permissions.Root.", StringComparison.InvariantCultureIgnoreCase)); + } + + var currentClaims = await roleManager.GetClaimsAsync(role); + + // Remove permissions that were previously selected + foreach (var claim in currentClaims.Where(c => !permissions.Exists(p => p == c.Value))) + { + var result = await roleManager.RemoveClaimAsync(role, claim); + if (!result.Succeeded) + { + var errors = result.Errors.Select(error => error.Description).ToList(); + throw new CustomException("operation failed", errors); + } + } + + // Add all permissions that were not previously selected + foreach (string permission in permissions.Where(c => !currentClaims.Any(p => p.Value == c))) + { + if (!string.IsNullOrEmpty(permission)) + { + context.RoleClaims.Add(new FshRoleClaim + { + RoleId = role.Id, + ClaimType = ClaimConstants.Permission, + ClaimValue = permission, + CreatedBy = currentUser.GetUserId().ToString() + }); + await context.SaveChangesAsync(); + } + } + + return "permissions updated"; + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpdateRolePermissions/UpdatePermissionsCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpdateRolePermissions/UpdatePermissionsCommandHandler.cs new file mode 100644 index 0000000000..4104057f88 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpdateRolePermissions/UpdatePermissionsCommandHandler.cs @@ -0,0 +1,21 @@ +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Roles.UpdatePermissions; +using Mediator; + +namespace FSH.Modules.Identity.Features.v1.Roles.UpdateRolePermissions; + +public sealed class UpdatePermissionsCommandHandler : ICommandHandler +{ + private readonly IRoleService _roleService; + + public UpdatePermissionsCommandHandler(IRoleService roleService) + { + _roleService = roleService; + } + + public async ValueTask Handle(UpdatePermissionsCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + return await _roleService.UpdatePermissionsAsync(command.RoleId, command.Permissions).ConfigureAwait(false); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpdateRolePermissions/UpdatePermissionsCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpdateRolePermissions/UpdatePermissionsCommandValidator.cs new file mode 100644 index 0000000000..a83151342d --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpdateRolePermissions/UpdatePermissionsCommandValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; +using FSH.Modules.Identity.Contracts.v1.Roles.UpdatePermissions; + +namespace FSH.Modules.Identity.Features.v1.Roles.UpdateRolePermissions; + +public sealed class UpdatePermissionsCommandValidator : AbstractValidator +{ + public UpdatePermissionsCommandValidator() + { + RuleFor(r => r.RoleId) + .NotEmpty(); + RuleFor(r => r.Permissions) + .NotNull(); + } +} \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpdateRolePermissions/UpdateRolePermissionsEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpdateRolePermissions/UpdateRolePermissionsEndpoint.cs new file mode 100644 index 0000000000..c3587b4df4 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpdateRolePermissions/UpdateRolePermissionsEndpoint.cs @@ -0,0 +1,35 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Roles.UpdatePermissions; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Roles.UpdateRolePermissions; + +public static class UpdateRolePermissionsEndpoint +{ + public static RouteHandlerBuilder MapUpdateRolePermissionsEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPut("/{id}/permissions", async ( + string id, + [FromBody] UpdatePermissionsCommand request, + IMediator mediator, + CancellationToken cancellationToken) => + { + if (id != request.RoleId) + { + return Results.BadRequest(); + } + + var response = await mediator.Send(request, cancellationToken); + return Results.Ok(response); + }) + .WithName("UpdateRolePermissions") + .WithSummary("Update role permissions") + .RequirePermission(IdentityPermissionConstants.Roles.Update) + .WithDescription("Replace the set of permissions assigned to a role."); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/CreateOrUpdateRoleEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/CreateOrUpdateRoleEndpoint.cs new file mode 100644 index 0000000000..035d86f34f --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/CreateOrUpdateRoleEndpoint.cs @@ -0,0 +1,23 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Roles.UpsertRole; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Roles.UpsertRole; + +public static class CreateOrUpdateRoleEndpoint +{ + public static RouteHandlerBuilder MapCreateOrUpdateRoleEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/roles", (IMediator mediator, [FromBody] UpsertRoleCommand request, CancellationToken cancellationToken) => + mediator.Send(request, cancellationToken)) + .WithName("CreateOrUpdateRole") + .WithSummary("Create or update role") + .RequirePermission(IdentityPermissionConstants.Roles.Create) + .WithDescription("Create a new role or update an existing role's name and description."); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandHandler.cs new file mode 100644 index 0000000000..39d07e02ae --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandHandler.cs @@ -0,0 +1,24 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Roles.UpsertRole; +using Mediator; + +namespace FSH.Modules.Identity.Features.v1.Roles.UpsertRole; + +public sealed class UpsertRoleCommandHandler : ICommandHandler +{ + private readonly IRoleService _roleService; + + public UpsertRoleCommandHandler(IRoleService roleService) + { + _roleService = roleService; + } + + public async ValueTask Handle(UpsertRoleCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + return await _roleService.CreateOrUpdateRoleAsync(command.Id, command.Name, command.Description ?? string.Empty) + .ConfigureAwait(false); + } +} 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 new file mode 100644 index 0000000000..e206420a88 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; +using FSH.Modules.Identity.Contracts.v1.Roles.UpsertRole; + +namespace FSH.Modules.Identity.Features.v1.Roles.UpsertRole; + +public sealed class UpsertRoleCommandValidator : AbstractValidator +{ + public UpsertRoleCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().WithMessage("Role name is required."); + } +} \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandHandler.cs new file mode 100644 index 0000000000..a3804d621e --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandHandler.cs @@ -0,0 +1,28 @@ +using FSH.Framework.Core.Context; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Sessions.AdminRevokeAllSessions; +using Mediator; + +namespace FSH.Modules.Identity.Features.v1.Sessions.AdminRevokeAllSessions; + +public sealed class AdminRevokeAllSessionsCommandHandler : ICommandHandler +{ + private readonly ISessionService _sessionService; + private readonly ICurrentUser _currentUser; + + public AdminRevokeAllSessionsCommandHandler(ISessionService sessionService, ICurrentUser currentUser) + { + _sessionService = sessionService; + _currentUser = currentUser; + } + + public async ValueTask Handle(AdminRevokeAllSessionsCommand command, CancellationToken cancellationToken) + { + var adminId = _currentUser.GetUserId().ToString(); + return await _sessionService.RevokeAllSessionsForAdminAsync( + command.UserId.ToString(), + adminId, + command.Reason ?? "Revoked by administrator", + cancellationToken); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsEndpoint.cs new file mode 100644 index 0000000000..6927b53a94 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsEndpoint.cs @@ -0,0 +1,25 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Sessions.AdminRevokeAllSessions; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Sessions.AdminRevokeAllSessions; + +public static class AdminRevokeAllSessionsEndpoint +{ + internal static RouteHandlerBuilder MapAdminRevokeAllSessionsEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/users/{userId:guid}/sessions/revoke-all", async (Guid userId, AdminRevokeAllSessionsCommand? command, IMediator mediator, CancellationToken cancellationToken) => + { + var result = await mediator.Send(command ?? new AdminRevokeAllSessionsCommand(userId), cancellationToken); + return TypedResults.Ok(new { RevokedCount = result }); + }) + .WithName("AdminRevokeAllSessions") + .WithSummary("Revoke all user's sessions (Admin)") + .RequirePermission(IdentityPermissionConstants.Sessions.RevokeAll) + .WithDescription("Revoke all sessions for a specific user. Requires admin permission."); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandHandler.cs new file mode 100644 index 0000000000..786916d3f8 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandHandler.cs @@ -0,0 +1,37 @@ +using FSH.Framework.Core.Context; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Sessions.AdminRevokeSession; +using Mediator; + +namespace FSH.Modules.Identity.Features.v1.Sessions.AdminRevokeSession; + +public sealed class AdminRevokeSessionCommandHandler : ICommandHandler +{ + private readonly ISessionService _sessionService; + private readonly ICurrentUser _currentUser; + + public AdminRevokeSessionCommandHandler(ISessionService sessionService, ICurrentUser currentUser) + { + _sessionService = sessionService; + _currentUser = currentUser; + } + + public async ValueTask Handle(AdminRevokeSessionCommand command, CancellationToken cancellationToken) + { + var adminId = _currentUser.GetUserId().ToString(); + + // Get the session to verify it belongs to the specified user + var session = await _sessionService.GetSessionAsync(command.SessionId, cancellationToken); + if (session is null || session.UserId != command.UserId.ToString()) + { + return false; + } + + // Use the admin revocation method (doesn't check ownership) + return await _sessionService.RevokeSessionForAdminAsync( + command.SessionId, + adminId, + command.Reason ?? "Revoked by administrator", + cancellationToken); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionEndpoint.cs new file mode 100644 index 0000000000..719f0af84a --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionEndpoint.cs @@ -0,0 +1,31 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Sessions.AdminRevokeSession; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Sessions.AdminRevokeSession; + +public static class AdminRevokeSessionEndpoint +{ + internal static RouteHandlerBuilder MapAdminRevokeSessionEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapDelete("/users/{userId:guid}/sessions/{sessionId:guid}", Handler) + .WithName("AdminRevokeSession") + .WithSummary("Revoke a user's session (Admin)") + .RequirePermission(IdentityPermissionConstants.Sessions.RevokeAll) + .WithDescription("Revoke a specific session for a user. Requires admin permission."); + } + + private static async Task Handler( + Guid userId, + Guid sessionId, + IMediator mediator, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new AdminRevokeSessionCommand(userId, sessionId), cancellationToken); + return result ? Results.Ok() : Results.NotFound(); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetMySessions/GetMySessionsEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetMySessions/GetMySessionsEndpoint.cs new file mode 100644 index 0000000000..33d2f2a35a --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetMySessions/GetMySessionsEndpoint.cs @@ -0,0 +1,22 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Sessions.GetMySessions; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Sessions.GetMySessions; + +public static class GetMySessionsEndpoint +{ + internal static RouteHandlerBuilder MapGetMySessionsEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/sessions/me", (CancellationToken cancellationToken, IMediator mediator) => + mediator.Send(new GetMySessionsQuery(), cancellationToken)) + .WithName("GetMySessions") + .WithSummary("Get current user's sessions") + .RequirePermission(IdentityPermissionConstants.Sessions.View) + .WithDescription("Retrieve all active sessions for the currently authenticated user."); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetMySessions/GetMySessionsQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetMySessions/GetMySessionsQueryHandler.cs new file mode 100644 index 0000000000..84d64e68c0 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetMySessions/GetMySessionsQueryHandler.cs @@ -0,0 +1,25 @@ +using FSH.Framework.Core.Context; +using FSH.Modules.Identity.Contracts.DTOs; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Sessions.GetMySessions; +using Mediator; + +namespace FSH.Modules.Identity.Features.v1.Sessions.GetMySessions; + +public sealed class GetMySessionsQueryHandler : IQueryHandler> +{ + private readonly ISessionService _sessionService; + private readonly ICurrentUser _currentUser; + + public GetMySessionsQueryHandler(ISessionService sessionService, ICurrentUser currentUser) + { + _sessionService = sessionService; + _currentUser = currentUser; + } + + public async ValueTask> Handle(GetMySessionsQuery query, CancellationToken cancellationToken) + { + var userId = _currentUser.GetUserId().ToString(); + return await _sessionService.GetUserSessionsAsync(userId, cancellationToken); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetUserSessions/GetUserSessionsEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetUserSessions/GetUserSessionsEndpoint.cs new file mode 100644 index 0000000000..d2fc3cc26e --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetUserSessions/GetUserSessionsEndpoint.cs @@ -0,0 +1,22 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Sessions.GetUserSessions; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Sessions.GetUserSessions; + +public static class GetUserSessionsEndpoint +{ + internal static RouteHandlerBuilder MapGetUserSessionsEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/users/{userId:guid}/sessions", (Guid userId, CancellationToken cancellationToken, IMediator mediator) => + mediator.Send(new GetUserSessionsQuery(userId), cancellationToken)) + .WithName("GetUserSessions") + .WithSummary("Get user's sessions (Admin)") + .RequirePermission(IdentityPermissionConstants.Sessions.ViewAll) + .WithDescription("Retrieve all active sessions for a specific user. Requires admin permission."); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetUserSessions/GetUserSessionsQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetUserSessions/GetUserSessionsQueryHandler.cs new file mode 100644 index 0000000000..d79ce13522 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetUserSessions/GetUserSessionsQueryHandler.cs @@ -0,0 +1,21 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Sessions.GetUserSessions; +using Mediator; + +namespace FSH.Modules.Identity.Features.v1.Sessions.GetUserSessions; + +public sealed class GetUserSessionsQueryHandler : IQueryHandler> +{ + private readonly ISessionService _sessionService; + + public GetUserSessionsQueryHandler(ISessionService sessionService) + { + _sessionService = sessionService; + } + + public async ValueTask> Handle(GetUserSessionsQuery query, CancellationToken cancellationToken) + { + return await _sessionService.GetUserSessionsForAdminAsync(query.UserId.ToString(), cancellationToken); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeAllSessions/RevokeAllSessionsCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeAllSessions/RevokeAllSessionsCommandHandler.cs new file mode 100644 index 0000000000..a342c1fd87 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeAllSessions/RevokeAllSessionsCommandHandler.cs @@ -0,0 +1,29 @@ +using FSH.Framework.Core.Context; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Sessions.RevokeAllSessions; +using Mediator; + +namespace FSH.Modules.Identity.Features.v1.Sessions.RevokeAllSessions; + +public sealed class RevokeAllSessionsCommandHandler : ICommandHandler +{ + private readonly ISessionService _sessionService; + private readonly ICurrentUser _currentUser; + + public RevokeAllSessionsCommandHandler(ISessionService sessionService, ICurrentUser currentUser) + { + _sessionService = sessionService; + _currentUser = currentUser; + } + + public async ValueTask Handle(RevokeAllSessionsCommand command, CancellationToken cancellationToken) + { + var userId = _currentUser.GetUserId().ToString(); + return await _sessionService.RevokeAllSessionsAsync( + userId, + userId, + command.ExceptSessionId, + "User requested logout from all devices", + cancellationToken); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeAllSessions/RevokeAllSessionsEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeAllSessions/RevokeAllSessionsEndpoint.cs new file mode 100644 index 0000000000..ddf1d4ffef --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeAllSessions/RevokeAllSessionsEndpoint.cs @@ -0,0 +1,25 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Sessions.RevokeAllSessions; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Sessions.RevokeAllSessions; + +public static class RevokeAllSessionsEndpoint +{ + internal static RouteHandlerBuilder MapRevokeAllSessionsEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/sessions/revoke-all", async (RevokeAllSessionsCommand? command, IMediator mediator, CancellationToken cancellationToken) => + { + var result = await mediator.Send(command ?? new RevokeAllSessionsCommand(), cancellationToken); + return TypedResults.Ok(new { RevokedCount = result }); + }) + .WithName("RevokeAllSessions") + .WithSummary("Revoke all sessions") + .RequirePermission(IdentityPermissionConstants.Sessions.Revoke) + .WithDescription("Revoke all sessions for the currently authenticated user except the current one."); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandHandler.cs new file mode 100644 index 0000000000..4ac03d7c19 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandHandler.cs @@ -0,0 +1,28 @@ +using FSH.Framework.Core.Context; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Sessions.RevokeSession; +using Mediator; + +namespace FSH.Modules.Identity.Features.v1.Sessions.RevokeSession; + +public sealed class RevokeSessionCommandHandler : ICommandHandler +{ + private readonly ISessionService _sessionService; + private readonly ICurrentUser _currentUser; + + public RevokeSessionCommandHandler(ISessionService sessionService, ICurrentUser currentUser) + { + _sessionService = sessionService; + _currentUser = currentUser; + } + + public async ValueTask Handle(RevokeSessionCommand command, CancellationToken cancellationToken) + { + var userId = _currentUser.GetUserId().ToString(); + return await _sessionService.RevokeSessionAsync( + command.SessionId, + userId, + "User requested", + cancellationToken); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionEndpoint.cs new file mode 100644 index 0000000000..08cfeed540 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionEndpoint.cs @@ -0,0 +1,30 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Sessions.RevokeSession; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Sessions.RevokeSession; + +public static class RevokeSessionEndpoint +{ + internal static RouteHandlerBuilder MapRevokeSessionEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapDelete("/sessions/{sessionId:guid}", Handler) + .WithName("RevokeSession") + .WithSummary("Revoke a session") + .RequirePermission(IdentityPermissionConstants.Sessions.Revoke) + .WithDescription("Revoke a specific session for the currently authenticated user."); + } + + private static async Task Handler( + Guid sessionId, + IMediator mediator, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new RevokeSessionCommand(sessionId), cancellationToken); + return result ? Results.Ok() : Results.NotFound(); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/UserSession.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/UserSession.cs new file mode 100644 index 0000000000..d6f4ffe7ad --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/UserSession.cs @@ -0,0 +1,25 @@ +namespace FSH.Modules.Identity.Features.v1.Sessions; + +public class UserSession +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string UserId { get; set; } = default!; + public string RefreshTokenHash { get; set; } = default!; + public string IpAddress { get; set; } = default!; + public string UserAgent { get; set; } = default!; + public string? DeviceType { get; set; } + public string? Browser { get; set; } + public string? BrowserVersion { get; set; } + public string? OperatingSystem { get; set; } + public string? OsVersion { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime LastActivityAt { get; set; } = DateTime.UtcNow; + public DateTime ExpiresAt { get; set; } + public bool IsRevoked { get; set; } + public DateTime? RevokedAt { get; set; } + public string? RevokedBy { get; set; } + public string? RevokedReason { get; set; } + + // Navigation property + public virtual Users.FshUser? User { get; set; } +} 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 new file mode 100644 index 0000000000..37ff41c4f3 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandHandler.cs @@ -0,0 +1,128 @@ +using FSH.Modules.Auditing.Contracts; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Tokens.RefreshToken; +using Mediator; +using Microsoft.AspNetCore.Http; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; + +namespace FSH.Modules.Identity.Features.v1.Tokens.RefreshToken; + +public sealed class RefreshTokenCommandHandler + : ICommandHandler +{ + private readonly IIdentityService _identityService; + private readonly ITokenService _tokenService; + private readonly ISecurityAudit _securityAudit; + private readonly IHttpContextAccessor _http; + private readonly ISessionService _sessionService; + + public RefreshTokenCommandHandler( + IIdentityService identityService, + ITokenService tokenService, + ISecurityAudit securityAudit, + IHttpContextAccessor http, + ISessionService sessionService) + { + _identityService = identityService; + _tokenService = tokenService; + _securityAudit = securityAudit; + _http = http; + _sessionService = sessionService; + } + + public async ValueTask Handle( + RefreshTokenCommand request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var http = _http.HttpContext; + var clientId = http?.Request.Headers["X-Client-Id"].ToString(); + if (string.IsNullOrWhiteSpace(clientId)) clientId = "web"; + + // Validate refresh token and rebuild subject + claims + var validated = await _identityService + .ValidateRefreshTokenAsync(request.RefreshToken, cancellationToken); + + if (validated is null) + { + await _securityAudit.TokenRevokedAsync("unknown", clientId!, "InvalidRefreshToken", cancellationToken); + throw new UnauthorizedAccessException("Invalid refresh token."); + } + + var (subject, claims) = validated.Value; + + // Check if the session associated with this refresh token is still valid + var refreshTokenHash = Sha256Short(request.RefreshToken); + var isSessionValid = await _sessionService.ValidateSessionAsync(refreshTokenHash, cancellationToken); + if (!isSessionValid) + { + await _securityAudit.TokenRevokedAsync(subject, clientId!, "SessionRevoked", cancellationToken); + throw new UnauthorizedAccessException("Session has been revoked."); + } + + // Optionally, cross-check the provided access token subject + var handler = new JwtSecurityTokenHandler(); + JwtSecurityToken? parsedAccessToken = null; + try + { + parsedAccessToken = handler.ReadJwtToken(request.Token); + } + catch + { + // Ignore parsing errors and rely on refresh-token validation + } + + if (parsedAccessToken is not null) + { + var accessTokenSubject = parsedAccessToken.Claims + .FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value; + + if (!string.IsNullOrEmpty(accessTokenSubject) && + !string.Equals(accessTokenSubject, subject, StringComparison.Ordinal)) + { + await _securityAudit.TokenRevokedAsync(subject, clientId!, "RefreshTokenSubjectMismatch", cancellationToken); + throw new UnauthorizedAccessException("Access token subject mismatch."); + } + } + + // Audit previous token revocation by rotation (no raw tokens) + await _securityAudit.TokenRevokedAsync(subject, clientId!, "RefreshTokenRotated", cancellationToken); + + // Issue new tokens + var newToken = await _tokenService.IssueAsync(subject, claims, null, cancellationToken); + + // Persist rotated refresh token for this user + await _identityService.StoreRefreshTokenAsync(subject, newToken.RefreshToken, newToken.RefreshTokenExpiresAt, cancellationToken); + + // Update the session with the new refresh token hash + var newRefreshTokenHash = Sha256Short(newToken.RefreshToken); + await _sessionService.UpdateSessionRefreshTokenAsync( + refreshTokenHash, + newRefreshTokenHash, + newToken.RefreshTokenExpiresAt, + cancellationToken); + + // Audit the newly issued token with a fingerprint + var fingerprint = Sha256Short(newToken.AccessToken); + await _securityAudit.TokenIssuedAsync( + userId: subject, + userName: claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value ?? string.Empty, + clientId: clientId!, + tokenFingerprint: fingerprint, + expiresUtc: newToken.AccessTokenExpiresAt, + ct: cancellationToken); + + return new RefreshTokenCommandResponse( + Token: newToken.AccessToken, + RefreshToken: newToken.RefreshToken, + RefreshTokenExpiryTime: newToken.RefreshTokenExpiresAt); + } + + private static string Sha256Short(string value) + { + var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value)); + return Convert.ToHexString(hash.AsSpan(0, 8)); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandValidator.cs new file mode 100644 index 0000000000..7b5947e56e --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using FSH.Modules.Identity.Contracts.v1.Tokens.RefreshToken; + +namespace FSH.Modules.Identity.Features.v1.Tokens.RefreshToken; + +public sealed class RefreshTokenCommandValidator : AbstractValidator +{ + public RefreshTokenCommandValidator() + { + RuleFor(p => p.Token) + .Cascade(CascadeMode.Stop) + .NotEmpty(); + + RuleFor(p => p.RefreshToken) + .Cascade(CascadeMode.Stop) + .NotEmpty(); + } +} + diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenEndpoint.cs new file mode 100644 index 0000000000..630c39b9bf --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenEndpoint.cs @@ -0,0 +1,37 @@ +using FSH.Modules.Identity.Contracts.v1.Tokens.RefreshToken; +using Mediator; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Framework.Identity.v1.Tokens.RefreshToken; + +public static class RefreshTokenEndpoint +{ + public static RouteHandlerBuilder MapRefreshTokenEndpoint(this IEndpointRouteBuilder endpoint) + { + ArgumentNullException.ThrowIfNull(endpoint); + + return endpoint.MapPost("/token/refresh", + [AllowAnonymous] async Task, UnauthorizedHttpResult, ProblemHttpResult>> + ([FromBody] RefreshTokenCommand command, + [FromHeader(Name = "tenant")] string tenant, + [FromServices] IMediator mediator, + CancellationToken ct) => + { + var response = await mediator.Send(command, ct); + return TypedResults.Ok(response); + }) + .WithName("RefreshJwtTokens") + .WithSummary("Refresh JWT access and refresh tokens") + .WithDescription("Use a valid (possibly expired) access token together with a valid refresh token to obtain a new access token and a rotated refresh token.") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status500InternalServerError); + } +} + 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 new file mode 100644 index 0000000000..7db3f29fed --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenCommandHandler.cs @@ -0,0 +1,149 @@ +using FSH.Modules.Auditing.Contracts; +using FSH.Modules.Identity.Contracts.DTOs; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Tokens.TokenGeneration; +using Mediator; +using Microsoft.AspNetCore.Http; +using System.Security.Claims; +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Eventing.Outbox; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Identity.Contracts.Events; + +namespace FSH.Modules.Identity.Features.v1.Tokens.TokenGeneration; + +public sealed class GenerateTokenCommandHandler + : ICommandHandler +{ + private readonly IIdentityService _identityService; + private readonly ITokenService _tokenService; + private readonly ISecurityAudit _securityAudit; + private readonly IHttpContextAccessor _http; + private readonly IOutboxStore _outboxStore; + private readonly IMultiTenantContextAccessor _multiTenantContextAccessor; + private readonly ISessionService _sessionService; + + public GenerateTokenCommandHandler( + IIdentityService identityService, + ITokenService tokenService, + ISecurityAudit securityAudit, + IHttpContextAccessor http, + IOutboxStore outboxStore, + IMultiTenantContextAccessor multiTenantContextAccessor, + ISessionService sessionService) + { + _identityService = identityService; + _tokenService = tokenService; + _securityAudit = securityAudit; + _http = http; + _outboxStore = outboxStore; + _multiTenantContextAccessor = multiTenantContextAccessor; + _sessionService = sessionService; + } + + public async ValueTask Handle( + GenerateTokenCommand request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + // Gather context for auditing + var http = _http.HttpContext; + var ip = http?.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + var ua = http?.Request.Headers.UserAgent.ToString() ?? "unknown"; + var clientId = http?.Request.Headers["X-Client-Id"].ToString(); + if (string.IsNullOrWhiteSpace(clientId)) clientId = "web"; + + // Validate credentials + var identityResult = await _identityService + .ValidateCredentialsAsync(request.Email, request.Password, cancellationToken); + + if (identityResult is null) + { + // 1) Audit failed login BEFORE throwing + await _securityAudit.LoginFailedAsync( + subjectIdOrName: request.Email, + clientId: clientId!, + reason: "InvalidCredentials", + ip: ip, + ct: cancellationToken); + + throw new UnauthorizedAccessException("Invalid credentials."); + } + + // Unpack subject + claims + var (subject, claims) = identityResult.Value; + + // 2) Audit successful login + await _securityAudit.LoginSucceededAsync( + userId: subject, + userName: claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value ?? request.Email, + clientId: clientId!, + ip: ip, + userAgent: ua, + ct: cancellationToken); + + // Issue token + var token = await _tokenService.IssueAsync(subject, claims, /*extra*/ null, cancellationToken); + + // Persist refresh token (hashed) for this user + await _identityService.StoreRefreshTokenAsync(subject, token.RefreshToken, token.RefreshTokenExpiresAt, cancellationToken); + + // Create user session for session management (non-blocking, fail gracefully) + try + { + var refreshTokenHash = Sha256Short(token.RefreshToken); + await _sessionService.CreateSessionAsync( + subject, + refreshTokenHash, + ip, + ua, + token.RefreshTokenExpiresAt, + cancellationToken); + } + catch (Exception) + { + // Session creation is non-critical - don't fail the login + // This can happen if migrations haven't been applied yet + } + + // 3) Audit token issuance with a fingerprint (never raw token) + var fingerprint = Sha256Short(token.AccessToken); + await _securityAudit.TokenIssuedAsync( + userId: subject, + userName: claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value ?? request.Email, + clientId: clientId!, + tokenFingerprint: fingerprint, + expiresUtc: token.AccessTokenExpiresAt, + ct: cancellationToken); + + // 4) Enqueue integration event for token generation (sample event for testing eventing) + var tenantId = _multiTenantContextAccessor.MultiTenantContext?.TenantInfo?.Id; + var correlationId = Guid.NewGuid().ToString(); + + var integrationEvent = new TokenGeneratedIntegrationEvent( + Id: Guid.NewGuid(), + OccurredOnUtc: DateTime.UtcNow, + TenantId: tenantId, + CorrelationId: correlationId, + Source: "Identity", + UserId: subject, + Email: request.Email, + ClientId: clientId!, + IpAddress: ip, + UserAgent: ua, + TokenFingerprint: fingerprint, + AccessTokenExpiresAtUtc: token.AccessTokenExpiresAt); + + await _outboxStore.AddAsync(integrationEvent, cancellationToken).ConfigureAwait(false); + + return token; + } + + private static string Sha256Short(string value) + { + var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value)); + // short printable fingerprint; store only this + return Convert.ToHexString(hash.AsSpan(0, 8)); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenCommandValidator.cs new file mode 100644 index 0000000000..0adef780ea --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenCommandValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using FSH.Modules.Identity.Contracts.v1.Tokens.TokenGeneration; + +namespace FSH.Modules.Identity.Features.v1.Tokens.TokenGeneration; + +public sealed class GenerateTokenCommandValidator : AbstractValidator +{ + public GenerateTokenCommandValidator() + { + RuleFor(p => p.Email) + .Cascade(CascadeMode.Stop) + .NotEmpty() + .EmailAddress(); + + RuleFor(p => p.Password) + .Cascade(CascadeMode.Stop) + .NotEmpty(); + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..51504e2d0b --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenEndpoint.cs @@ -0,0 +1,40 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using FSH.Modules.Identity.Contracts.v1.Tokens.TokenGeneration; +using Mediator; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using System.ComponentModel; + +namespace FSH.Framework.Identity.v1.Tokens.TokenGeneration; + +public static class GenerateTokenEndpoint +{ + public static RouteHandlerBuilder MapGenerateTokenEndpoint(this IEndpointRouteBuilder endpoint) + { + ArgumentNullException.ThrowIfNull(endpoint); + + return endpoint.MapPost("/token/issue", + [AllowAnonymous] async Task, UnauthorizedHttpResult, ProblemHttpResult>> + ([FromBody] GenerateTokenCommand command, + [DefaultValue("root")][FromHeader] string tenant, + [FromServices] IMediator mediator, + CancellationToken ct) => + { + var token = await mediator.Send(command, ct); + return token is null + ? TypedResults.Unauthorized() + : TypedResults.Ok(token); + }) + .WithName("IssueJwtTokens") + .WithSummary("Issue JWT access and refresh tokens") + .WithDescription("Submit credentials to receive a JWT access token and a refresh token. Provide the 'tenant' header to select the tenant context (defaults to 'root').") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status500InternalServerError); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandHandler.cs new file mode 100644 index 0000000000..b1213cd59b --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandHandler.cs @@ -0,0 +1,17 @@ +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Users.AssignUserRoles; +using Mediator; + +namespace FSH.Modules.Identity.Features.v1.Users.AssignUserRoles; + +public sealed class AssignUserRolesCommandHandler(IUserService userService) + : ICommandHandler +{ + public async ValueTask Handle(AssignUserRolesCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + return await userService.AssignRolesAsync(command.UserId, command.UserRoles, cancellationToken); + } + +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesEndpoint.cs new file mode 100644 index 0000000000..9deb92fb79 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesEndpoint.cs @@ -0,0 +1,32 @@ +using FSH.Modules.Identity.Contracts.v1.Users.AssignUserRoles; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Users.AssignUserRoles; + +public static class AssignUserRolesEndpoint +{ + internal static RouteHandlerBuilder MapAssignUserRolesEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/users/{id:guid}/roles", async ( + string id, + AssignUserRolesCommand command, + HttpContext context, + IMediator mediator, + CancellationToken cancellationToken) => + { + if (!string.Equals(id, command.UserId, StringComparison.Ordinal)) + { + return Results.BadRequest(); + } + + var result = await mediator.Send(command, cancellationToken); + return Results.Ok(result); + }) + .WithName("AssignUserRoles") + .WithSummary("Assign roles to user") + .WithDescription("Assign one or more roles to a user."); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordCommandHandler.cs new file mode 100644 index 0000000000..e8e9101c1e --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordCommandHandler.cs @@ -0,0 +1,34 @@ +using FSH.Framework.Shared.Identity.Claims; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Users.ChangePassword; +using Mediator; +using Microsoft.AspNetCore.Http; + +namespace FSH.Modules.Identity.Features.v1.Users.ChangePassword; + +public sealed class ChangePasswordCommandHandler : ICommandHandler +{ + private readonly IUserService _userService; + private readonly IHttpContextAccessor _httpContextAccessor; + + public ChangePasswordCommandHandler(IUserService userService, IHttpContextAccessor httpContextAccessor) + { + _userService = userService; + _httpContextAccessor = httpContextAccessor; + } + + public async ValueTask Handle(ChangePasswordCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + var userId = _httpContextAccessor.HttpContext?.User.GetUserId(); + if (string.IsNullOrEmpty(userId)) + { + throw new InvalidOperationException("User is not authenticated."); + } + + await _userService.ChangePasswordAsync(command.Password, command.NewPassword, command.ConfirmNewPassword, userId).ConfigureAwait(false); + + return "password reset email sent"; + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordEndpoint.cs new file mode 100644 index 0000000000..12962e6cdd --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordEndpoint.cs @@ -0,0 +1,26 @@ +using FSH.Modules.Identity.Contracts.v1.Users.ChangePassword; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Users.ChangePassword; + +public static class ChangePasswordEndpoint +{ + internal static RouteHandlerBuilder MapChangePasswordEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/change-password", async ( + [FromBody] ChangePasswordCommand command, + IMediator mediator, + CancellationToken cancellationToken) => + { + var result = await mediator.Send(command, cancellationToken); + return Results.Ok(result); + }) + .WithName("ChangePassword") + .WithSummary("Change password") + .WithDescription("Change the current user's password."); + } +} 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 new file mode 100644 index 0000000000..3cc10a750b --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordValidator.cs @@ -0,0 +1,61 @@ +using FluentValidation; +using FSH.Framework.Shared.Identity.Claims; +using FSH.Modules.Identity.Contracts.v1.Users.ChangePassword; +using FSH.Modules.Identity.Features.v1.Users; +using FSH.Modules.Identity.Services; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Http; + +namespace FSH.Modules.Identity.Features.v1.Users.ChangePassword; + +public sealed class ChangePasswordValidator : AbstractValidator +{ + private readonly UserManager _userManager; + private readonly IPasswordHistoryService _passwordHistoryService; + private readonly IHttpContextAccessor _httpContextAccessor; + + public ChangePasswordValidator( + UserManager userManager, + IPasswordHistoryService passwordHistoryService, + IHttpContextAccessor httpContextAccessor) + { + _userManager = userManager; + _passwordHistoryService = passwordHistoryService; + _httpContextAccessor = httpContextAccessor; + + RuleFor(p => p.Password) + .NotEmpty() + .WithMessage("Current password is required."); + + RuleFor(p => p.NewPassword) + .NotEmpty() + .WithMessage("New password is required.") + .NotEqual(p => p.Password) + .WithMessage("New password must be different from the current password.") + .MustAsync(NotBeInPasswordHistoryAsync) + .WithMessage("This password has been used recently. Please choose a different password."); + + RuleFor(p => p.ConfirmNewPassword) + .Equal(p => p.NewPassword) + .WithMessage("Passwords do not match."); + } + + private async Task NotBeInPasswordHistoryAsync(string newPassword, CancellationToken cancellationToken) + { + var userId = _httpContextAccessor.HttpContext?.User.GetUserId(); + if (string.IsNullOrEmpty(userId)) + { + return true; // Let other validation handle unauthorized access + } + + var user = await _userManager.FindByIdAsync(userId); + if (user is null) + { + return true; // Let other validation handle user not found + } + + // Check if password is in history + var isInHistory = await _passwordHistoryService.IsPasswordInHistoryAsync(user, newPassword, cancellationToken); + return !isInHistory; // Return true if NOT in history (validation passes) + } +} \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandHandler.cs new file mode 100644 index 0000000000..f5103ee3f0 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandHandler.cs @@ -0,0 +1,23 @@ +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Users.ConfirmEmail; +using Mediator; + +namespace FSH.Modules.Identity.Features.v1.Users.ConfirmEmail; + +public sealed class ConfirmEmailCommandHandler : ICommandHandler +{ + private readonly IUserService _userService; + + public ConfirmEmailCommandHandler(IUserService userService) + { + _userService = userService; + } + + public async ValueTask Handle(ConfirmEmailCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + return await _userService.ConfirmEmailAsync(command.UserId, command.Code, command.Tenant, cancellationToken) + .ConfigureAwait(false); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailEndpoint.cs new file mode 100644 index 0000000000..8314fb3138 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailEndpoint.cs @@ -0,0 +1,23 @@ +using FSH.Modules.Identity.Contracts.v1.Users.ConfirmEmail; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Users.ConfirmEmail; + +public static class ConfirmEmailEndpoint +{ + internal static RouteHandlerBuilder MapConfirmEmailEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/confirm-email", async (string userId, string code, string tenant, IMediator mediator, CancellationToken cancellationToken) => + { + var result = await mediator.Send(new ConfirmEmailCommand(userId, code, tenant), cancellationToken); + return Results.Ok(result); + }) + .WithName("ConfirmEmail") + .WithSummary("Confirm user email") + .WithDescription("Confirm a user's email address.") + .AllowAnonymous(); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandHandler.cs new file mode 100644 index 0000000000..d9cbc1e55d --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandHandler.cs @@ -0,0 +1,24 @@ +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Users.DeleteUser; +using Mediator; + +namespace FSH.Modules.Identity.Features.v1.Users.DeleteUser; + +public sealed class DeleteUserCommandHandler : ICommandHandler +{ + private readonly IUserService _userService; + + public DeleteUserCommandHandler(IUserService userService) + { + _userService = userService; + } + + public async ValueTask Handle(DeleteUserCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + await _userService.DeleteAsync(command.Id).ConfigureAwait(false); + + return Unit.Value; + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserEndpoint.cs new file mode 100644 index 0000000000..d6a4283f8d --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserEndpoint.cs @@ -0,0 +1,25 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Users.DeleteUser; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Users.DeleteUser; + +public static class DeleteUserEndpoint +{ + internal static RouteHandlerBuilder MapDeleteUserEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapDelete("/users/{id:guid}", async (string id, IMediator mediator, CancellationToken cancellationToken) => + { + await mediator.Send(new DeleteUserCommand(id), cancellationToken); + return Results.NoContent(); + }) + .WithName("DeleteUser") + .WithSummary("Delete user") + .RequirePermission(IdentityPermissionConstants.Users.Delete) + .WithDescription("Delete a user by unique identifier."); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandHandler.cs new file mode 100644 index 0000000000..b11da1ac61 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandHandler.cs @@ -0,0 +1,34 @@ +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Users.ForgotPassword; +using Mediator; +using Microsoft.Extensions.Options; +using FSH.Framework.Web.Origin; + +namespace FSH.Modules.Identity.Features.v1.Users.ForgotPassword; + +public sealed class ForgotPasswordCommandHandler : ICommandHandler +{ + private readonly IUserService _userService; + private readonly IOptions _originOptions; + + public ForgotPasswordCommandHandler(IUserService userService, IOptions originOptions) + { + _userService = userService; + _originOptions = originOptions; + } + + public async ValueTask Handle(ForgotPasswordCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + var origin = _originOptions.Value?.OriginUrl?.ToString(); + if (string.IsNullOrWhiteSpace(origin)) + { + throw new InvalidOperationException("Origin URL is not configured."); + } + + await _userService.ForgotPasswordAsync(command.Email, origin, cancellationToken).ConfigureAwait(false); + + return "Password reset email sent."; + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandValidator.cs new file mode 100644 index 0000000000..1280db86fe --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using FSH.Modules.Identity.Contracts.v1.Users.ForgotPassword; + +namespace FSH.Modules.Identity.Features.v1.Users.ForgotPassword; + +public sealed class ForgotPasswordCommandValidator : AbstractValidator +{ + public ForgotPasswordCommandValidator() + { + RuleFor(p => p.Email).Cascade(CascadeMode.Stop) + .NotEmpty() + .EmailAddress(); + } +} \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordEndpoint.cs new file mode 100644 index 0000000000..0c7a366c5e --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordEndpoint.cs @@ -0,0 +1,30 @@ +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Identity.Contracts.v1.Users.ForgotPassword; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Users.ForgotPassword; + +public static class ForgotPasswordEndpoint +{ + internal static RouteHandlerBuilder MapForgotPasswordEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/forgot-password", async ( + HttpRequest request, + [FromHeader(Name = MultitenancyConstants.Identifier)] string tenant, + [FromBody] ForgotPasswordCommand command, + IMediator mediator, + CancellationToken cancellationToken) => + { + var result = await mediator.Send(command, cancellationToken); + return Results.Ok(result); + }) + .WithName("RequestPasswordReset") + .WithSummary("Request password reset") + .WithDescription("Generate a password reset token and send it via email.") + .AllowAnonymous(); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/FshUser.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/FshUser.cs new file mode 100644 index 0000000000..2e98f6abfe --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/FshUser.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Identity; +using FSH.Modules.Identity.Features.v1.Users.PasswordHistory; + +namespace FSH.Modules.Identity.Features.v1.Users; + +public class FshUser : IdentityUser +{ + public string? FirstName { get; set; } + public string? LastName { get; set; } + public Uri? ImageUrl { get; set; } + public bool IsActive { get; set; } + public string? RefreshToken { get; set; } + public DateTime RefreshTokenExpiryTime { get; set; } + + public string? ObjectId { get; set; } + + /// Timestamp when the user last changed their password + public DateTime LastPasswordChangeDate { get; set; } = DateTime.UtcNow; + + // Navigation property for password history + public virtual ICollection PasswordHistories { get; set; } = new List(); +} \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserById/GetUserByIdEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserById/GetUserByIdEndpoint.cs new file mode 100644 index 0000000000..38f71e806d --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserById/GetUserByIdEndpoint.cs @@ -0,0 +1,22 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Users.GetUser; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Users.GetUserById; + +public static class GetUserByIdEndpoint +{ + internal static RouteHandlerBuilder MapGetUserByIdEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/users/{id:guid}", (string id, IMediator mediator, CancellationToken cancellationToken) => + mediator.Send(new GetUserQuery(id), cancellationToken)) + .WithName("GetUser") + .WithSummary("Get user by ID") + .RequirePermission(IdentityPermissionConstants.Users.View) + .WithDescription("Retrieve a user's profile details by unique user identifier."); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserById/GetUserByIdQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserById/GetUserByIdQueryHandler.cs new file mode 100644 index 0000000000..5e39c05b07 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserById/GetUserByIdQueryHandler.cs @@ -0,0 +1,22 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Users.GetUser; +using Mediator; + +namespace FSH.Modules.Identity.Features.v1.Users.GetUserById; + +public sealed class GetUserByIdQueryHandler : IQueryHandler +{ + private readonly IUserService _userService; + + public GetUserByIdQueryHandler(IUserService userService) + { + _userService = userService; + } + + public async ValueTask Handle(GetUserQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + return await _userService.GetAsync(query.Id, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserGroups/GetUserGroupsEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserGroups/GetUserGroupsEndpoint.cs new file mode 100644 index 0000000000..d94f726e05 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserGroups/GetUserGroupsEndpoint.cs @@ -0,0 +1,22 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Users.GetUserGroups; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Users.GetUserGroups; + +public static class GetUserGroupsEndpoint +{ + public static RouteHandlerBuilder MapGetUserGroupsEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/users/{userId}/groups", (string userId, IMediator mediator, CancellationToken cancellationToken) => + mediator.Send(new GetUserGroupsQuery(userId), cancellationToken)) + .WithName("GetUserGroups") + .WithSummary("Get groups for a user") + .RequirePermission(IdentityPermissionConstants.Groups.View) + .WithDescription("Retrieve all groups that a specific user belongs to."); + } +} 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 new file mode 100644 index 0000000000..d625d3af8c --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserGroups/GetUserGroupsQueryHandler.cs @@ -0,0 +1,81 @@ +using FSH.Framework.Core.Exceptions; +using FSH.Modules.Identity.Contracts.DTOs; +using FSH.Modules.Identity.Contracts.v1.Users.GetUserGroups; +using FSH.Modules.Identity.Data; +using Mediator; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Identity.Features.v1.Users.GetUserGroups; + +public sealed class GetUserGroupsQueryHandler : IQueryHandler> +{ + private readonly IdentityDbContext _dbContext; + + public GetUserGroupsQueryHandler(IdentityDbContext dbContext) + { + _dbContext = dbContext; + } + + public async ValueTask> Handle(GetUserGroupsQuery query, CancellationToken cancellationToken) + { + // Validate user exists + var userExists = await _dbContext.Users + .AnyAsync(u => u.Id == query.UserId, cancellationToken); + + if (!userExists) + { + throw new NotFoundException($"User with ID '{query.UserId}' not found."); + } + + // Get user's groups + var groupIds = await _dbContext.UserGroups + .Where(ug => ug.UserId == query.UserId) + .Select(ug => ug.GroupId) + .ToListAsync(cancellationToken); + + if (groupIds.Count == 0) + { + return []; + } + + var groups = await _dbContext.Groups + .Include(g => g.GroupRoles) + .Where(g => groupIds.Contains(g.Id)) + .ToListAsync(cancellationToken); + + // Get member counts + var memberCounts = await _dbContext.UserGroups + .Where(ug => groupIds.Contains(ug.GroupId)) + .GroupBy(ug => ug.GroupId) + .Select(g => new { GroupId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.GroupId, x => x.Count, cancellationToken); + + // Get role names + var allRoleIds = groups + .SelectMany(g => g.GroupRoles.Select(gr => gr.RoleId)) + .Distinct() + .ToList(); + + var roleNames = allRoleIds.Count > 0 + ? await _dbContext.Roles + .Where(r => allRoleIds.Contains(r.Id)) + .ToDictionaryAsync(r => r.Id, r => r.Name!, cancellationToken) + : new Dictionary(); + + return groups.Select(g => new GroupDto + { + Id = g.Id, + Name = g.Name, + Description = g.Description, + IsDefault = g.IsDefault, + IsSystemGroup = g.IsSystemGroup, + MemberCount = memberCounts.GetValueOrDefault(g.Id, 0), + RoleIds = g.GroupRoles.Select(gr => gr.RoleId).ToList().AsReadOnly(), + RoleNames = g.GroupRoles + .Select(gr => roleNames.GetValueOrDefault(gr.RoleId, gr.RoleId)) + .ToList() + .AsReadOnly(), + CreatedAt = g.CreatedAt + }); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserPermissions/GetCurrentUserPermissionsQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserPermissions/GetCurrentUserPermissionsQueryHandler.cs new file mode 100644 index 0000000000..7a5c877716 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserPermissions/GetCurrentUserPermissionsQueryHandler.cs @@ -0,0 +1,21 @@ +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Users.GetUserPermissions; +using Mediator; + +namespace FSH.Modules.Identity.Features.v1.Users.GetUserPermissions; + +public sealed class GetCurrentUserPermissionsQueryHandler : IQueryHandler?> +{ + private readonly IUserService _userService; + + public GetCurrentUserPermissionsQueryHandler(IUserService userService) + { + _userService = userService; + } + + public async ValueTask?> Handle(GetCurrentUserPermissionsQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + return await _userService.GetPermissionsAsync(query.UserId, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserPermissions/GetUserPermissionsEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserPermissions/GetUserPermissionsEndpoint.cs new file mode 100644 index 0000000000..627d3d82b7 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserPermissions/GetUserPermissionsEndpoint.cs @@ -0,0 +1,29 @@ +using System.Security.Claims; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Shared.Identity.Claims; +using FSH.Modules.Identity.Contracts.v1.Users.GetUserPermissions; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Users.GetUserPermissions; + +public static class GetUserPermissionsEndpoint +{ + internal static RouteHandlerBuilder MapGetCurrentUserPermissionsEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/permissions", async (ClaimsPrincipal user, IMediator mediator, CancellationToken cancellationToken) => + { + if (user.GetUserId() is not { } userId || string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedException(); + } + + return await mediator.Send(new GetCurrentUserPermissionsQuery(userId), cancellationToken); + }) + .WithName("GetCurrentUserPermissions") + .WithSummary("Get current user permissions") + .WithDescription("Retrieve permissions for the authenticated user."); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserProfile/GetCurrentUserProfileQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserProfile/GetCurrentUserProfileQueryHandler.cs new file mode 100644 index 0000000000..800be6aca5 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserProfile/GetCurrentUserProfileQueryHandler.cs @@ -0,0 +1,22 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Users.GetUserProfile; +using Mediator; + +namespace FSH.Modules.Identity.Features.v1.Users.GetUserProfile; + +public sealed class GetCurrentUserProfileQueryHandler : IQueryHandler +{ + private readonly IUserService _userService; + + public GetCurrentUserProfileQueryHandler(IUserService userService) + { + _userService = userService; + } + + public async ValueTask Handle(GetCurrentUserProfileQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + return await _userService.GetAsync(query.UserId, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserProfile/GetUserProfileEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserProfile/GetUserProfileEndpoint.cs new file mode 100644 index 0000000000..b64e88b7a3 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserProfile/GetUserProfileEndpoint.cs @@ -0,0 +1,29 @@ +using System.Security.Claims; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Shared.Identity.Claims; +using FSH.Modules.Identity.Contracts.v1.Users.GetUserProfile; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Users.GetUserProfile; + +public static class GetUserProfileEndpoint +{ + internal static RouteHandlerBuilder MapGetMeEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/profile", async (ClaimsPrincipal user, IMediator mediator, CancellationToken cancellationToken) => + { + if (user.GetUserId() is not { } userId || string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedException(); + } + + return await mediator.Send(new GetCurrentUserProfileQuery(userId), cancellationToken); + }) + .WithName("GetCurrentUserProfile") + .WithSummary("Get current user profile") + .WithDescription("Retrieve the authenticated user's profile from the access token."); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserRoles/GetUserRolesEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserRoles/GetUserRolesEndpoint.cs new file mode 100644 index 0000000000..ce39fcb403 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserRoles/GetUserRolesEndpoint.cs @@ -0,0 +1,22 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Users.GetUserRoles; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Users.GetUserRoles; + +public static class GetUserRolesEndpoint +{ + internal static RouteHandlerBuilder MapGetUserRolesEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/users/{id:guid}/roles", (string id, IMediator mediator, CancellationToken cancellationToken) => + mediator.Send(new GetUserRolesQuery(id), cancellationToken)) + .WithName("GetUserRoles") + .WithSummary("Get user roles") + .RequirePermission(IdentityPermissionConstants.Users.View) + .WithDescription("Retrieve the roles assigned to a specific user."); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserRoles/GetUserRolesQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserRoles/GetUserRolesQueryHandler.cs new file mode 100644 index 0000000000..3a84e7814b --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserRoles/GetUserRolesQueryHandler.cs @@ -0,0 +1,22 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Users.GetUserRoles; +using Mediator; + +namespace FSH.Modules.Identity.Features.v1.Users.GetUserRoles; + +public sealed class GetUserRolesQueryHandler : IQueryHandler> +{ + private readonly IUserService _userService; + + public GetUserRolesQueryHandler(IUserService userService) + { + _userService = userService; + } + + public async ValueTask> Handle(GetUserRolesQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + return await _userService.GetUserRolesAsync(query.UserId, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUsers/GetUsersListEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUsers/GetUsersListEndpoint.cs new file mode 100644 index 0000000000..9cd3fa9854 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUsers/GetUsersListEndpoint.cs @@ -0,0 +1,22 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Users.GetUsers; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Users.GetUsers; + +public static class GetUsersListEndpoint +{ + internal static RouteHandlerBuilder MapGetUsersListEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/users", (CancellationToken cancellationToken, IMediator mediator) => + mediator.Send(new GetUsersQuery(), cancellationToken)) + .WithName("ListUsers") + .WithSummary("List users") + .RequirePermission(IdentityPermissionConstants.Users.View) + .WithDescription("Retrieve a list of users for the current tenant."); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUsers/GetUsersQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUsers/GetUsersQueryHandler.cs new file mode 100644 index 0000000000..1a531b3460 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUsers/GetUsersQueryHandler.cs @@ -0,0 +1,21 @@ +using FSH.Modules.Identity.Contracts.DTOs; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Users.GetUsers; +using Mediator; + +namespace FSH.Modules.Identity.Features.v1.Users.GetUsers; + +public sealed class GetUsersQueryHandler : IQueryHandler> +{ + private readonly IUserService _userService; + + public GetUsersQueryHandler(IUserService userService) + { + _userService = userService; + } + + public async ValueTask> Handle(GetUsersQuery query, CancellationToken cancellationToken) + { + return await _userService.GetListAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/PasswordHistory/PasswordHistory.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/PasswordHistory/PasswordHistory.cs new file mode 100644 index 0000000000..3ba38c8451 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/PasswordHistory/PasswordHistory.cs @@ -0,0 +1,12 @@ +namespace FSH.Modules.Identity.Features.v1.Users.PasswordHistory; + +public class PasswordHistory +{ + public int Id { get; set; } + public string UserId { get; set; } = default!; + public string PasswordHash { get; set; } = default!; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Navigation property + public virtual FshUser? User { get; set; } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandHandler.cs new file mode 100644 index 0000000000..21a522526c --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandHandler.cs @@ -0,0 +1,33 @@ +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Users.RegisterUser; +using Mediator; + +namespace FSH.Modules.Identity.Features.v1.Users.RegisterUser; + +public sealed class RegisterUserCommandHandler : ICommandHandler +{ + private readonly IUserService _userService; + + public RegisterUserCommandHandler(IUserService userService) + { + _userService = userService; + } + + public async ValueTask Handle(RegisterUserCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + string userId = await _userService.RegisterAsync( + command.FirstName, + command.LastName, + command.Email, + command.UserName, + command.Password, + command.ConfirmPassword, + command.PhoneNumber ?? string.Empty, + command.Origin ?? string.Empty, + cancellationToken).ConfigureAwait(false); + + return new RegisterUserResponse(userId); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserEndpoint.cs new file mode 100644 index 0000000000..2f58a2bff6 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserEndpoint.cs @@ -0,0 +1,29 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Users.RegisterUser; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Users.RegisterUser; + +public static class RegisterUserEndpoint +{ + internal static RouteHandlerBuilder MapRegisterUserEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/register", (RegisterUserCommand command, + HttpContext context, + IMediator mediator, + CancellationToken cancellationToken) => + { + var origin = $"{context.Request.Scheme}://{context.Request.Host.Value}{context.Request.PathBase.Value}"; + command.Origin = origin; + return mediator.Send(command, cancellationToken); + }) + .WithName("RegisterUser") + .WithSummary("Register user") + .RequirePermission(IdentityPermissionConstants.Users.Create) + .WithDescription("Create a new user account."); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResetPassword/ResetPasswordCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResetPassword/ResetPasswordCommandHandler.cs new file mode 100644 index 0000000000..24ad887756 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResetPassword/ResetPasswordCommandHandler.cs @@ -0,0 +1,24 @@ +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Users.ResetPassword; +using Mediator; + +namespace FSH.Modules.Identity.Features.v1.Users.ResetPassword; + +public sealed class ResetPasswordCommandHandler : ICommandHandler +{ + private readonly IUserService _userService; + + public ResetPasswordCommandHandler(IUserService userService) + { + _userService = userService; + } + + public async ValueTask Handle(ResetPasswordCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + await _userService.ResetPasswordAsync(command.Email, command.Password, command.Token, cancellationToken).ConfigureAwait(false); + + return "Password has been reset."; + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResetPassword/ResetPasswordCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResetPassword/ResetPasswordCommandValidator.cs new file mode 100644 index 0000000000..1069a03c07 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResetPassword/ResetPasswordCommandValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using FSH.Modules.Identity.Contracts.v1.Users.ResetPassword; + +namespace FSH.Modules.Identity.Features.v1.Users.ResetPassword; + +public sealed class ResetPasswordCommandValidator : AbstractValidator +{ + public ResetPasswordCommandValidator() + { + RuleFor(x => x.Email).NotEmpty().EmailAddress(); + RuleFor(x => x.Password).NotEmpty(); + RuleFor(x => x.Token).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResetPassword/ResetPasswordEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResetPassword/ResetPasswordEndpoint.cs new file mode 100644 index 0000000000..fff5cb07bc --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResetPassword/ResetPasswordEndpoint.cs @@ -0,0 +1,29 @@ +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Identity.Contracts.v1.Users.ResetPassword; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Users.ResetPassword; + +public static class ResetPasswordEndpoint +{ + internal static RouteHandlerBuilder MapResetPasswordEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/reset-password", + async ([FromBody] ResetPasswordCommand command, + [FromHeader(Name = MultitenancyConstants.Identifier)] string tenant, + IMediator mediator, + CancellationToken cancellationToken) => + { + var result = await mediator.Send(command, cancellationToken); + return Results.Ok(result); + }) + .WithName("ResetPassword") + .WithSummary("Reset password") + .WithDescription("Reset the user's password using the provided verification token.") + .AllowAnonymous(); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersEndpoint.cs new file mode 100644 index 0000000000..dab66c3f17 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersEndpoint.cs @@ -0,0 +1,24 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Users.SearchUsers; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Users.SearchUsers; + +public static class SearchUsersEndpoint +{ + internal static RouteHandlerBuilder MapSearchUsersEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet( + "/users/search", + async ([AsParameters] SearchUsersQuery query, IMediator mediator, CancellationToken cancellationToken) => + await mediator.Send(query, cancellationToken)) + .WithName("SearchUsers") + .WithSummary("Search users with pagination") + .WithDescription("Search and filter users with server-side pagination, sorting, and filtering by status, email confirmation, and role.") + .RequirePermission(IdentityPermissionConstants.Users.View); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersQueryHandler.cs new file mode 100644 index 0000000000..36b7df257b --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersQueryHandler.cs @@ -0,0 +1,179 @@ +using FSH.Framework.Persistence; +using FSH.Framework.Shared.Persistence; +using FSH.Modules.Identity.Contracts.DTOs; +using FSH.Modules.Identity.Contracts.v1.Users.SearchUsers; +using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Features.v1.Users; +using Mediator; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using FSH.Framework.Web.Origin; + +namespace FSH.Modules.Identity.Features.v1.Users.SearchUsers; + +public sealed class SearchUsersQueryHandler : IQueryHandler> +{ + private readonly UserManager _userManager; + private readonly IdentityDbContext _dbContext; + private readonly Uri? _originUrl; + private readonly IHttpContextAccessor _httpContextAccessor; + + public SearchUsersQueryHandler( + UserManager userManager, + IdentityDbContext dbContext, + IOptions originOptions, + IHttpContextAccessor httpContextAccessor) + { + _userManager = userManager; + _dbContext = dbContext; + _originUrl = originOptions.Value.OriginUrl; + _httpContextAccessor = httpContextAccessor; + } + + public async ValueTask> Handle(SearchUsersQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + + IQueryable users = _userManager.Users.AsNoTracking(); + + // Apply filters + if (!string.IsNullOrWhiteSpace(query.Search)) + { + string term = query.Search.ToLowerInvariant(); + users = users.Where(u => + (u.FirstName != null && u.FirstName.ToLower().Contains(term)) || + (u.LastName != null && u.LastName.ToLower().Contains(term)) || + (u.Email != null && u.Email.ToLower().Contains(term)) || + (u.UserName != null && u.UserName.ToLower().Contains(term))); + } + + if (query.IsActive.HasValue) + { + users = users.Where(u => u.IsActive == query.IsActive.Value); + } + + if (query.EmailConfirmed.HasValue) + { + users = users.Where(u => u.EmailConfirmed == query.EmailConfirmed.Value); + } + + if (!string.IsNullOrWhiteSpace(query.RoleId)) + { + var userIdsInRole = await _dbContext.UserRoles + .Where(ur => ur.RoleId == query.RoleId) + .Select(ur => ur.UserId) + .ToListAsync(cancellationToken); + + users = users.Where(u => userIdsInRole.Contains(u.Id)); + } + + // Apply sorting + users = ApplySorting(users, query.Sort); + + // Project to DTO + var projected = users.Select(u => new UserDto + { + Id = u.Id, + UserName = u.UserName, + FirstName = u.FirstName, + LastName = u.LastName, + Email = u.Email, + IsActive = u.IsActive, + EmailConfirmed = u.EmailConfirmed, + PhoneNumber = u.PhoneNumber, + ImageUrl = u.ImageUrl != null ? u.ImageUrl.ToString() : null + }); + + var pagedResult = await projected.ToPagedResponseAsync(query, cancellationToken).ConfigureAwait(false); + + // Resolve image URLs for items + var items = pagedResult.Items.Select(u => new UserDto + { + Id = u.Id, + UserName = u.UserName, + FirstName = u.FirstName, + LastName = u.LastName, + Email = u.Email, + IsActive = u.IsActive, + EmailConfirmed = u.EmailConfirmed, + PhoneNumber = u.PhoneNumber, + ImageUrl = ResolveImageUrl(u.ImageUrl) + }).ToList(); + + return new PagedResponse + { + Items = items, + PageNumber = pagedResult.PageNumber, + PageSize = pagedResult.PageSize, + TotalCount = pagedResult.TotalCount, + TotalPages = pagedResult.TotalPages + }; + } + + private static IQueryable ApplySorting(IQueryable query, string? sort) + { + if (string.IsNullOrWhiteSpace(sort)) + { + return query.OrderBy(u => u.FirstName).ThenBy(u => u.LastName); + } + + var sortParts = sort.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + IOrderedQueryable? orderedQuery = null; + + foreach (var part in sortParts) + { + var descending = part.StartsWith('-'); + var field = descending ? part[1..] : part; + + orderedQuery = (orderedQuery, field.ToLowerInvariant()) switch + { + (null, "firstname") => descending ? query.OrderByDescending(u => u.FirstName) : query.OrderBy(u => u.FirstName), + (null, "lastname") => descending ? query.OrderByDescending(u => u.LastName) : query.OrderBy(u => u.LastName), + (null, "email") => descending ? query.OrderByDescending(u => u.Email) : query.OrderBy(u => u.Email), + (null, "username") => descending ? query.OrderByDescending(u => u.UserName) : query.OrderBy(u => u.UserName), + (null, "isactive") => descending ? query.OrderByDescending(u => u.IsActive) : query.OrderBy(u => u.IsActive), + (null, _) => query.OrderBy(u => u.FirstName), + + (not null, "firstname") => descending ? orderedQuery.ThenByDescending(u => u.FirstName) : orderedQuery.ThenBy(u => u.FirstName), + (not null, "lastname") => descending ? orderedQuery.ThenByDescending(u => u.LastName) : orderedQuery.ThenBy(u => u.LastName), + (not null, "email") => descending ? orderedQuery.ThenByDescending(u => u.Email) : orderedQuery.ThenBy(u => u.Email), + (not null, "username") => descending ? orderedQuery.ThenByDescending(u => u.UserName) : orderedQuery.ThenBy(u => u.UserName), + (not null, "isactive") => descending ? orderedQuery.ThenByDescending(u => u.IsActive) : orderedQuery.ThenBy(u => u.IsActive), + (not null, _) => orderedQuery.ThenBy(u => u.FirstName) + }; + } + + return orderedQuery ?? query.OrderBy(u => u.FirstName); + } + + private string? ResolveImageUrl(string? imageUrl) + { + if (string.IsNullOrWhiteSpace(imageUrl)) + { + return null; + } + + if (Uri.TryCreate(imageUrl, UriKind.Absolute, out _)) + { + return imageUrl; + } + + if (_originUrl is null) + { + var request = _httpContextAccessor.HttpContext?.Request; + if (request is not null && !string.IsNullOrWhiteSpace(request.Scheme) && request.Host.HasValue) + { + var baseUri = $"{request.Scheme}://{request.Host.Value}{request.PathBase}"; + var relativePath = imageUrl.TrimStart('/'); + return $"{baseUri.TrimEnd('/')}/{relativePath}"; + } + + return imageUrl; + } + + var originRelativePath = imageUrl.TrimStart('/'); + return $"{_originUrl.AbsoluteUri.TrimEnd('/')}/{originRelativePath}"; + } +} 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 new file mode 100644 index 0000000000..e73b230739 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersQueryValidator.cs @@ -0,0 +1,26 @@ +using FluentValidation; +using FSH.Modules.Identity.Contracts.v1.Users.SearchUsers; + +namespace FSH.Modules.Identity.Features.v1.Users.SearchUsers; + +public sealed class SearchUsersQueryValidator : AbstractValidator +{ + public SearchUsersQueryValidator() + { + RuleFor(q => q.PageNumber) + .GreaterThan(0) + .When(q => q.PageNumber.HasValue); + + RuleFor(q => q.PageSize) + .InclusiveBetween(1, 100) + .When(q => q.PageSize.HasValue); + + RuleFor(q => q.Search) + .MaximumLength(200) + .When(q => !string.IsNullOrEmpty(q.Search)); + + RuleFor(q => q.RoleId) + .MaximumLength(450) + .When(q => !string.IsNullOrEmpty(q.RoleId)); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/SelfRegistration/SelfRegisterUserEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SelfRegistration/SelfRegisterUserEndpoint.cs new file mode 100644 index 0000000000..682a4c4cc8 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SelfRegistration/SelfRegisterUserEndpoint.cs @@ -0,0 +1,33 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Identity.Contracts.v1.Users.RegisterUser; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Framework.Infrastructure.Identity.Users.Endpoints; + +public static class SelfRegisterUserEndpoint +{ + internal static RouteHandlerBuilder MapSelfRegisterUserEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/self-register", (RegisterUserCommand command, + [FromHeader(Name = MultitenancyConstants.Identifier)] string tenant, + HttpContext context, + IMediator mediator, + CancellationToken cancellationToken) => + { + var origin = $"{context.Request.Scheme}://{context.Request.Host.Value}{context.Request.PathBase.Value}"; + command.Origin = origin; + return mediator.Send(command, cancellationToken); + }) + .WithName("SelfRegisterUser") + .WithSummary("Self register user") + .RequirePermission(IdentityPermissionConstants.Users.Create) + .WithDescription("Allow a user to self-register.") + .AllowAnonymous(); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandHandler.cs new file mode 100644 index 0000000000..5f560c3835 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandHandler.cs @@ -0,0 +1,29 @@ +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Users.ToggleUserStatus; +using Mediator; + +namespace FSH.Modules.Identity.Features.v1.Users.ToggleUserStatus; + +public sealed class ToggleUserStatusCommandHandler : ICommandHandler +{ + private readonly IUserService _userService; + + public ToggleUserStatusCommandHandler(IUserService userService) + { + _userService = userService; + } + + public async ValueTask Handle(ToggleUserStatusCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + if (string.IsNullOrWhiteSpace(command.UserId)) + { + throw new ArgumentException("UserId must be provided.", nameof(command.UserId)); + } + + await _userService.ToggleStatusAsync(command.ActivateUser, command.UserId, cancellationToken).ConfigureAwait(false); + + return Unit.Value; + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusEndpoint.cs new file mode 100644 index 0000000000..64b3650c4b --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusEndpoint.cs @@ -0,0 +1,40 @@ +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.v1.Users.ToggleUserStatus; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Identity.Features.v1.Users.ToggleUserStatus; + +public static class ToggleUserStatusEndpoint +{ + internal static RouteHandlerBuilder ToggleUserStatusEndpointEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPatch("/users/{id:guid}", async ( + string id, + [FromBody] ToggleUserStatusCommand command, + IMediator mediator, + CancellationToken cancellationToken) => + { + if (string.IsNullOrWhiteSpace(command.UserId)) + { + command.UserId = id; + } + + if (!string.Equals(id, command.UserId, StringComparison.Ordinal)) + { + return Results.BadRequest(); + } + + await mediator.Send(command, cancellationToken); + return Results.NoContent(); + }) + .WithName("ToggleUserStatus") + .WithSummary("Toggle user status") + .RequirePermission(IdentityPermissionConstants.Users.Update) + .WithDescription("Activate or deactivate a user account."); + } +} 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 new file mode 100644 index 0000000000..25f4d70d82 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandHandler.cs @@ -0,0 +1,30 @@ +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Users.UpdateUser; +using Mediator; + +namespace FSH.Modules.Identity.Features.v1.Users.UpdateUser; + +public sealed class UpdateUserCommandHandler : ICommandHandler +{ + private readonly IUserService _userService; + + public UpdateUserCommandHandler(IUserService userService) + { + _userService = userService; + } + + public async ValueTask Handle(UpdateUserCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + await _userService.UpdateAsync( + command.Id, + command.FirstName ?? string.Empty, + command.LastName ?? string.Empty, + command.PhoneNumber ?? string.Empty, + command.Image!, + command.DeleteCurrentImage).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 new file mode 100644 index 0000000000..6fc722d9d9 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs @@ -0,0 +1,42 @@ +using FluentValidation; +using FSH.Framework.Storage; +using FSH.Modules.Identity.Contracts.v1.Users.UpdateUser; + +namespace FSH.Modules.Identity.Features.v1.Users.UpdateUser; + +public sealed class UpdateUserCommandValidator : AbstractValidator +{ + public UpdateUserCommandValidator() + { + RuleFor(x => x.Id) + .NotEmpty() + .WithMessage("User ID is required."); + + RuleFor(x => x.FirstName) + .MaximumLength(50) + .When(x => !string.IsNullOrWhiteSpace(x.FirstName)); + + RuleFor(x => x.LastName) + .MaximumLength(50) + .When(x => !string.IsNullOrWhiteSpace(x.LastName)); + + RuleFor(x => x.PhoneNumber) + .MaximumLength(15) + .When(x => !string.IsNullOrWhiteSpace(x.PhoneNumber)); + + RuleFor(x => x.Email) + .EmailAddress() + .When(x => !string.IsNullOrWhiteSpace(x.Email)); + + When(x => x.Image is not null, () => + { + RuleFor(x => x.Image!) + .SetValidator(new UserImageValidator(FileType.Image)); + }); + + // 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."); + } +} \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserEndpoint.cs new file mode 100644 index 0000000000..d76cb844fe --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserEndpoint.cs @@ -0,0 +1,36 @@ +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Shared.Identity; +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Framework.Shared.Identity.Claims; +using FSH.Modules.Identity.Contracts.v1.Users.UpdateUser; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using System.Security.Claims; + +namespace FSH.Modules.Identity.Features.v1.Users.UpdateUser; + +public static class UpdateUserEndpoint +{ + internal static RouteHandlerBuilder MapUpdateUserEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPut("/profile", async ([FromBody] UpdateUserCommand request, ClaimsPrincipal user, IMediator mediator, CancellationToken cancellationToken) => + { + if (user.GetUserId() is not { } userId || string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedException(); + } + + request.Id = userId; + + await mediator.Send(request, cancellationToken); + return Results.Ok(); + }) + .WithName("UpdateUserProfile") + .WithSummary("Update user profile") + .RequirePermission(IdentityPermissionConstants.Users.Update) + .WithDescription("Update profile details for the authenticated user."); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UserImageValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UserImageValidator.cs new file mode 100644 index 0000000000..7ff0f9171a --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UserImageValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using FSH.Framework.Storage; +using FSH.Framework.Storage.DTOs; + +namespace FSH.Modules.Identity.Features.v1.Users; + +public sealed class UserImageValidator : AbstractValidator +{ + public UserImageValidator() : this(FileType.Image) { } + public UserImageValidator(FileType fileType) + { + 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)}"); + + RuleFor(x => x.Data) + .NotEmpty() + .Must(data => data.Count <= rules.MaxSizeInMB * 1024 * 1024) + .WithMessage($"File must be <= {rules.MaxSizeInMB} MB."); + } +} \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/IRequiredPermissionMetadata.cs b/src/Modules/Identity/Modules.Identity/IRequiredPermissionMetadata.cs new file mode 100644 index 0000000000..d55e24c1c3 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/IRequiredPermissionMetadata.cs @@ -0,0 +1,6 @@ +namespace FSH.Modules.Identity; + +public interface IRequiredPermissionMetadata +{ + HashSet RequiredPermissions { get; } +} diff --git a/src/Modules/Identity/Modules.Identity/IdentityMetrics.cs b/src/Modules/Identity/Modules.Identity/IdentityMetrics.cs new file mode 100644 index 0000000000..a2faf0c784 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/IdentityMetrics.cs @@ -0,0 +1,30 @@ +using System.Diagnostics.Metrics; + +namespace FSH.Modules.Identity; + +public sealed class IdentityMetrics : IDisposable +{ + public const string MeterName = "FSH.Modules.Identity"; + private readonly Counter _tokensGenerated; + private readonly Meter _meter; + + public IdentityMetrics(IMeterFactory meterFactory) + { + _meter = meterFactory.Create(MeterName); + + _tokensGenerated = _meter.CreateCounter( + name: "identity_tokens_generated", + unit: "tokens", + description: "Number of tokens generated"); + } + + public void TokenGenerated(string emailId) + { + _tokensGenerated.Add(1, new KeyValuePair("user.email", emailId)); + } + + public void Dispose() + { + _meter?.Dispose(); + } +} \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/IdentityModule.cs b/src/Modules/Identity/Modules.Identity/IdentityModule.cs new file mode 100644 index 0000000000..43467574ac --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/IdentityModule.cs @@ -0,0 +1,204 @@ +using Asp.Versioning; +using FSH.Framework.Core.Context; +using FSH.Framework.Eventing; +using FSH.Framework.Eventing.Outbox; +using FSH.Framework.Identity.v1.Tokens.RefreshToken; +using FSH.Framework.Identity.v1.Tokens.TokenGeneration; +using FSH.Framework.Infrastructure.Identity.Users.Endpoints; +using FSH.Framework.Infrastructure.Identity.Users.Services; +using FSH.Framework.Persistence; +using FSH.Framework.Storage.Local; +using FSH.Framework.Storage.Services; +using FSH.Framework.Storage; +using FSH.Framework.Web.Modules; +using FSH.Modules.Identity.Authorization; +using FSH.Modules.Identity.Authorization.Jwt; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Features.v1.Roles; +using FSH.Modules.Identity.Features.v1.Roles.DeleteRole; +using FSH.Modules.Identity.Features.v1.Roles.GetRoleById; +using FSH.Modules.Identity.Features.v1.Roles.GetRoles; +using FSH.Modules.Identity.Features.v1.Roles.GetRoleWithPermissions; +using FSH.Modules.Identity.Features.v1.Roles.UpdateRolePermissions; +using FSH.Modules.Identity.Features.v1.Roles.UpsertRole; +using FSH.Modules.Identity.Features.v1.Users; +using FSH.Modules.Identity.Features.v1.Users.AssignUserRoles; +using FSH.Modules.Identity.Features.v1.Users.ChangePassword; +using FSH.Modules.Identity.Features.v1.Users.ConfirmEmail; +using FSH.Modules.Identity.Features.v1.Users.DeleteUser; +using FSH.Modules.Identity.Features.v1.Users.GetUserById; +using FSH.Modules.Identity.Features.v1.Users.GetUserPermissions; +using FSH.Modules.Identity.Features.v1.Users.GetUserProfile; +using FSH.Modules.Identity.Features.v1.Users.GetUserRoles; +using FSH.Modules.Identity.Features.v1.Users.GetUsers; +using FSH.Modules.Identity.Features.v1.Users.RegisterUser; +using FSH.Modules.Identity.Features.v1.Users.SearchUsers; +using FSH.Modules.Identity.Features.v1.Users.ResetPassword; +using FSH.Modules.Identity.Features.v1.Users.ToggleUserStatus; +using FSH.Modules.Identity.Features.v1.Users.UpdateUser; +using FSH.Modules.Identity.Features.v1.Sessions.GetMySessions; +using FSH.Modules.Identity.Features.v1.Sessions.RevokeSession; +using FSH.Modules.Identity.Features.v1.Sessions.RevokeAllSessions; +using FSH.Modules.Identity.Features.v1.Sessions.GetUserSessions; +using FSH.Modules.Identity.Features.v1.Sessions.AdminRevokeSession; +using FSH.Modules.Identity.Features.v1.Sessions.AdminRevokeAllSessions; +using FSH.Modules.Identity.Features.v1.Groups.CreateGroup; +using FSH.Modules.Identity.Features.v1.Groups.UpdateGroup; +using FSH.Modules.Identity.Features.v1.Groups.DeleteGroup; +using FSH.Modules.Identity.Features.v1.Groups.GetGroups; +using FSH.Modules.Identity.Features.v1.Groups.GetGroupById; +using FSH.Modules.Identity.Features.v1.Groups.GetGroupMembers; +using FSH.Modules.Identity.Features.v1.Groups.AddUsersToGroup; +using FSH.Modules.Identity.Features.v1.Groups.RemoveUserFromGroup; +using FSH.Modules.Identity.Features.v1.Users.GetUserGroups; +using FSH.Modules.Identity.Services; +using Hangfire; +using Hangfire.Common; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; + +namespace FSH.Modules.Identity; + +public class IdentityModule : IModule +{ + public void ConfigureServices(IHostApplicationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + var services = builder.Services; + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(sp => (ICurrentUserInitializer)sp.GetRequiredService()); + services.AddTransient(); + services.AddTransient(); + services.AddHeroStorage(builder.Configuration); + services.AddScoped(); + services.AddHeroDbContext(); + services.AddEventingCore(builder.Configuration); + services.AddEventingForDbContext(); + services.AddIntegrationEventHandlers(typeof(IdentityModule).Assembly); + builder.Services.AddHealthChecks() + .AddDbContextCheck( + name: "db:identity", + failureStatus: HealthStatus.Unhealthy); + services.AddScoped(); + + // Configure password policy options + services.Configure(builder.Configuration.GetSection("PasswordPolicy")); + + // Register password history service + services.AddScoped(); + + // Register password expiry service + services.AddScoped(); + + // Register session service + services.AddScoped(); + + // Register group role service for group-derived permissions + services.AddScoped(); + + services.AddIdentity(options => + { + options.Password.RequiredLength = IdentityModuleConstants.PasswordLength; + options.Password.RequireDigit = false; + options.Password.RequireLowercase = false; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequireUppercase = false; + options.User.RequireUniqueEmail = true; + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + //metrics + services.AddSingleton(); + + services.ConfigureJwtAuth(); + } + + public void MapEndpoints(IEndpointRouteBuilder endpoints) + { + ArgumentNullException.ThrowIfNull(endpoints); + + var apiVersionSet = endpoints.NewApiVersionSet() + .HasApiVersion(new ApiVersion(1)) + .ReportApiVersions() + .Build(); + + var group = endpoints + .MapGroup("api/v{version:apiVersion}/identity") + .WithTags("Identity") + .WithApiVersionSet(apiVersionSet); + + // tokens + group.MapGenerateTokenEndpoint().AllowAnonymous().RequireRateLimiting("auth"); + group.MapRefreshTokenEndpoint().AllowAnonymous().RequireRateLimiting("auth"); + + // example Hangfire setup for Identity outbox dispatcher + var jobManager = endpoints.ServiceProvider.GetService(); + if (jobManager is not null) + { + jobManager.AddOrUpdate( + "identity-outbox-dispatcher", + Job.FromExpression(d => d.DispatchAsync(CancellationToken.None)), + Cron.Minutely(), + new RecurringJobOptions()); + } + + // roles + group.MapGetRolesEndpoint(); + group.MapGetRoleByIdEndpoint(); + group.MapDeleteRoleEndpoint(); + group.MapGetRolePermissionsEndpoint(); + group.MapUpdateRolePermissionsEndpoint(); + group.MapCreateOrUpdateRoleEndpoint(); + + // users + group.MapAssignUserRolesEndpoint(); + group.MapChangePasswordEndpoint(); + group.MapConfirmEmailEndpoint().RequireRateLimiting("auth"); + group.MapDeleteUserEndpoint(); + group.MapGetUserByIdEndpoint(); + group.MapGetCurrentUserPermissionsEndpoint(); + group.MapGetMeEndpoint(); + group.MapGetUserRolesEndpoint(); + group.MapGetUsersListEndpoint(); + group.MapSearchUsersEndpoint(); + group.MapRegisterUserEndpoint(); + group.MapResetPasswordEndpoint(); + group.MapSelfRegisterUserEndpoint(); + group.ToggleUserStatusEndpointEndpoint(); + group.MapUpdateUserEndpoint(); + + // sessions - user endpoints + group.MapGetMySessionsEndpoint(); + group.MapRevokeSessionEndpoint(); + group.MapRevokeAllSessionsEndpoint(); + + // sessions - admin endpoints + group.MapGetUserSessionsEndpoint(); + group.MapAdminRevokeSessionEndpoint(); + group.MapAdminRevokeAllSessionsEndpoint(); + + // groups + group.MapGetGroupsEndpoint(); + group.MapGetGroupByIdEndpoint(); + group.MapCreateGroupEndpoint(); + group.MapUpdateGroupEndpoint(); + group.MapDeleteGroupEndpoint(); + group.MapGetGroupMembersEndpoint(); + group.MapAddUsersToGroupEndpoint(); + group.MapRemoveUserFromGroupEndpoint(); + + // user groups + group.MapGetUserGroupsEndpoint(); + } +} diff --git a/src/Modules/Identity/Modules.Identity/IdentityModuleConstants.cs b/src/Modules/Identity/Modules.Identity/IdentityModuleConstants.cs new file mode 100644 index 0000000000..a831a4f54f --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/IdentityModuleConstants.cs @@ -0,0 +1,14 @@ +using FSH.Framework.Web.Modules; + +namespace FSH.Modules.Identity; + +public sealed class IdentityModuleConstants : IModuleConstants +{ + public string ModuleId => "Identity"; + + public string ModuleName => "Identity"; + + public string ApiPrefix => "identity"; + public const string SchemaName = "identity"; + public const int PasswordLength = 10; +} diff --git a/src/Modules/Identity/Modules.Identity/Modules.Identity.csproj b/src/Modules/Identity/Modules.Identity/Modules.Identity.csproj new file mode 100644 index 0000000000..a6b2dc74c7 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Modules.Identity.csproj @@ -0,0 +1,30 @@ + + + + FSH.Modules.Identity + FSH.Modules.Identity + FullStackHero.Modules.Identity + $(NoWarn);CA1031;CA1812;CA2208;S3267;S3928;CA1062;CA1304;CA1308;CA1311;CA1862 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs b/src/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs new file mode 100644 index 0000000000..888d251e57 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs @@ -0,0 +1,62 @@ +using FSH.Framework.Core.Context; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Shared.Identity.Claims; +using System.Security.Claims; + +namespace FSH.Modules.Identity.Services; + +public class CurrentUserService : ICurrentUser, ICurrentUserInitializer +{ + private ClaimsPrincipal? _user; + + public string? Name => _user?.Identity?.Name; + + private Guid _userId = Guid.Empty; + + public Guid GetUserId() + { + return IsAuthenticated() + ? Guid.Parse(_user?.GetUserId() ?? Guid.Empty.ToString()) + : _userId; + } + + public string? GetUserEmail() => + IsAuthenticated() + ? _user!.GetEmail() + : string.Empty; + + public bool IsAuthenticated() => + _user?.Identity?.IsAuthenticated is true; + + public bool IsInRole(string role) => + _user?.IsInRole(role) is true; + + public IEnumerable? GetUserClaims() => + _user?.Claims; + + public string? GetTenant() => + IsAuthenticated() ? _user?.GetTenant() : string.Empty; + + public void SetCurrentUser(ClaimsPrincipal user) + { + if (_user != null) + { + throw new CustomException("Method reserved for in-scope initialization"); + } + + _user = user; + } + + public void SetCurrentUserId(string userId) + { + if (_userId != Guid.Empty) + { + throw new CustomException("Method reserved for in-scope initialization"); + } + + if (!string.IsNullOrEmpty(userId)) + { + _userId = Guid.Parse(userId); + } + } +} \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Services/GroupRoleService.cs b/src/Modules/Identity/Modules.Identity/Services/GroupRoleService.cs new file mode 100644 index 0000000000..f0efde2b23 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Services/GroupRoleService.cs @@ -0,0 +1,40 @@ +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Data; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Identity.Services; + +public sealed class GroupRoleService : IGroupRoleService +{ + private readonly IdentityDbContext _dbContext; + + public GroupRoleService(IdentityDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task> GetUserGroupRolesAsync(string userId, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(userId); + + // Get all group IDs the user belongs to + var userGroupIds = await _dbContext.UserGroups + .Where(ug => ug.UserId == userId) + .Select(ug => ug.GroupId) + .ToListAsync(ct); + + if (userGroupIds.Count == 0) + { + return []; + } + + // Get all distinct role names from those groups + var groupRoles = await _dbContext.GroupRoles + .Where(gr => userGroupIds.Contains(gr.GroupId)) + .Select(gr => gr.Role!.Name!) + .Distinct() + .ToListAsync(ct); + + return groupRoles; + } +} diff --git a/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs b/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs new file mode 100644 index 0000000000..1134906530 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs @@ -0,0 +1,227 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Shared.Constants; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Features.v1.Users; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; + +namespace FSH.Modules.Identity.Services; + +public sealed class IdentityService : IIdentityService +{ + private readonly UserManager _userManager; + private readonly ILogger _logger; + private readonly IMultiTenantContextAccessor? _multiTenantContextAccessor; + private readonly IGroupRoleService _groupRoleService; + + public IdentityService( + UserManager userManager, + IMultiTenantContextAccessor? multiTenantContextAccessor, + ILogger logger, + IGroupRoleService groupRoleService) + { + _userManager = userManager; + _multiTenantContextAccessor = multiTenantContextAccessor; + _logger = logger; + _groupRoleService = groupRoleService; + } + + public async Task<(string Subject, IEnumerable Claims)?> + ValidateCredentialsAsync(string email, string password, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(email); + ArgumentNullException.ThrowIfNull(password); + + var currentTenant = _multiTenantContextAccessor!.MultiTenantContext.TenantInfo; + if (currentTenant == null) throw new UnauthorizedException(); + + if (string.IsNullOrWhiteSpace(currentTenant.Id) + || await _userManager.FindByEmailAsync(email.Trim().Normalize()) is not { } user + || !await _userManager.CheckPasswordAsync(user, password)) + { + throw new UnauthorizedException(); + } + + if (!user.IsActive) + { + throw new UnauthorizedException("user is deactivated"); + } + + if (!user.EmailConfirmed) + { + throw new UnauthorizedException("email not confirmed"); + } + + if (currentTenant.Id != MultitenancyConstants.Root.Id) + { + if (!currentTenant.IsActive) + { + throw new UnauthorizedException($"tenant {currentTenant.Id} is deactivated"); + } + + if (DateTime.UtcNow > currentTenant.ValidUpto) + { + throw new UnauthorizedException($"tenant {currentTenant.Id} validity has expired"); + } + } + + // Build user claims + var claims = new List + { + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new(ClaimTypes.NameIdentifier, user.Id), + new(ClaimTypes.Email, user.Email!), + new(ClaimTypes.Name, user.FirstName ?? string.Empty), + new(ClaimTypes.MobilePhone, user.PhoneNumber ?? string.Empty), + new(ClaimConstants.Fullname, $"{user.FirstName} {user.LastName}"), + new(ClaimTypes.Surname, user.LastName ?? string.Empty), + new(ClaimConstants.Tenant, _multiTenantContextAccessor!.MultiTenantContext.TenantInfo!.Id), + new(ClaimConstants.ImageUrl, user.ImageUrl == null ? string.Empty : user.ImageUrl.ToString()) + }; + + // Add roles as claims (direct roles + group-derived roles) + var directRoles = await _userManager.GetRolesAsync(user); + var groupRoles = await _groupRoleService.GetUserGroupRolesAsync(user.Id, ct); + + // Combine and deduplicate roles + var allRoles = directRoles.Union(groupRoles).Distinct(); + claims.AddRange(allRoles.Select(r => new Claim(ClaimTypes.Role, r))); + + return (user.Id, claims); + } + + public async Task<(string Subject, IEnumerable Claims)?> + ValidateRefreshTokenAsync(string refreshToken, CancellationToken ct = default) + { + var currentTenant = _multiTenantContextAccessor!.MultiTenantContext.TenantInfo; + if (currentTenant == null) throw new UnauthorizedException(); + + if (string.IsNullOrWhiteSpace(currentTenant.Id)) + { + throw new UnauthorizedException(); + } + + var hashedToken = HashToken(refreshToken); + + _logger.LogDebug( + "Validating refresh token for tenant {TenantId}. Token hash: {TokenHash}", + currentTenant.Id, + hashedToken[..Math.Min(8, hashedToken.Length)] + "..."); + + var user = await _userManager.Users + .FirstOrDefaultAsync(u => u.RefreshToken == hashedToken, ct); + + if (user is null) + { + _logger.LogWarning( + "No user found with matching refresh token hash for tenant {TenantId}", + currentTenant.Id); + throw new UnauthorizedException("refresh token is invalid or expired"); + } + + if (user.RefreshTokenExpiryTime <= DateTime.UtcNow) + { + _logger.LogWarning( + "Refresh token expired for user {UserId}. Expired at: {ExpiryTime}, Current time: {CurrentTime}", + user.Id, + user.RefreshTokenExpiryTime, + DateTime.UtcNow); + throw new UnauthorizedException("refresh token is invalid or expired"); + } + + if (!user.IsActive) + { + throw new UnauthorizedException("user is deactivated"); + } + + if (!user.EmailConfirmed) + { + throw new UnauthorizedException("email not confirmed"); + } + + if (currentTenant.Id != MultitenancyConstants.Root.Id) + { + if (!currentTenant.IsActive) + { + throw new UnauthorizedException($"tenant {currentTenant.Id} is deactivated"); + } + + if (DateTime.UtcNow > currentTenant.ValidUpto) + { + throw new UnauthorizedException($"tenant {currentTenant.Id} validity has expired"); + } + } + + var claims = new List + { + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new(ClaimTypes.NameIdentifier, user.Id), + new(ClaimTypes.Email, user.Email!), + new(ClaimTypes.Name, user.FirstName ?? string.Empty), + new(ClaimTypes.MobilePhone, user.PhoneNumber ?? string.Empty), + new(ClaimConstants.Fullname, $"{user.FirstName} {user.LastName}"), + new(ClaimTypes.Surname, user.LastName ?? string.Empty), + new(ClaimConstants.Tenant, _multiTenantContextAccessor!.MultiTenantContext.TenantInfo!.Id), + new(ClaimConstants.ImageUrl, user.ImageUrl == null ? string.Empty : user.ImageUrl.ToString()) + }; + + // Add roles as claims (direct roles + group-derived roles) + var directRoles = await _userManager.GetRolesAsync(user); + var groupRoles = await _groupRoleService.GetUserGroupRolesAsync(user.Id, ct); + + // Combine and deduplicate roles + var allRoles = directRoles.Union(groupRoles).Distinct(); + claims.AddRange(allRoles.Select(r => new Claim(ClaimTypes.Role, r))); + + return (user.Id, claims); + } + + public async Task StoreRefreshTokenAsync(string subject, string refreshToken, DateTime expiresAtUtc, CancellationToken ct = default) + { + var currentTenant = _multiTenantContextAccessor!.MultiTenantContext.TenantInfo; + if (currentTenant == null) throw new UnauthorizedException(); + + if (string.IsNullOrWhiteSpace(currentTenant.Id)) + { + throw new UnauthorizedException(); + } + + var user = await _userManager.FindByIdAsync(subject); + + if (user is null) + { + throw new UnauthorizedException("user not found"); + } + + var hashedToken = HashToken(refreshToken); + user.RefreshToken = hashedToken; + user.RefreshTokenExpiryTime = expiresAtUtc; + + _logger.LogDebug( + "Storing refresh token for user {UserId} in tenant {TenantId}. Token hash: {TokenHash}, Expires: {ExpiresAt}", + subject, + currentTenant.Id, + hashedToken[..Math.Min(8, hashedToken.Length)] + "...", + expiresAtUtc); + + var result = await _userManager.UpdateAsync(user); + + if (!result.Succeeded) + { + _logger.LogError("Failed to persist refresh token for user {UserId}: {Errors}", subject, string.Join(", ", result.Errors.Select(e => e.Description))); + throw new UnauthorizedException("could not persist refresh token"); + } + } + + private static string HashToken(string token) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(token); + var hash = System.Security.Cryptography.SHA256.HashData(bytes); + return Convert.ToBase64String(hash); + } +} diff --git a/src/Modules/Identity/Modules.Identity/Services/PasswordExpiryService.cs b/src/Modules/Identity/Modules.Identity/Services/PasswordExpiryService.cs new file mode 100644 index 0000000000..c94a2fda16 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Services/PasswordExpiryService.cs @@ -0,0 +1,108 @@ +using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Features.v1.Users; +using Microsoft.Extensions.Options; + +namespace FSH.Modules.Identity.Services; + +public interface IPasswordExpiryService +{ + /// Check if a user's password has expired + bool IsPasswordExpired(FshUser user); + + /// Get the number of days until password expires (-1 if already expired) + int GetDaysUntilExpiry(FshUser user); + + /// Check if password is expiring soon (within warning period) + bool IsPasswordExpiringWithinWarningPeriod(FshUser user); + + /// Get expiry status with detailed information + PasswordExpiryStatus GetPasswordExpiryStatus(FshUser user); + + /// Update the last password change date for a user + void UpdateLastPasswordChangeDate(FshUser user); +} + +public class PasswordExpiryStatus +{ + public bool IsExpired { get; set; } + public bool IsExpiringWithinWarningPeriod { get; set; } + public int DaysUntilExpiry { get; set; } + public DateTime? ExpiryDate { get; set; } + + public string Status + { + get + { + if (IsExpired) + return "Expired"; + if (IsExpiringWithinWarningPeriod) + return "Expiring Soon"; + return "Valid"; + } + } +} + +internal sealed class PasswordExpiryService : IPasswordExpiryService +{ + private readonly PasswordPolicyOptions _passwordPolicyOptions; + + public PasswordExpiryService(IOptions passwordPolicyOptions) + { + _passwordPolicyOptions = passwordPolicyOptions.Value; + } + + public bool IsPasswordExpired(FshUser user) + { + if (!_passwordPolicyOptions.EnforcePasswordExpiry) + { + return false; + } + + var expiryDate = user.LastPasswordChangeDate.AddDays(_passwordPolicyOptions.PasswordExpiryDays); + return DateTime.UtcNow > expiryDate; + } + + public int GetDaysUntilExpiry(FshUser user) + { + if (!_passwordPolicyOptions.EnforcePasswordExpiry) + { + return int.MaxValue; + } + + var expiryDate = user.LastPasswordChangeDate.AddDays(_passwordPolicyOptions.PasswordExpiryDays); + var daysUntilExpiry = (int)(expiryDate - DateTime.UtcNow).TotalDays; + return daysUntilExpiry; + } + + public bool IsPasswordExpiringWithinWarningPeriod(FshUser user) + { + if (!_passwordPolicyOptions.EnforcePasswordExpiry) + { + return false; + } + + var daysUntilExpiry = GetDaysUntilExpiry(user); + return daysUntilExpiry >= 0 && daysUntilExpiry <= _passwordPolicyOptions.PasswordExpiryWarningDays; + } + + public PasswordExpiryStatus GetPasswordExpiryStatus(FshUser user) + { + var expiryDate = user.LastPasswordChangeDate.AddDays(_passwordPolicyOptions.PasswordExpiryDays); + var daysUntilExpiry = GetDaysUntilExpiry(user); + var isExpired = IsPasswordExpired(user); + var isExpiringWithinWarningPeriod = IsPasswordExpiringWithinWarningPeriod(user); + + return new PasswordExpiryStatus + { + IsExpired = isExpired, + IsExpiringWithinWarningPeriod = isExpiringWithinWarningPeriod, + DaysUntilExpiry = daysUntilExpiry, + ExpiryDate = _passwordPolicyOptions.EnforcePasswordExpiry ? expiryDate : null + }; + } + + public void UpdateLastPasswordChangeDate(FshUser user) + { + user.LastPasswordChangeDate = DateTime.UtcNow; + } +} diff --git a/src/Modules/Identity/Modules.Identity/Services/PasswordHistoryService.cs b/src/Modules/Identity/Modules.Identity/Services/PasswordHistoryService.cs new file mode 100644 index 0000000000..284700507d --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Services/PasswordHistoryService.cs @@ -0,0 +1,112 @@ +using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Features.v1.Users; +using FSH.Modules.Identity.Features.v1.Users.PasswordHistory; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace FSH.Modules.Identity.Services; + +public interface IPasswordHistoryService +{ + Task IsPasswordInHistoryAsync(FshUser user, string newPassword, CancellationToken cancellationToken = default); + Task SavePasswordHistoryAsync(FshUser user, CancellationToken cancellationToken = default); + Task CleanupOldPasswordHistoryAsync(string userId, CancellationToken cancellationToken = default); +} + +internal sealed class PasswordHistoryService : IPasswordHistoryService +{ + private readonly IdentityDbContext _db; + private readonly UserManager _userManager; + private readonly PasswordPolicyOptions _passwordPolicyOptions; + + public PasswordHistoryService( + IdentityDbContext db, + UserManager userManager, + IOptions passwordPolicyOptions) + { + _db = db; + _userManager = userManager; + _passwordPolicyOptions = passwordPolicyOptions.Value; + } + + public async Task IsPasswordInHistoryAsync(FshUser user, string newPassword, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(newPassword); + + // Get the last N passwords from history (where N = PasswordHistoryCount) + var passwordHistoryCount = _passwordPolicyOptions.PasswordHistoryCount; + if (passwordHistoryCount <= 0) + { + return false; // Password history check disabled + } + + var recentPasswordHashes = await _db.Set() + .Where(ph => ph.UserId == user.Id) + .OrderByDescending(ph => ph.CreatedAt) + .Take(passwordHistoryCount) + .Select(ph => ph.PasswordHash) + .ToListAsync(cancellationToken); + + // Check if the new password matches any recent password + foreach (var passwordHash in recentPasswordHashes) + { + var passwordHasher = _userManager.PasswordHasher; + var result = passwordHasher.VerifyHashedPassword(user, passwordHash, newPassword); + + if (result == PasswordVerificationResult.Success || result == PasswordVerificationResult.SuccessRehashNeeded) + { + return true; // Password is in history + } + } + + return false; // Password is not in history + } + + public async Task SavePasswordHistoryAsync(FshUser user, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(user); + + var passwordHistoryEntry = new PasswordHistory + { + UserId = user.Id, + PasswordHash = user.PasswordHash!, + CreatedAt = DateTime.UtcNow + }; + + _db.Set().Add(passwordHistoryEntry); + await _db.SaveChangesAsync(cancellationToken); + + // Clean up old password history entries + await CleanupOldPasswordHistoryAsync(user.Id, cancellationToken); + } + + public async Task CleanupOldPasswordHistoryAsync(string userId, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(userId); + + var passwordHistoryCount = _passwordPolicyOptions.PasswordHistoryCount; + if (passwordHistoryCount <= 0) + { + return; // Password history disabled + } + + // Get all password history entries for the user, ordered by most recent + var allPasswordHistories = await _db.Set() + .Where(ph => ph.UserId == userId) + .OrderByDescending(ph => ph.CreatedAt) + .ToListAsync(cancellationToken); + + // Keep only the configured number of passwords + if (allPasswordHistories.Count > passwordHistoryCount) + { + var oldPasswordHistories = allPasswordHistories + .Skip(passwordHistoryCount) + .ToList(); + + _db.Set().RemoveRange(oldPasswordHistories); + await _db.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/src/Modules/Identity/Modules.Identity/Services/SessionService.cs b/src/Modules/Identity/Modules.Identity/Services/SessionService.cs new file mode 100644 index 0000000000..9f3772910c --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Services/SessionService.cs @@ -0,0 +1,387 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Core.Context; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Identity.Contracts.DTOs; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Features.v1.Sessions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using UAParser; + +namespace FSH.Modules.Identity.Services; + +public sealed class SessionService : ISessionService +{ + private readonly IdentityDbContext _db; + private readonly ICurrentUser _currentUser; + private readonly IMultiTenantContextAccessor _multiTenantContextAccessor; + private readonly ILogger _logger; + private readonly Parser _uaParser; + + public SessionService( + IdentityDbContext db, + ICurrentUser currentUser, + IMultiTenantContextAccessor multiTenantContextAccessor, + ILogger logger) + { + _db = db; + _currentUser = currentUser; + _multiTenantContextAccessor = multiTenantContextAccessor; + _logger = logger; + _uaParser = Parser.GetDefault(); + } + + private void EnsureValidTenant() + { + if (string.IsNullOrWhiteSpace(_multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) + { + throw new UnauthorizedAccessException("Invalid tenant"); + } + } + + public async Task CreateSessionAsync( + string userId, + string refreshTokenHash, + string ipAddress, + string userAgent, + DateTime expiresAt, + CancellationToken cancellationToken = default) + { + EnsureValidTenant(); + + var clientInfo = _uaParser.Parse(userAgent); + + var session = new UserSession + { + UserId = userId, + RefreshTokenHash = refreshTokenHash, + IpAddress = ipAddress, + UserAgent = userAgent, + DeviceType = GetDeviceType(clientInfo.Device.Family), + Browser = clientInfo.UA.Family, + BrowserVersion = clientInfo.UA.Major, + OperatingSystem = clientInfo.OS.Family, + OsVersion = clientInfo.OS.Major, + ExpiresAt = expiresAt, + CreatedAt = DateTime.UtcNow, + LastActivityAt = DateTime.UtcNow + }; + + _db.UserSessions.Add(session); + await _db.SaveChangesAsync(cancellationToken); + + _logger.LogInformation("Created session {SessionId} for user {UserId}", session.Id, userId); + + return MapToDto(session, isCurrentSession: true); + } + + public async Task> GetUserSessionsAsync( + string userId, + CancellationToken cancellationToken = default) + { + EnsureValidTenant(); + + var currentUserId = _currentUser.GetUserId().ToString(); + if (!string.Equals(userId, currentUserId, StringComparison.OrdinalIgnoreCase)) + { + throw new UnauthorizedAccessException("Cannot view sessions for another user"); + } + + var sessions = await _db.UserSessions + .AsNoTracking() + .Where(s => s.UserId == userId && !s.IsRevoked && s.ExpiresAt > DateTime.UtcNow) + .OrderByDescending(s => s.LastActivityAt) + .ToListAsync(cancellationToken); + + return sessions.Select(s => MapToDto(s, isCurrentSession: false)).ToList(); + } + + public async Task> GetUserSessionsForAdminAsync( + string userId, + CancellationToken cancellationToken = default) + { + EnsureValidTenant(); + + var sessions = await _db.UserSessions + .AsNoTracking() + .Include(s => s.User) + .Where(s => s.UserId == userId && !s.IsRevoked && s.ExpiresAt > DateTime.UtcNow) + .OrderByDescending(s => s.LastActivityAt) + .ToListAsync(cancellationToken); + + return sessions.Select(s => MapToDto(s, isCurrentSession: false)).ToList(); + } + + public async Task GetSessionAsync( + Guid sessionId, + CancellationToken cancellationToken = default) + { + EnsureValidTenant(); + + var session = await _db.UserSessions + .AsNoTracking() + .Include(s => s.User) + .FirstOrDefaultAsync(s => s.Id == sessionId, cancellationToken); + + return session is null ? null : MapToDto(session, isCurrentSession: false); + } + + public async Task RevokeSessionAsync( + Guid sessionId, + string revokedBy, + string? reason = null, + CancellationToken cancellationToken = default) + { + EnsureValidTenant(); + + var session = await _db.UserSessions + .FirstOrDefaultAsync(s => s.Id == sessionId && !s.IsRevoked, cancellationToken); + + if (session is null) + { + return false; + } + + var currentUserId = _currentUser.GetUserId().ToString(); + if (!string.Equals(session.UserId, currentUserId, StringComparison.OrdinalIgnoreCase)) + { + throw new UnauthorizedAccessException("Cannot revoke session for another user"); + } + + session.IsRevoked = true; + session.RevokedAt = DateTime.UtcNow; + session.RevokedBy = revokedBy; + session.RevokedReason = reason ?? "User requested"; + + await _db.SaveChangesAsync(cancellationToken); + + _logger.LogInformation("Session {SessionId} revoked by {RevokedBy}", sessionId, revokedBy); + + return true; + } + + public async Task RevokeAllSessionsAsync( + string userId, + string revokedBy, + Guid? exceptSessionId = null, + string? reason = null, + CancellationToken cancellationToken = default) + { + EnsureValidTenant(); + + var currentUserId = _currentUser.GetUserId().ToString(); + if (!string.Equals(userId, currentUserId, StringComparison.OrdinalIgnoreCase)) + { + throw new UnauthorizedAccessException("Cannot revoke sessions for another user"); + } + + var query = _db.UserSessions + .Where(s => s.UserId == userId && !s.IsRevoked); + + if (exceptSessionId.HasValue) + { + query = query.Where(s => s.Id != exceptSessionId.Value); + } + + var sessions = await query.ToListAsync(cancellationToken); + + foreach (var session in sessions) + { + session.IsRevoked = true; + session.RevokedAt = DateTime.UtcNow; + session.RevokedBy = revokedBy; + session.RevokedReason = reason ?? "User requested logout from all devices"; + } + + await _db.SaveChangesAsync(cancellationToken); + + _logger.LogInformation("Revoked {Count} sessions for user {UserId}", sessions.Count, userId); + + return sessions.Count; + } + + public async Task RevokeAllSessionsForAdminAsync( + string userId, + string revokedBy, + string? reason = null, + CancellationToken cancellationToken = default) + { + EnsureValidTenant(); + + var sessions = await _db.UserSessions + .Where(s => s.UserId == userId && !s.IsRevoked) + .ToListAsync(cancellationToken); + + foreach (var session in sessions) + { + session.IsRevoked = true; + session.RevokedAt = DateTime.UtcNow; + session.RevokedBy = revokedBy; + session.RevokedReason = reason ?? "Admin requested"; + } + + await _db.SaveChangesAsync(cancellationToken); + + _logger.LogInformation("Admin {AdminId} revoked {Count} sessions for user {UserId}", + revokedBy, sessions.Count, userId); + + return sessions.Count; + } + + public async Task RevokeSessionForAdminAsync( + Guid sessionId, + string revokedBy, + string? reason = null, + CancellationToken cancellationToken = default) + { + EnsureValidTenant(); + + var session = await _db.UserSessions + .FirstOrDefaultAsync(s => s.Id == sessionId && !s.IsRevoked, cancellationToken); + + if (session is null) + { + return false; + } + + session.IsRevoked = true; + session.RevokedAt = DateTime.UtcNow; + session.RevokedBy = revokedBy; + session.RevokedReason = reason ?? "Admin requested"; + + await _db.SaveChangesAsync(cancellationToken); + + _logger.LogInformation("Admin {AdminId} revoked session {SessionId}", revokedBy, sessionId); + + return true; + } + + public async Task UpdateSessionActivityAsync( + string refreshTokenHash, + CancellationToken cancellationToken = default) + { + EnsureValidTenant(); + + var session = await _db.UserSessions + .FirstOrDefaultAsync(s => s.RefreshTokenHash == refreshTokenHash && !s.IsRevoked, cancellationToken); + + if (session is not null) + { + session.LastActivityAt = DateTime.UtcNow; + await _db.SaveChangesAsync(cancellationToken); + } + } + + public async Task UpdateSessionRefreshTokenAsync( + string oldRefreshTokenHash, + string newRefreshTokenHash, + DateTime newExpiresAt, + CancellationToken cancellationToken = default) + { + EnsureValidTenant(); + + var session = await _db.UserSessions + .FirstOrDefaultAsync(s => s.RefreshTokenHash == oldRefreshTokenHash && !s.IsRevoked, cancellationToken); + + if (session is not null) + { + session.RefreshTokenHash = newRefreshTokenHash; + session.ExpiresAt = newExpiresAt; + session.LastActivityAt = DateTime.UtcNow; + await _db.SaveChangesAsync(cancellationToken); + + _logger.LogInformation("Updated session {SessionId} with new refresh token", session.Id); + } + } + + public async Task ValidateSessionAsync( + string refreshTokenHash, + CancellationToken cancellationToken = default) + { + EnsureValidTenant(); + + var session = await _db.UserSessions + .AsNoTracking() + .FirstOrDefaultAsync(s => s.RefreshTokenHash == refreshTokenHash, cancellationToken); + + if (session is null) + { + return true; // No session tracking for this token (backwards compatibility) + } + + return !session.IsRevoked && session.ExpiresAt > DateTime.UtcNow; + } + + public async Task GetSessionIdByRefreshTokenAsync( + string refreshTokenHash, + CancellationToken cancellationToken = default) + { + EnsureValidTenant(); + + var session = await _db.UserSessions + .AsNoTracking() + .FirstOrDefaultAsync(s => s.RefreshTokenHash == refreshTokenHash && !s.IsRevoked, cancellationToken); + + return session?.Id; + } + + public async Task CleanupExpiredSessionsAsync( + CancellationToken cancellationToken = default) + { + var cutoffDate = DateTime.UtcNow.AddDays(-30); // Keep revoked sessions for 30 days for audit + var expiredSessions = await _db.UserSessions + .Where(s => s.ExpiresAt < DateTime.UtcNow && s.ExpiresAt < cutoffDate) + .ToListAsync(cancellationToken); + + if (expiredSessions.Count > 0) + { + _db.UserSessions.RemoveRange(expiredSessions); + await _db.SaveChangesAsync(cancellationToken); + _logger.LogInformation("Cleaned up {Count} expired sessions", expiredSessions.Count); + } + } + + private static string GetDeviceType(string deviceFamily) + { + if (string.IsNullOrWhiteSpace(deviceFamily) || deviceFamily == "Other") + { + return "Desktop"; + } + + var lower = deviceFamily.ToLowerInvariant(); + if (lower.Contains("mobile") || lower.Contains("phone") || lower.Contains("iphone") || lower.Contains("android")) + { + return "Mobile"; + } + + if (lower.Contains("tablet") || lower.Contains("ipad")) + { + return "Tablet"; + } + + return "Desktop"; + } + + private static UserSessionDto MapToDto(UserSession session, bool isCurrentSession) + { + return new UserSessionDto + { + Id = session.Id, + UserId = session.UserId, + UserName = session.User?.UserName, + UserEmail = session.User?.Email, + IpAddress = session.IpAddress, + DeviceType = session.DeviceType, + Browser = session.Browser, + BrowserVersion = session.BrowserVersion, + OperatingSystem = session.OperatingSystem, + OsVersion = session.OsVersion, + CreatedAt = session.CreatedAt, + LastActivityAt = session.LastActivityAt, + ExpiresAt = session.ExpiresAt, + IsActive = !session.IsRevoked && session.ExpiresAt > DateTime.UtcNow, + IsCurrentSession = isCurrentSession + }; + } +} diff --git a/src/Modules/Identity/Modules.Identity/Services/TokenService.cs b/src/Modules/Identity/Modules.Identity/Services/TokenService.cs new file mode 100644 index 0000000000..3e883d7666 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Services/TokenService.cs @@ -0,0 +1,63 @@ +using FSH.Modules.Identity.Authorization.Jwt; +using FSH.Modules.Identity.Contracts.DTOs; +using FSH.Modules.Identity.Contracts.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace FSH.Modules.Identity.Services; + +public sealed class TokenService : ITokenService +{ + private readonly JwtOptions _options; + private readonly ILogger _logger; + private readonly IdentityMetrics _metrics; + + public TokenService(IOptions options, ILogger logger, IdentityMetrics metrics) + { + ArgumentNullException.ThrowIfNull(options); + _options = options.Value; + _logger = logger; + _metrics = metrics; + } + + public Task IssueAsync( + string subject, + IEnumerable claims, + string? tenant = null, + CancellationToken ct = default) + { + var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey)); + var creds = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256); + + // Access token + var accessTokenExpiry = DateTime.UtcNow.AddMinutes(_options.AccessTokenMinutes); + var jwtToken = new JwtSecurityToken( + _options.Issuer, + _options.Audience, + claims, + expires: accessTokenExpiry, + signingCredentials: creds); + + var accessToken = new JwtSecurityTokenHandler().WriteToken(jwtToken); + + // Refresh token + var refreshToken = Convert.ToBase64String(Guid.NewGuid().ToByteArray()); + var refreshTokenExpiry = DateTime.UtcNow.AddDays(_options.RefreshTokenDays); + + var userEmail = claims.Where(a => a.Type == ClaimTypes.Email).Select(a => a.Value).First(); + _logger.LogInformation("Issued JWT for {Email}", userEmail); + _metrics.TokenGenerated(userEmail); + + var response = new TokenResponse( + AccessToken: accessToken, + RefreshToken: refreshToken, + RefreshTokenExpiresAt: refreshTokenExpiry, + AccessTokenExpiresAt: accessTokenExpiry); + + return Task.FromResult(response); + } +} \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Services/UserService.Password.cs b/src/Modules/Identity/Modules.Identity/Services/UserService.Password.cs new file mode 100644 index 0000000000..778e235f57 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Services/UserService.Password.cs @@ -0,0 +1,87 @@ +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Mailing; +using Microsoft.AspNetCore.WebUtilities; +using System.Collections.ObjectModel; +using System.Text; + +namespace FSH.Framework.Infrastructure.Identity.Users.Services; + +internal sealed partial class UserService +{ + public async Task ForgotPasswordAsync(string email, string origin, CancellationToken cancellationToken) + { + EnsureValidTenant(); + + var user = await userManager.FindByEmailAsync(email); + if (user == null) + { + throw new NotFoundException("user not found"); + } + + if (string.IsNullOrWhiteSpace(user.Email)) + { + throw new InvalidOperationException("user email cannot be null or empty"); + } + + var token = await userManager.GeneratePasswordResetTokenAsync(user); + token = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token)); + + var resetPasswordUri = $"{origin}/reset-password?token={token}&email={email}"; + var mailRequest = new MailRequest( + new Collection { user.Email }, + "Reset Password", + $"Please reset your password using the following link: {resetPasswordUri}"); + + jobService.Enqueue(() => mailService.SendAsync(mailRequest, CancellationToken.None)); + } + + public async Task ResetPasswordAsync(string email, string password, string token, CancellationToken cancellationToken) + { + EnsureValidTenant(); + + var user = await userManager.FindByEmailAsync(email); + if (user == null) + { + throw new NotFoundException("user not found"); + } + + token = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(token)); + var result = await userManager.ResetPasswordAsync(user, token, password); + + if (!result.Succeeded) + { + var errors = result.Errors.Select(e => e.Description).ToList(); + throw new CustomException("error resetting password", errors); + } + } + + public async Task ChangePasswordAsync(string password, string newPassword, string confirmNewPassword, string userId) + { + var user = await userManager.FindByIdAsync(userId); + + _ = user ?? throw new NotFoundException("user not found"); + + 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); + } + + // Save the old password hash to history after successful password change + // Reload user to get the new password hash + user = await userManager.FindByIdAsync(userId); + if (user is not null) + { + // Update password expiry date + _passwordExpiryService.UpdateLastPasswordChangeDate(user); + + // Save to history + await _passwordHistoryService.SavePasswordHistoryAsync(user); + + // Update user with new password change date + await userManager.UpdateAsync(user); + } + } +} \ No newline at end of file diff --git a/src/api/framework/Infrastructure/Identity/Users/Services/UserService.Permissions.cs b/src/Modules/Identity/Modules.Identity/Services/UserService.Permissions.cs similarity index 89% rename from src/api/framework/Infrastructure/Identity/Users/Services/UserService.Permissions.cs rename to src/Modules/Identity/Modules.Identity/Services/UserService.Permissions.cs index 52f5698f78..6d504dfd60 100644 --- a/src/api/framework/Infrastructure/Identity/Users/Services/UserService.Permissions.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserService.Permissions.cs @@ -1,9 +1,10 @@ -using FSH.Framework.Core.Caching; +using FSH.Framework.Caching; using FSH.Framework.Core.Exceptions; -using FSH.Starter.Shared.Authorization; +using FSH.Framework.Shared.Constants; using Microsoft.EntityFrameworkCore; namespace FSH.Framework.Infrastructure.Identity.Users.Services; + internal sealed partial class UserService { public async Task?> GetPermissionsAsync(string userId, CancellationToken cancellationToken) @@ -23,7 +24,7 @@ internal sealed partial class UserService .ToListAsync(cancellationToken)) { permissions.AddRange(await db.RoleClaims - .Where(rc => rc.RoleId == role.Id && rc.ClaimType == FshClaims.Permission) + .Where(rc => rc.RoleId == role.Id && rc.ClaimType == ClaimConstants.Permission) .Select(rc => rc.ClaimValue!) .ToListAsync(cancellationToken)); } @@ -48,6 +49,6 @@ public async Task HasPermissionAsync(string userId, string permission, Can public Task InvalidatePermissionCacheAsync(string userId, CancellationToken cancellationToken) { - return cache.RemoveAsync(GetPermissionCacheKey(userId), cancellationToken); + return cache.RemoveItemAsync(GetPermissionCacheKey(userId), cancellationToken); } -} +} \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Services/UserService.cs b/src/Modules/Identity/Modules.Identity/Services/UserService.cs new file mode 100644 index 0000000000..9889040a8d --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Services/UserService.cs @@ -0,0 +1,567 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Caching; +using FSH.Framework.Core.Common; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Core.Context; +using FSH.Framework.Eventing.Outbox; +using FSH.Framework.Jobs.Services; +using FSH.Framework.Mailing; +using FSH.Framework.Mailing.Services; +using FSH.Framework.Shared.Constants; +using FSH.Framework.Shared.Multitenancy; +using FSH.Framework.Storage; +using FSH.Framework.Storage.DTOs; +using FSH.Framework.Storage.Services; +using FSH.Framework.Web.Origin; +using FSH.Modules.Identity.Contracts.DTOs; +using FSH.Modules.Identity.Contracts.Events; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Features.v1.Groups; +using FSH.Modules.Identity.Features.v1.Roles; +using FSH.Modules.Identity.Features.v1.Users; +using FSH.Modules.Identity.Services; +using FSH.Modules.Auditing.Contracts; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.EntityFrameworkCore; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Security.Claims; +using System.Text; + +namespace FSH.Framework.Infrastructure.Identity.Users.Services; + +internal sealed partial class UserService( + UserManager userManager, + SignInManager signInManager, + RoleManager roleManager, + IdentityDbContext db, + ICacheService cache, + IJobService jobService, + IMailService mailService, + IMultiTenantContextAccessor multiTenantContextAccessor, + IStorageService storageService, + IOutboxStore outboxStore, + IOptions originOptions, + IHttpContextAccessor httpContextAccessor, + ICurrentUser currentUser, + IAuditClient auditClient, + IPasswordHistoryService passwordHistoryService, + IPasswordExpiryService passwordExpiryService + ) : IUserService +{ + private readonly Uri? _originUrl = originOptions.Value.OriginUrl; + private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor; + private readonly ICurrentUser _currentUser = currentUser; + private readonly IAuditClient _auditClient = auditClient; + private readonly IPasswordHistoryService _passwordHistoryService = passwordHistoryService; + private readonly IPasswordExpiryService _passwordExpiryService = passwordExpiryService; + + private void EnsureValidTenant() + { + if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) + { + throw new UnauthorizedException("invalid tenant"); + } + } + + public async Task ConfirmEmailAsync(string userId, string code, string tenant, CancellationToken cancellationToken) + { + EnsureValidTenant(); + + var user = await userManager.Users + .Where(u => u.Id == userId && !u.EmailConfirmed) + .FirstOrDefaultAsync(cancellationToken); + + _ = user ?? throw new CustomException("An error occurred while confirming E-Mail."); + + 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)); + } + + public Task ConfirmPhoneNumberAsync(string userId, string code) + { + throw new NotImplementedException(); + } + + public async Task ExistsWithEmailAsync(string email, string? exceptId = null) + { + EnsureValidTenant(); + return await userManager.FindByEmailAsync(email.Normalize()) is FshUser user && user.Id != exceptId; + } + + public async Task ExistsWithNameAsync(string name) + { + EnsureValidTenant(); + return await userManager.FindByNameAsync(name) is not null; + } + + public async Task ExistsWithPhoneNumberAsync(string phoneNumber, string? exceptId = null) + { + EnsureValidTenant(); + return await userManager.Users.FirstOrDefaultAsync(x => x.PhoneNumber == phoneNumber) is FshUser user && user.Id != exceptId; + } + + public async Task GetAsync(string userId, CancellationToken cancellationToken) + { + var user = await userManager.Users + .AsNoTracking() + .Where(u => u.Id == userId) + .FirstOrDefaultAsync(cancellationToken); + + _ = user ?? throw new NotFoundException("user not found"); + + return new UserDto + { + Id = user.Id, + Email = user.Email, + UserName = user.UserName, + FirstName = user.FirstName, + LastName = user.LastName, + ImageUrl = ResolveImageUrl(user.ImageUrl), + IsActive = user.IsActive + }; + } + + public Task GetCountAsync(CancellationToken cancellationToken) => + userManager.Users.AsNoTracking().CountAsync(cancellationToken); + + public async Task> GetListAsync(CancellationToken cancellationToken) + { + var users = await userManager.Users.AsNoTracking().ToListAsync(cancellationToken); + var result = new List(users.Count); + foreach (var user in users) + { + result.Add(new UserDto + { + Id = user.Id, + Email = user.Email, + UserName = user.UserName, + FirstName = user.FirstName, + LastName = user.LastName, + ImageUrl = ResolveImageUrl(user.ImageUrl), + IsActive = user.IsActive + }); + } + + return result; + } + + public Task GetOrCreateFromPrincipalAsync(ClaimsPrincipal principal) + { + throw new NotImplementedException(); + } + + public async Task RegisterAsync(string firstName, string lastName, string email, string userName, string password, string confirmPassword, string phoneNumber, string origin, CancellationToken cancellationToken) + { + if (password != confirmPassword) throw new CustomException("password mismatch."); + + // create user entity + var user = new FshUser + { + Email = email, + FirstName = firstName, + LastName = lastName, + UserName = userName, + PhoneNumber = phoneNumber, + IsActive = true, + EmailConfirmed = false, + PhoneNumberConfirmed = false, + }; + + // register user + var result = await userManager.CreateAsync(user, password); + if (!result.Succeeded) + { + var errors = result.Errors.Select(error => error.Description).ToList(); + throw new CustomException("error while registering a new user", errors); + } + + // add basic role + await userManager.AddToRoleAsync(user, RoleConstants.Basic); + + // add user to default groups + var defaultGroups = await db.Groups + .Where(g => g.IsDefault && !g.IsDeleted) + .ToListAsync(cancellationToken); + + foreach (var group in defaultGroups) + { + db.UserGroups.Add(new UserGroup + { + UserId = user.Id, + GroupId = group.Id, + AddedAt = DateTime.UtcNow, + AddedBy = "System" + }); + } + + if (defaultGroups.Count > 0) + { + await db.SaveChangesAsync(cancellationToken); + } + + // send confirmation mail + if (!string.IsNullOrEmpty(user.Email)) + { + string emailVerificationUri = await GetEmailVerificationUriAsync(user, origin); + string emailBody = BuildConfirmationEmailHtml(user.FirstName ?? user.UserName ?? "User", emailVerificationUri); + var mailRequest = new MailRequest( + new Collection { user.Email }, + "Confirm Your Email Address", + emailBody); + jobService.Enqueue("email", () => mailService.SendAsync(mailRequest, cancellationToken)); + } + + // enqueue integration event for user registration + var tenantId = multiTenantContextAccessor.MultiTenantContext.TenantInfo?.Id; + var correlationId = Guid.NewGuid().ToString(); + var integrationEvent = new UserRegisteredIntegrationEvent( + Id: Guid.NewGuid(), + OccurredOnUtc: DateTime.UtcNow, + TenantId: tenantId, + CorrelationId: correlationId, + Source: "Identity", + UserId: user.Id, + Email: user.Email ?? string.Empty, + FirstName: user.FirstName ?? string.Empty, + LastName: user.LastName ?? string.Empty); + + await outboxStore.AddAsync(integrationEvent, cancellationToken).ConfigureAwait(false); + + return user.Id; + } + + public async Task ToggleStatusAsync(bool activateUser, string userId, CancellationToken cancellationToken) + { + EnsureValidTenant(); + + var actorId = _currentUser.GetUserId(); + if (actorId == Guid.Empty) + { + throw new UnauthorizedException("authenticated user required to toggle status"); + } + + var actor = await userManager.FindByIdAsync(actorId.ToString()); + _ = actor ?? throw new UnauthorizedException("current user not found"); + + async ValueTask AuditPolicyFailureAsync(string reason, CancellationToken ct) + { + var tenant = multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id ?? "unknown"; + var claims = new Dictionary + { + ["actorId"] = actorId.ToString(), + ["targetUserId"] = userId, + ["tenant"] = tenant, + ["action"] = activateUser ? "activate" : "deactivate" + }; + + await _auditClient.WriteSecurityAsync( + SecurityAction.PolicyFailed, + subjectId: actorId.ToString(), + reasonCode: reason, + claims: claims, + severity: AuditSeverity.Warning, + source: "Identity", + ct: ct).ConfigureAwait(false); + } + + if (!await userManager.IsInRoleAsync(actor, RoleConstants.Admin)) + { + await AuditPolicyFailureAsync("ActorNotAdmin", cancellationToken); + throw new CustomException("Only administrators can toggle user status."); + } + + if (!activateUser && string.Equals(actor.Id, userId, StringComparison.Ordinal)) + { + await AuditPolicyFailureAsync("SelfDeactivationBlocked", cancellationToken); + throw new CustomException("Users cannot deactivate themselves."); + } + + var user = await userManager.Users.Where(u => u.Id == userId).FirstOrDefaultAsync(cancellationToken); + _ = user ?? throw new NotFoundException("User Not Found."); + + bool targetIsAdmin = await userManager.IsInRoleAsync(user, RoleConstants.Admin); + if (targetIsAdmin) + { + await AuditPolicyFailureAsync("AdminDeactivationBlocked", cancellationToken); + throw new CustomException("Administrators cannot be deactivated."); + } + + if (!activateUser) + { + var activeAdmins = await userManager.GetUsersInRoleAsync(RoleConstants.Admin); + int activeAdminCount = activeAdmins.Count(u => u.IsActive); + if (activeAdminCount == 0) + { + await AuditPolicyFailureAsync("NoActiveAdmins", cancellationToken); + throw new CustomException("Tenant must have at least one active administrator."); + } + } + + user.IsActive = activateUser; + + var result = await userManager.UpdateAsync(user); + if (!result.Succeeded) + { + var errors = result.Errors.Select(error => error.Description).ToList(); + throw new CustomException("Toggle status failed", errors); + } + + var tenantId = multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id ?? "unknown"; + await _auditClient.WriteActivityAsync( + ActivityKind.Command, + name: "ToggleUserStatus", + statusCode: 204, + durationMs: 0, + captured: BodyCapture.None, + requestSize: 0, + responseSize: 0, + requestPreview: new { actorId = actorId.ToString(), targetUserId = userId, action = activateUser ? "activate" : "deactivate", tenant = tenantId }, + responsePreview: new { outcome = "success" }, + severity: AuditSeverity.Information, + source: "Identity", + ct: cancellationToken).ConfigureAwait(false); + } + + public async Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage) + { + var user = await userManager.FindByIdAsync(userId); + + _ = user ?? throw new NotFoundException("user not found"); + + Uri imageUri = user.ImageUrl ?? null!; + if (image.Data != null || deleteCurrentImage) + { + var imageString = await storageService.UploadAsync(image, FileType.Image); + user.ImageUrl = new Uri(imageString, UriKind.RelativeOrAbsolute); + if (deleteCurrentImage && imageUri != null) + { + await storageService.RemoveAsync(imageUri.ToString()); + } + } + + user.FirstName = firstName; + user.LastName = lastName; + string? currentPhoneNumber = await userManager.GetPhoneNumberAsync(user); + if (phoneNumber != currentPhoneNumber) + { + await userManager.SetPhoneNumberAsync(user, phoneNumber); + } + + var result = await userManager.UpdateAsync(user); + await signInManager.RefreshSignInAsync(user); + + if (!result.Succeeded) + { + throw new CustomException("Update profile failed"); + } + } + + public async Task DeleteAsync(string userId) + { + FshUser? user = await userManager.FindByIdAsync(userId); + + _ = user ?? throw new NotFoundException("User Not Found."); + + user.IsActive = false; + IdentityResult? result = await userManager.UpdateAsync(user); + + if (!result.Succeeded) + { + List errors = result.Errors.Select(error => error.Description).ToList(); + throw new CustomException("Delete profile failed", errors); + } + } + + private async Task GetEmailVerificationUriAsync(FshUser user, string origin) + { + EnsureValidTenant(); + + string code = await userManager.GenerateEmailConfirmationTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + const string route = "api/v1/identity/confirm-email"; + var endpointUri = new Uri(string.Concat($"{origin}/", route)); + string verificationUri = QueryHelpers.AddQueryString(endpointUri.ToString(), QueryStringKeys.UserId, user.Id); + verificationUri = QueryHelpers.AddQueryString(verificationUri, QueryStringKeys.Code, code); + verificationUri = QueryHelpers.AddQueryString(verificationUri, + MultitenancyConstants.Identifier, + multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id!); + return verificationUri; + } + + private static string BuildConfirmationEmailHtml(string userName, string confirmationUrl) + { + return $""" + + + + + + Confirm Your Email + + + + + + +
+ + + + + + + + + + +
+

Confirm Your Email Address

+
+

+ Hi {System.Net.WebUtility.HtmlEncode(userName)}, +

+

+ Thank you for registering! Please confirm your email address by clicking the button below: +

+ + + + +
+ + Confirm Email Address + +
+

+ If the button doesn't work, copy and paste this link into your browser: +

+

+ {System.Net.WebUtility.HtmlEncode(confirmationUrl)} +

+

+ If you didn't create an account, you can safely ignore this email. +

+
+

+ This is an automated message. Please do not reply to this email. +

+
+
+ + + """; + } + + public async Task AssignRolesAsync(string userId, List userRoles, CancellationToken cancellationToken) + { + var user = await userManager.Users.Where(u => u.Id == userId).FirstOrDefaultAsync(cancellationToken); + + _ = user ?? throw new NotFoundException("user not found"); + + // Check if the user is an admin for which the admin role is getting disabled + if (await userManager.IsInRoleAsync(user, RoleConstants.Admin) + && userRoles.Exists(a => !a.Enabled && a.RoleName == RoleConstants.Admin)) + { + // Get count of users in Admin Role + int adminCount = (await userManager.GetUsersInRoleAsync(RoleConstants.Admin)).Count; + + // Check if user is not Root Tenant Admin + // Edge Case : there are chances for other tenants to have users with the same email as that of Root Tenant Admin. Probably can add a check while User Registration + if (user.Email == MultitenancyConstants.Root.EmailAddress) + { + if (multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id == MultitenancyConstants.Root.Id) + { + throw new CustomException("action not permitted"); + } + } + else if (adminCount <= 2) + { + throw new CustomException("tenant should have at least 2 admins."); + } + } + + foreach (var userRole in userRoles) + { + // Check if Role Exists + if (await roleManager.FindByNameAsync(userRole.RoleName!) is not null) + { + if (userRole.Enabled) + { + if (!await userManager.IsInRoleAsync(user, userRole.RoleName!)) + { + await userManager.AddToRoleAsync(user, userRole.RoleName!); + } + } + else + { + await userManager.RemoveFromRoleAsync(user, userRole.RoleName!); + } + } + } + + return "User Roles Updated Successfully."; + + } + + public async Task> GetUserRolesAsync(string userId, CancellationToken cancellationToken) + { + var userRoles = new List(); + + var user = await userManager.FindByIdAsync(userId); + if (user is null) throw new NotFoundException("user not found"); + var roles = await roleManager.Roles.AsNoTracking().ToListAsync(cancellationToken); + if (roles is null) throw new NotFoundException("roles not found"); + foreach (var role in roles) + { + userRoles.Add(new UserRoleDto + { + RoleId = role.Id, + RoleName = role.Name, + Description = role.Description, + Enabled = await userManager.IsInRoleAsync(user, role.Name!) + }); + } + + return userRoles; + } + + private string? ResolveImageUrl(Uri? imageUrl) + { + if (imageUrl is null) + { + return null; + } + + // Absolute URLs (e.g., S3) pass through unchanged. + if (imageUrl.IsAbsoluteUri) + { + return imageUrl.ToString(); + } + + // For relative paths from local storage, prefix with the API origin and wwwroot. + if (_originUrl is null) + { + var request = _httpContextAccessor.HttpContext?.Request; + if (request is not null && !string.IsNullOrWhiteSpace(request.Scheme) && request.Host.HasValue) + { + var baseUri = $"{request.Scheme}://{request.Host.Value}{request.PathBase}"; + var relativePath = imageUrl.ToString().TrimStart('/'); + return $"{baseUri.TrimEnd('/')}/{relativePath}"; + } + + return imageUrl.ToString(); + } + + var originRelativePath = imageUrl.ToString().TrimStart('/'); + return $"{_originUrl.AbsoluteUri.TrimEnd('/')}/{originRelativePath}"; + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Dtos/TenantDto.cs b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Dtos/TenantDto.cs new file mode 100644 index 0000000000..ddccd071e3 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Dtos/TenantDto.cs @@ -0,0 +1,12 @@ +namespace FSH.Modules.Multitenancy.Contracts.Dtos; + +public sealed class TenantDto +{ + public string Id { get; set; } = default!; + public string Name { get; set; } = default!; + public string? ConnectionString { get; set; } + public string AdminEmail { get; set; } = default!; + public bool IsActive { get; set; } + public DateTime ValidUpto { get; set; } + public string? Issuer { get; set; } +} \ No newline at end of file diff --git a/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Dtos/TenantLifecycleResultDto.cs b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Dtos/TenantLifecycleResultDto.cs new file mode 100644 index 0000000000..1f3eb6337b --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Dtos/TenantLifecycleResultDto.cs @@ -0,0 +1,12 @@ +namespace FSH.Modules.Multitenancy.Contracts.Dtos; + +public sealed class TenantLifecycleResultDto +{ + public string TenantId { get; set; } = default!; + + public bool IsActive { get; set; } + + public DateTime? ValidUpto { get; set; } + + public string Message { get; set; } = string.Empty; +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Dtos/TenantMigrationStatusDto.cs b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Dtos/TenantMigrationStatusDto.cs new file mode 100644 index 0000000000..fce2c22ac4 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Dtos/TenantMigrationStatusDto.cs @@ -0,0 +1,22 @@ +namespace FSH.Modules.Multitenancy.Contracts.Dtos; + +public sealed class TenantMigrationStatusDto +{ + public string TenantId { get; set; } = default!; + + public string Name { get; set; } = string.Empty; + + public bool IsActive { get; set; } + + public DateTime? ValidUpto { get; set; } + + public bool HasPendingMigrations { get; set; } + + public string? Provider { get; set; } + + public string? LastAppliedMigration { get; set; } + + public IReadOnlyCollection PendingMigrations { get; set; } = Array.Empty(); + + public string? Error { get; set; } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Dtos/TenantProvisioningStatusDto.cs b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Dtos/TenantProvisioningStatusDto.cs new file mode 100644 index 0000000000..83d25a037d --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Dtos/TenantProvisioningStatusDto.cs @@ -0,0 +1,19 @@ +namespace FSH.Modules.Multitenancy.Contracts.Dtos; + +public sealed record TenantProvisioningStepDto( + string Step, + string Status, + DateTime? StartedUtc, + DateTime? CompletedUtc, + string? Error); + +public sealed record TenantProvisioningStatusDto( + string TenantId, + string Status, + string CorrelationId, + string? CurrentStep, + string? Error, + DateTime CreatedUtc, + DateTime? StartedUtc, + DateTime? CompletedUtc, + IReadOnlyCollection Steps); diff --git a/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Dtos/TenantStatusDto.cs b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Dtos/TenantStatusDto.cs new file mode 100644 index 0000000000..8b6b020d30 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Dtos/TenantStatusDto.cs @@ -0,0 +1,13 @@ +namespace FSH.Modules.Multitenancy.Contracts.Dtos; + +public sealed class TenantStatusDto +{ + public string Id { get; init; } = default!; + public string Name { get; init; } = default!; + public bool IsActive { get; init; } + public DateTime ValidUpto { get; init; } + public bool HasConnectionString { get; init; } + public string AdminEmail { get; init; } = default!; + public string? Issuer { get; init; } +} + diff --git a/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Dtos/TenantThemeDto.cs b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Dtos/TenantThemeDto.cs new file mode 100644 index 0000000000..a4c716bcc4 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Dtos/TenantThemeDto.cs @@ -0,0 +1,93 @@ +using FSH.Framework.Storage.DTOs; + +namespace FSH.Modules.Multitenancy.Contracts.Dtos; + +public sealed record TenantThemeDto +{ + public PaletteDto LightPalette { get; init; } = new(); + public PaletteDto DarkPalette { get; init; } = new(); + public BrandAssetsDto BrandAssets { get; init; } = new(); + public TypographyDto Typography { get; init; } = new(); + public LayoutDto Layout { get; init; } = new(); + public bool IsDefault { get; init; } + + public static TenantThemeDto Default => new(); +} + +public sealed record PaletteDto +{ + public string Primary { get; init; } = "#2563EB"; + public string Secondary { get; init; } = "#0F172A"; + public string Tertiary { get; init; } = "#6366F1"; + public string Background { get; init; } = "#F8FAFC"; + public string Surface { get; init; } = "#FFFFFF"; + public string Error { get; init; } = "#DC2626"; + public string Warning { get; init; } = "#F59E0B"; + public string Success { get; init; } = "#16A34A"; + public string Info { get; init; } = "#0284C7"; + + public static PaletteDto DefaultLight => new(); + + public static PaletteDto DefaultDark => new() + { + Primary = "#38BDF8", + Secondary = "#94A3B8", + Tertiary = "#818CF8", + Background = "#0B1220", + Surface = "#111827", + Error = "#F87171", + Warning = "#FBBF24", + Success = "#22C55E", + Info = "#38BDF8" + }; +} + +public sealed record BrandAssetsDto +{ + // Current URLs (returned from API) + public string? LogoUrl { get; init; } + public string? LogoDarkUrl { get; init; } + public string? FaviconUrl { get; init; } + + // File uploads (same pattern as profile picture) + public FileUploadRequest? Logo { get; init; } + public FileUploadRequest? LogoDark { get; init; } + public FileUploadRequest? Favicon { get; init; } + + // Flags to delete current assets + public bool DeleteLogo { get; init; } + public bool DeleteLogoDark { get; init; } + public bool DeleteFavicon { get; init; } +} + +public sealed record TypographyDto +{ + public string FontFamily { get; init; } = "Inter, sans-serif"; + public string HeadingFontFamily { get; init; } = "Inter, sans-serif"; + public double FontSizeBase { get; init; } = 14; + public double LineHeightBase { get; init; } = 1.5; + + public static IReadOnlyList WebSafeFonts => new[] + { + "Inter, sans-serif", + "Arial, sans-serif", + "Helvetica, sans-serif", + "Georgia, serif", + "Times New Roman, serif", + "Verdana, sans-serif", + "Tahoma, sans-serif", + "Trebuchet MS, sans-serif", + "Courier New, monospace", + "Lucida Console, monospace", + "Segoe UI, sans-serif", + "Roboto, sans-serif", + "Open Sans, sans-serif", + "system-ui, sans-serif" + }; +} + +public sealed record LayoutDto +{ + public string BorderRadius { get; init; } = "4px"; + public int DefaultElevation { get; init; } = 1; +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/ITenantService.cs b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/ITenantService.cs new file mode 100644 index 0000000000..5181b16863 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/ITenantService.cs @@ -0,0 +1,29 @@ +using FSH.Framework.Shared.Persistence; +using FSH.Modules.Multitenancy.Contracts.Dtos; +using FSH.Modules.Multitenancy.Contracts.v1.GetTenants; +using FSH.Framework.Shared.Multitenancy; + +namespace FSH.Modules.Multitenancy.Contracts; + +public interface ITenantService +{ + Task> GetAllAsync(GetTenantsQuery query, CancellationToken cancellationToken); + + Task ExistsWithIdAsync(string id); + + Task ExistsWithNameAsync(string name); + + Task GetStatusAsync(string id); + + Task CreateAsync(string id, string name, string? connectionString, string adminEmail, string? issuer, CancellationToken cancellationToken); + + Task ActivateAsync(string id, CancellationToken cancellationToken); + + Task DeactivateAsync(string id); + + Task UpgradeSubscription(string id, DateTime extendedExpiryDate); + + Task MigrateTenantAsync(AppTenantInfo tenant, CancellationToken cancellationToken); + + Task SeedTenantAsync(AppTenantInfo tenant, CancellationToken cancellationToken); +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/ITenantThemeService.cs b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/ITenantThemeService.cs new file mode 100644 index 0000000000..ce95f4be99 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/ITenantThemeService.cs @@ -0,0 +1,41 @@ +using FSH.Modules.Multitenancy.Contracts.Dtos; + +namespace FSH.Modules.Multitenancy.Contracts; + +public interface ITenantThemeService +{ + /// + /// Gets the theme for the specified tenant. Falls back to default theme if none exists. + /// + Task GetThemeAsync(string tenantId, CancellationToken ct = default); + + /// + /// Gets the theme for the current tenant context. Falls back to default theme if none exists. + /// + Task GetCurrentTenantThemeAsync(CancellationToken ct = default); + + /// + /// Gets the default theme (set by root tenant) for new tenants. + /// + Task GetDefaultThemeAsync(CancellationToken ct = default); + + /// + /// Updates the theme for the specified tenant. + /// + Task UpdateThemeAsync(string tenantId, TenantThemeDto theme, CancellationToken ct = default); + + /// + /// Resets the theme for the specified tenant to defaults. + /// + Task ResetThemeAsync(string tenantId, CancellationToken ct = default); + + /// + /// Sets the specified tenant's theme as the default for new tenants (root tenant only). + /// + Task SetAsDefaultThemeAsync(string tenantId, CancellationToken ct = default); + + /// + /// Invalidates the cached theme for the specified tenant. + /// + Task InvalidateCacheAsync(string tenantId, CancellationToken ct = default); +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Modules.Multitenancy.Contracts.csproj b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Modules.Multitenancy.Contracts.csproj new file mode 100644 index 0000000000..9ffa33b276 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Modules.Multitenancy.Contracts.csproj @@ -0,0 +1,16 @@ + + + FSH.Modules.Multitenancy.Contracts + FSH.Modules.Multitenancy.Contracts + FullStackHero.Modules.Multitenancy.Contracts + $(NoWarn);CA1056;S2094 + + + + + + + + + + diff --git a/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/MultitenancyContractsMarker.cs b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/MultitenancyContractsMarker.cs new file mode 100644 index 0000000000..13ea7482ac --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/MultitenancyContractsMarker.cs @@ -0,0 +1,6 @@ +namespace FSH.Modules.Multitenancy.Contracts; + +// Marker type for contract assembly scanning (Mediator, etc.) +public sealed class MultitenancyContractsMarker +{ +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/ChangeTenantActivation/ChangeTenantActivationCommand.cs b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/ChangeTenantActivation/ChangeTenantActivationCommand.cs new file mode 100644 index 0000000000..cd799edf43 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/ChangeTenantActivation/ChangeTenantActivationCommand.cs @@ -0,0 +1,7 @@ +using FSH.Modules.Multitenancy.Contracts.Dtos; +using Mediator; + +namespace FSH.Modules.Multitenancy.Contracts.v1.ChangeTenantActivation; + +public sealed record ChangeTenantActivationCommand(string TenantId, bool IsActive) + : ICommand; \ No newline at end of file diff --git a/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/CreateTenant/CreateTenantCommand.cs b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/CreateTenant/CreateTenantCommand.cs new file mode 100644 index 0000000000..3b9f2ff491 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/CreateTenant/CreateTenantCommand.cs @@ -0,0 +1,10 @@ +using Mediator; + +namespace FSH.Modules.Multitenancy.Contracts.v1.CreateTenant; + +public sealed record CreateTenantCommand( + string Id, + string Name, + string? ConnectionString, + string AdminEmail, + string? Issuer) : ICommand; \ No newline at end of file diff --git a/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/CreateTenant/CreateTenantCommandResponse.cs b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/CreateTenant/CreateTenantCommandResponse.cs new file mode 100644 index 0000000000..b95fe38e79 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/CreateTenant/CreateTenantCommandResponse.cs @@ -0,0 +1,6 @@ +namespace FSH.Modules.Multitenancy.Contracts.v1.CreateTenant; + +public sealed record CreateTenantCommandResponse( + string Id, + string ProvisioningCorrelationId, + string Status); diff --git a/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/GetTenantMigrations/GetTenantMigrationsQuery.cs b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/GetTenantMigrations/GetTenantMigrationsQuery.cs new file mode 100644 index 0000000000..494530e366 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/GetTenantMigrations/GetTenantMigrationsQuery.cs @@ -0,0 +1,7 @@ +using FSH.Modules.Multitenancy.Contracts.Dtos; +using Mediator; + +namespace FSH.Modules.Multitenancy.Contracts.v1.GetTenantMigrations; + +public sealed record GetTenantMigrationsQuery : IQuery>; + diff --git a/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/GetTenantStatus/GetTenantStatusQuery.cs b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/GetTenantStatus/GetTenantStatusQuery.cs new file mode 100644 index 0000000000..b896aea544 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/GetTenantStatus/GetTenantStatusQuery.cs @@ -0,0 +1,7 @@ +using FSH.Modules.Multitenancy.Contracts.Dtos; +using Mediator; + +namespace FSH.Modules.Multitenancy.Contracts.v1.GetTenantStatus; + +public sealed record GetTenantStatusQuery(string TenantId) : IQuery; + diff --git a/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/GetTenantTheme/GetTenantThemeQuery.cs b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/GetTenantTheme/GetTenantThemeQuery.cs new file mode 100644 index 0000000000..27fa401f1a --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/GetTenantTheme/GetTenantThemeQuery.cs @@ -0,0 +1,6 @@ +using FSH.Modules.Multitenancy.Contracts.Dtos; +using Mediator; + +namespace FSH.Modules.Multitenancy.Contracts.v1.GetTenantTheme; + +public sealed record GetTenantThemeQuery : IQuery; diff --git a/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/GetTenants/GetTenantsQuery.cs b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/GetTenants/GetTenantsQuery.cs new file mode 100644 index 0000000000..8b6421409c --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/GetTenants/GetTenantsQuery.cs @@ -0,0 +1,14 @@ +using FSH.Framework.Shared.Persistence; +using FSH.Modules.Multitenancy.Contracts.Dtos; +using Mediator; + +namespace FSH.Modules.Multitenancy.Contracts.v1.GetTenants; + +public sealed class GetTenantsQuery : IPagedQuery, IQuery> +{ + public int? PageNumber { get; set; } + + public int? PageSize { get; set; } + + public string? Sort { get; set; } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/ResetTenantTheme/ResetTenantThemeCommand.cs b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/ResetTenantTheme/ResetTenantThemeCommand.cs new file mode 100644 index 0000000000..daf532441b --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/ResetTenantTheme/ResetTenantThemeCommand.cs @@ -0,0 +1,5 @@ +using Mediator; + +namespace FSH.Modules.Multitenancy.Contracts.v1.ResetTenantTheme; + +public sealed record ResetTenantThemeCommand : ICommand; diff --git a/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/TenantProvisioning/GetTenantProvisioningStatusQuery.cs b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/TenantProvisioning/GetTenantProvisioningStatusQuery.cs new file mode 100644 index 0000000000..a9406c6932 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/TenantProvisioning/GetTenantProvisioningStatusQuery.cs @@ -0,0 +1,6 @@ +using FSH.Modules.Multitenancy.Contracts.Dtos; +using Mediator; + +namespace FSH.Modules.Multitenancy.Contracts.v1.TenantProvisioning; + +public sealed record GetTenantProvisioningStatusQuery(string TenantId) : IQuery; diff --git a/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/TenantProvisioning/RetryTenantProvisioningCommand.cs b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/TenantProvisioning/RetryTenantProvisioningCommand.cs new file mode 100644 index 0000000000..eae3963c9f --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/TenantProvisioning/RetryTenantProvisioningCommand.cs @@ -0,0 +1,6 @@ +using FSH.Modules.Multitenancy.Contracts.Dtos; +using Mediator; + +namespace FSH.Modules.Multitenancy.Contracts.v1.TenantProvisioning; + +public sealed record RetryTenantProvisioningCommand(string TenantId) : ICommand; diff --git a/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/UpdateTenantTheme/UpdateTenantThemeCommand.cs b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/UpdateTenantTheme/UpdateTenantThemeCommand.cs new file mode 100644 index 0000000000..a028921cbf --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/UpdateTenantTheme/UpdateTenantThemeCommand.cs @@ -0,0 +1,6 @@ +using FSH.Modules.Multitenancy.Contracts.Dtos; +using Mediator; + +namespace FSH.Modules.Multitenancy.Contracts.v1.UpdateTenantTheme; + +public sealed record UpdateTenantThemeCommand(TenantThemeDto Theme) : ICommand; diff --git a/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/UpgradeTenant/UpgradeTenantCommand.cs b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/UpgradeTenant/UpgradeTenantCommand.cs new file mode 100644 index 0000000000..6d3025d543 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/UpgradeTenant/UpgradeTenantCommand.cs @@ -0,0 +1,6 @@ +using Mediator; + +namespace FSH.Modules.Multitenancy.Contracts.v1.UpgradeTenant; + +public sealed record UpgradeTenantCommand(string Tenant, DateTime ExtendedExpiryDate) + : ICommand; \ No newline at end of file diff --git a/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/UpgradeTenant/UpgradeTenantCommandResponse.cs b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/UpgradeTenant/UpgradeTenantCommandResponse.cs new file mode 100644 index 0000000000..a4e7291808 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/v1/UpgradeTenant/UpgradeTenantCommandResponse.cs @@ -0,0 +1,3 @@ +namespace FSH.Modules.Multitenancy.Contracts.v1.UpgradeTenant; + +public sealed record UpgradeTenantCommandResponse(DateTime NewValidity, string Tenant); \ No newline at end of file diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/AssemblyInfo.cs b/src/Modules/Multitenancy/Modules.Multitenancy/AssemblyInfo.cs new file mode 100644 index 0000000000..1f8dddebd0 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using FSH.Framework.Web.Modules; + +[assembly: FshModule(typeof(FSH.Modules.Multitenancy.MultitenancyModule), 200)] diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Data/Configurations/AppTenantInfoConfiguration.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Data/Configurations/AppTenantInfoConfiguration.cs new file mode 100644 index 0000000000..f0ecbf633b --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Data/Configurations/AppTenantInfoConfiguration.cs @@ -0,0 +1,13 @@ +using FSH.Framework.Shared.Multitenancy; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FSH.Modules.Multitenancy.Data.Configurations; + +public class AppTenantInfoConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Tenants", MultitenancyConstants.Schema); + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Data/Configurations/TenantProvisioningConfiguration.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Data/Configurations/TenantProvisioningConfiguration.cs new file mode 100644 index 0000000000..0a3013ce6f --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Data/Configurations/TenantProvisioningConfiguration.cs @@ -0,0 +1,20 @@ +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Multitenancy.Provisioning; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FSH.Modules.Multitenancy.Data.Configurations; + +public class TenantProvisioningConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("TenantProvisionings", MultitenancyConstants.Schema); + + builder.HasMany(p => p.Steps) + .WithOne(s => s.Provisioning!) + .HasForeignKey(s => s.ProvisioningId); + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Data/Configurations/TenantProvisioningStepConfiguration.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Data/Configurations/TenantProvisioningStepConfiguration.cs new file mode 100644 index 0000000000..886cfc6cb3 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Data/Configurations/TenantProvisioningStepConfiguration.cs @@ -0,0 +1,14 @@ +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Multitenancy.Provisioning; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FSH.Modules.Multitenancy.Data.Configurations; + +public class TenantProvisioningStepConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("TenantProvisioningSteps", MultitenancyConstants.Schema); + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Data/Configurations/TenantThemeConfiguration.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Data/Configurations/TenantThemeConfiguration.cs new file mode 100644 index 0000000000..d3176d3f33 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Data/Configurations/TenantThemeConfiguration.cs @@ -0,0 +1,70 @@ +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Multitenancy.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FSH.Modules.Multitenancy.Data.Configurations; + +public class TenantThemeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("TenantThemes", MultitenancyConstants.Schema); + + builder.HasKey(t => t.Id); + + builder.HasIndex(t => t.TenantId) + .IsUnique(); + + builder.Property(t => t.TenantId) + .HasMaxLength(64) + .IsRequired(); + + // Light Palette + builder.Property(t => t.PrimaryColor).HasMaxLength(9).IsRequired(); + builder.Property(t => t.SecondaryColor).HasMaxLength(9).IsRequired(); + builder.Property(t => t.TertiaryColor).HasMaxLength(9).IsRequired(); + builder.Property(t => t.BackgroundColor).HasMaxLength(9).IsRequired(); + builder.Property(t => t.SurfaceColor).HasMaxLength(9).IsRequired(); + builder.Property(t => t.ErrorColor).HasMaxLength(9).IsRequired(); + builder.Property(t => t.WarningColor).HasMaxLength(9).IsRequired(); + builder.Property(t => t.SuccessColor).HasMaxLength(9).IsRequired(); + builder.Property(t => t.InfoColor).HasMaxLength(9).IsRequired(); + + // Dark Palette + builder.Property(t => t.DarkPrimaryColor).HasMaxLength(9).IsRequired(); + builder.Property(t => t.DarkSecondaryColor).HasMaxLength(9).IsRequired(); + builder.Property(t => t.DarkTertiaryColor).HasMaxLength(9).IsRequired(); + builder.Property(t => t.DarkBackgroundColor).HasMaxLength(9).IsRequired(); + builder.Property(t => t.DarkSurfaceColor).HasMaxLength(9).IsRequired(); + builder.Property(t => t.DarkErrorColor).HasMaxLength(9).IsRequired(); + builder.Property(t => t.DarkWarningColor).HasMaxLength(9).IsRequired(); + builder.Property(t => t.DarkSuccessColor).HasMaxLength(9).IsRequired(); + builder.Property(t => t.DarkInfoColor).HasMaxLength(9).IsRequired(); + + // Brand Assets (URLs can be long with S3/CDN paths) + builder.Property(t => t.LogoUrl).HasMaxLength(2048); + builder.Property(t => t.LogoDarkUrl).HasMaxLength(2048); + builder.Property(t => t.FaviconUrl).HasMaxLength(2048); + + // Typography + builder.Property(t => t.FontFamily).HasMaxLength(200).IsRequired(); + builder.Property(t => t.HeadingFontFamily).HasMaxLength(200).IsRequired(); + builder.Property(t => t.FontSizeBase).IsRequired(); + builder.Property(t => t.LineHeightBase).IsRequired(); + + // Layout + builder.Property(t => t.BorderRadius).HasMaxLength(20).IsRequired(); + builder.Property(t => t.DefaultElevation).IsRequired(); + + // Is Default + builder.Property(t => t.IsDefault).IsRequired(); + + // Audit + builder.Property(t => t.CreatedOnUtc).IsRequired(); + builder.Property(t => t.CreatedBy).HasMaxLength(256); + builder.Property(t => t.LastModifiedBy).HasMaxLength(256); + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Data/TenantDbContext.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Data/TenantDbContext.cs new file mode 100644 index 0000000000..1b27e6540d --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Data/TenantDbContext.cs @@ -0,0 +1,32 @@ +using Finbuckle.MultiTenant.EntityFrameworkCore.Stores; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Multitenancy.Domain; +using FSH.Modules.Multitenancy.Provisioning; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Multitenancy.Data; + +public class TenantDbContext : EFCoreStoreDbContext +{ + public const string Schema = "tenant"; + + public TenantDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet TenantProvisionings => Set(); + + public DbSet TenantProvisioningSteps => Set(); + + public DbSet TenantThemes => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ArgumentNullException.ThrowIfNull(modelBuilder); + + base.OnModelCreating(modelBuilder); + + modelBuilder.ApplyConfigurationsFromAssembly(typeof(TenantDbContext).Assembly); + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Data/TenantDbContextFactory.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Data/TenantDbContextFactory.cs new file mode 100644 index 0000000000..51e1d6e04f --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Data/TenantDbContextFactory.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; + +namespace FSH.Modules.Multitenancy.Data; + +public sealed class TenantDbContextFactory : IDesignTimeDbContextFactory +{ + public TenantDbContext CreateDbContext(string[] args) + { + // Design-time factory: read configuration (appsettings + env vars) to decide provider and connection. + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: true) + .AddJsonFile("appsettings.Development.json", optional: true) + .AddEnvironmentVariables() + .Build(); + + var provider = configuration["DatabaseOptions:Provider"] ?? "POSTGRESQL"; + var connectionString = configuration["DatabaseOptions:ConnectionString"] + ?? "Host=localhost;Database=fsh-tenant;Username=postgres;Password=postgres"; + + var optionsBuilder = new DbContextOptionsBuilder(); + + switch (provider.ToUpperInvariant()) + { + case "POSTGRESQL": + optionsBuilder.UseNpgsql( + connectionString, + b => b.MigrationsAssembly("FSH.Playground.Migrations.PostgreSQL")); + break; + default: + throw new NotSupportedException($"Database provider '{provider}' is not supported for TenantDbContext migrations."); + } + + return new TenantDbContext(optionsBuilder.Options); + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Domain/TenantTheme.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Domain/TenantTheme.cs new file mode 100644 index 0000000000..236118d939 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Domain/TenantTheme.cs @@ -0,0 +1,113 @@ +using FSH.Framework.Core.Domain; + +namespace FSH.Modules.Multitenancy.Domain; + +public class TenantTheme : BaseEntity, IHasTenant, IAuditableEntity +{ + public string TenantId { get; private set; } = default!; + + // Light Palette + public string PrimaryColor { get; set; } = "#2563EB"; + public string SecondaryColor { get; set; } = "#0F172A"; + public string TertiaryColor { get; set; } = "#6366F1"; + public string BackgroundColor { get; set; } = "#F8FAFC"; + public string SurfaceColor { get; set; } = "#FFFFFF"; + public string ErrorColor { get; set; } = "#DC2626"; + public string WarningColor { get; set; } = "#F59E0B"; + public string SuccessColor { get; set; } = "#16A34A"; + public string InfoColor { get; set; } = "#0284C7"; + + // Dark Palette + public string DarkPrimaryColor { get; set; } = "#38BDF8"; + public string DarkSecondaryColor { get; set; } = "#94A3B8"; + public string DarkTertiaryColor { get; set; } = "#818CF8"; + public string DarkBackgroundColor { get; set; } = "#0B1220"; + public string DarkSurfaceColor { get; set; } = "#111827"; + public string DarkErrorColor { get; set; } = "#F87171"; + public string DarkWarningColor { get; set; } = "#FBBF24"; + public string DarkSuccessColor { get; set; } = "#22C55E"; + public string DarkInfoColor { get; set; } = "#38BDF8"; + + // Brand Assets + public string? LogoUrl { get; set; } + public string? LogoDarkUrl { get; set; } + public string? FaviconUrl { get; set; } + + // Typography + public string FontFamily { get; set; } = "Inter, sans-serif"; + public string HeadingFontFamily { get; set; } = "Inter, sans-serif"; + public double FontSizeBase { get; set; } = 14; + public double LineHeightBase { get; set; } = 1.5; + + // Layout + public string BorderRadius { get; set; } = "4px"; + public int DefaultElevation { get; set; } = 1; + + // Is Default Theme (for root tenant to set default for new tenants) + public bool IsDefault { get; set; } + + // IAuditableEntity + public DateTimeOffset CreatedOnUtc { get; private set; } = DateTimeOffset.UtcNow; + public string? CreatedBy { get; private set; } + public DateTimeOffset? LastModifiedOnUtc { get; private set; } + public string? LastModifiedBy { get; private set; } + + private TenantTheme() { } // EF Core + + public static TenantTheme Create(string tenantId, string? createdBy = null) + { + return new TenantTheme + { + Id = Guid.NewGuid(), + TenantId = tenantId, + CreatedBy = createdBy, + CreatedOnUtc = DateTimeOffset.UtcNow + }; + } + + public void Update(string? modifiedBy) + { + LastModifiedOnUtc = DateTimeOffset.UtcNow; + LastModifiedBy = modifiedBy; + } + + public void ResetToDefaults() + { + // Light Palette + PrimaryColor = "#2563EB"; + SecondaryColor = "#0F172A"; + TertiaryColor = "#6366F1"; + BackgroundColor = "#F8FAFC"; + SurfaceColor = "#FFFFFF"; + ErrorColor = "#DC2626"; + WarningColor = "#F59E0B"; + SuccessColor = "#16A34A"; + InfoColor = "#0284C7"; + + // Dark Palette + DarkPrimaryColor = "#38BDF8"; + DarkSecondaryColor = "#94A3B8"; + DarkTertiaryColor = "#818CF8"; + DarkBackgroundColor = "#0B1220"; + DarkSurfaceColor = "#111827"; + DarkErrorColor = "#F87171"; + DarkWarningColor = "#FBBF24"; + DarkSuccessColor = "#22C55E"; + DarkInfoColor = "#38BDF8"; + + // Brand Assets + LogoUrl = null; + LogoDarkUrl = null; + FaviconUrl = null; + + // Typography + FontFamily = "Inter, sans-serif"; + HeadingFontFamily = "Inter, sans-serif"; + FontSizeBase = 14; + LineHeightBase = 1.5; + + // Layout + BorderRadius = "4px"; + DefaultElevation = 1; + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Extensions.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Extensions.cs new file mode 100644 index 0000000000..c237200637 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Extensions.cs @@ -0,0 +1,19 @@ +using Finbuckle.MultiTenant; +using Finbuckle.MultiTenant.Abstractions; +using Finbuckle.MultiTenant.AspNetCore.Extensions; +using FSH.Modules.Multitenancy.Data; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace FSH.Modules.Multitenancy; + +public static class Extensions +{ + public static WebApplication UseHeroMultiTenantDatabases(this WebApplication app) + { + ArgumentNullException.ThrowIfNull(app); + app.UseMultiTenant(); + + return app; + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/ChangeTenantActivation/ChangeTenantActivationCommandHandler.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/ChangeTenantActivation/ChangeTenantActivationCommandHandler.cs new file mode 100644 index 0000000000..0108b98ce9 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/ChangeTenantActivation/ChangeTenantActivationCommandHandler.cs @@ -0,0 +1,42 @@ +using FSH.Modules.Multitenancy.Contracts; +using FSH.Modules.Multitenancy.Contracts.Dtos; +using FSH.Modules.Multitenancy.Contracts.v1.ChangeTenantActivation; +using Mediator; + +namespace FSH.Modules.Multitenancy.Features.v1.ChangeTenantActivation; + +public sealed class ChangeTenantActivationCommandHandler : ICommandHandler +{ + private readonly ITenantService _tenantService; + + public ChangeTenantActivationCommandHandler(ITenantService tenantService) + { + _tenantService = tenantService; + } + + public async ValueTask Handle(ChangeTenantActivationCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + string message; + + if (command.IsActive) + { + message = await _tenantService.ActivateAsync(command.TenantId, cancellationToken).ConfigureAwait(false); + } + else + { + message = await _tenantService.DeactivateAsync(command.TenantId).ConfigureAwait(false); + } + + var status = await _tenantService.GetStatusAsync(command.TenantId).ConfigureAwait(false); + + return new TenantLifecycleResultDto + { + TenantId = status.Id, + IsActive = status.IsActive, + ValidUpto = status.ValidUpto, + Message = message + }; + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/ChangeTenantActivation/ChangeTenantActivationCommandValidator.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/ChangeTenantActivation/ChangeTenantActivationCommandValidator.cs new file mode 100644 index 0000000000..c969de26f3 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/ChangeTenantActivation/ChangeTenantActivationCommandValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; +using FSH.Modules.Multitenancy.Contracts.v1.ChangeTenantActivation; + +namespace FSH.Modules.Multitenancy.Features.v1.ChangeTenantActivation; + +internal sealed class ChangeTenantActivationCommandValidator : AbstractValidator +{ + public ChangeTenantActivationCommandValidator() => + RuleFor(t => t.TenantId) + .NotEmpty(); +} \ No newline at end of file diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/ChangeTenantActivation/ChangeTenantActivationEndpoint.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/ChangeTenantActivation/ChangeTenantActivationEndpoint.cs new file mode 100644 index 0000000000..f14c600231 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/ChangeTenantActivation/ChangeTenantActivationEndpoint.cs @@ -0,0 +1,39 @@ +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Multitenancy.Contracts.Dtos; +using FSH.Modules.Multitenancy.Contracts.v1.ChangeTenantActivation; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Multitenancy.Features.v1.ChangeTenantActivation; + +public static class ChangeTenantActivationEndpoint +{ + public static RouteHandlerBuilder Map(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost( + "/{id}/activation", + async ([FromRoute] string id, [FromBody] ChangeTenantActivationCommand command, IMediator mediator) => + { + if (!string.Equals(id, command.TenantId, StringComparison.Ordinal)) + { + return Results.BadRequest(); + } + + TenantLifecycleResultDto result = await mediator.Send(command); + return Results.Ok(result); + }) + .WithName("ChangeTenantActivation") + .WithSummary("Change tenant activation state") + .WithDescription("Activate or deactivate a tenant in a single endpoint.") + .RequirePermission(MultitenancyConstants.Permissions.Update) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); + } +} \ No newline at end of file diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/CreateTenant/CreateTenantCommandHandler.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/CreateTenant/CreateTenantCommandHandler.cs new file mode 100644 index 0000000000..abc190e2ce --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/CreateTenant/CreateTenantCommandHandler.cs @@ -0,0 +1,30 @@ +using FSH.Modules.Multitenancy.Contracts; +using FSH.Modules.Multitenancy.Contracts.v1.CreateTenant; +using FSH.Modules.Multitenancy.Provisioning; +using Mediator; + +namespace FSH.Modules.Multitenancy.Features.v1.CreateTenant; + +public sealed class CreateTenantCommandHandler(ITenantService tenantService, ITenantProvisioningService provisioningService) + : ICommandHandler +{ + public async ValueTask Handle(CreateTenantCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + var tenantId = await tenantService.CreateAsync( + command.Id, + command.Name, + command.ConnectionString, + command.AdminEmail, + command.Issuer, + cancellationToken); + + var provisioning = await provisioningService.StartAsync(tenantId, cancellationToken); + + return new CreateTenantCommandResponse( + tenantId, + provisioning.CorrelationId, + provisioning.Status.ToString()); + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/CreateTenant/CreateTenantCommandValidator.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/CreateTenant/CreateTenantCommandValidator.cs new file mode 100644 index 0000000000..4be00b3867 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/CreateTenant/CreateTenantCommandValidator.cs @@ -0,0 +1,30 @@ +using FluentValidation; +using FSH.Framework.Persistence; +using FSH.Modules.Multitenancy.Contracts; +using FSH.Modules.Multitenancy.Contracts.v1.CreateTenant; + +namespace FSH.Modules.Multitenancy.Features.v1.CreateTenant; + +public sealed class CreateTenantCommandValidator : AbstractValidator +{ + public CreateTenantCommandValidator(ITenantService tenantService, IConnectionStringValidator connectionStringValidator) + { + RuleFor(t => t.Id).Cascade(CascadeMode.Stop) + .NotEmpty() + .MustAsync(async (id, _) => !await tenantService.ExistsWithIdAsync(id).ConfigureAwait(false)) + .WithMessage((_, id) => $"Tenant {id} already exists."); + + RuleFor(t => t.Name).Cascade(CascadeMode.Stop) + .NotEmpty() + .MustAsync(async (name, _) => !await tenantService.ExistsWithNameAsync(name!).ConfigureAwait(false)) + .WithMessage((_, name) => $"Tenant {name} already exists."); + + RuleFor(t => t.ConnectionString).Cascade(CascadeMode.Stop) + .Must((_, cs) => string.IsNullOrWhiteSpace(cs) || connectionStringValidator.TryValidate(cs)) + .WithMessage("Connection string invalid."); + + RuleFor(t => t.AdminEmail).Cascade(CascadeMode.Stop) + .NotEmpty() + .EmailAddress(); + } +} \ No newline at end of file diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/CreateTenant/CreateTenantEndpoint.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/CreateTenant/CreateTenantEndpoint.cs new file mode 100644 index 0000000000..1b3dddf0cb --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/CreateTenant/CreateTenantEndpoint.cs @@ -0,0 +1,26 @@ +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Multitenancy.Contracts.v1.CreateTenant; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Multitenancy.Features.v1.CreateTenant; + +public static class CreateTenantEndpoint +{ + public static RouteHandlerBuilder Map(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/", async ( + [FromBody] CreateTenantCommand command, + [FromServices] IMediator mediator) + => TypedResults.Ok(await mediator.Send(command))) + .WithName("CreateTenant") + .WithSummary("Create tenant") + .RequirePermission(MultitenancyConstants.Permissions.Create) + .WithDescription("Create a new tenant.") + .Produces(StatusCodes.Status200OK); + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenantMigrations/GetTenantMigrationsQueryHandler.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenantMigrations/GetTenantMigrationsQueryHandler.cs new file mode 100644 index 0000000000..c8a35eba9f --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenantMigrations/GetTenantMigrationsQueryHandler.cs @@ -0,0 +1,76 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Multitenancy.Contracts.Dtos; +using FSH.Modules.Multitenancy.Contracts.v1.GetTenantMigrations; +using FSH.Modules.Multitenancy.Data; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FSH.Modules.Multitenancy.Features.v1.GetTenantMigrations; + +public sealed class GetTenantMigrationsQueryHandler + : IQueryHandler> +{ + private readonly IMultiTenantStore _tenantStore; + private readonly IServiceScopeFactory _scopeFactory; + + public GetTenantMigrationsQueryHandler( + IMultiTenantStore tenantStore, + IServiceScopeFactory scopeFactory) + { + _tenantStore = tenantStore; + _scopeFactory = scopeFactory; + } + + public async ValueTask> Handle( + GetTenantMigrationsQuery query, + CancellationToken cancellationToken) + { + var tenants = await _tenantStore.GetAllAsync().ConfigureAwait(false); + + var tenantMigrationStatuses = new List(); + + foreach (var tenant in tenants) + { + var tenantStatus = new TenantMigrationStatusDto + { + TenantId = tenant.Id, + Name = tenant.Name!, + IsActive = tenant.IsActive, + ValidUpto = tenant.ValidUpto + }; + + try + { + using IServiceScope tenantScope = _scopeFactory.CreateScope(); + + tenantScope.ServiceProvider.GetRequiredService() + .MultiTenantContext = new MultiTenantContext(tenant); + + var dbContext = tenantScope.ServiceProvider.GetRequiredService(); + + var appliedMigrations = await dbContext.Database + .GetAppliedMigrationsAsync(cancellationToken) + .ConfigureAwait(false); + + var pendingMigrations = await dbContext.Database + .GetPendingMigrationsAsync(cancellationToken) + .ConfigureAwait(false); + + tenantStatus.Provider = dbContext.Database.ProviderName; + tenantStatus.LastAppliedMigration = appliedMigrations.LastOrDefault(); + tenantStatus.PendingMigrations = pendingMigrations.ToArray(); + tenantStatus.HasPendingMigrations = tenantStatus.PendingMigrations.Count > 0; + } + catch (Exception ex) + { + tenantStatus.Error = ex.Message; + } + + tenantMigrationStatuses.Add(tenantStatus); + } + + return tenantMigrationStatuses; + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenantMigrations/TenantMigrationsEndpoint.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenantMigrations/TenantMigrationsEndpoint.cs new file mode 100644 index 0000000000..4754b50519 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenantMigrations/TenantMigrationsEndpoint.cs @@ -0,0 +1,34 @@ +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Multitenancy.Contracts.Dtos; +using FSH.Modules.Multitenancy.Contracts.v1.GetTenantMigrations; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Multitenancy.Features.v1.GetTenantMigrations; + +public static class TenantMigrationsEndpoint +{ + public static RouteHandlerBuilder Map(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet( + "/migrations", + async (IMediator mediator, CancellationToken cancellationToken) => + { + IReadOnlyCollection result = + await mediator.Send(new GetTenantMigrationsQuery(), cancellationToken); + + return Results.Ok(result); + }) + .WithName("GetTenantMigrations") + .RequirePermission(MultitenancyConstants.Permissions.View) + .WithSummary("Get per-tenant migration status") + .WithDescription("Retrieve migration status for each tenant, including pending migrations and provider information.") + .Produces>(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); + } +} + diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenantStatus/GetTenantStatusEndpoint.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenantStatus/GetTenantStatusEndpoint.cs new file mode 100644 index 0000000000..46c7b12df0 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenantStatus/GetTenantStatusEndpoint.cs @@ -0,0 +1,27 @@ +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Multitenancy.Contracts.Dtos; +using FSH.Modules.Multitenancy.Contracts.v1.GetTenantStatus; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Multitenancy.Features.v1.GetTenantStatus; + +public static class GetTenantStatusEndpoint +{ + public static RouteHandlerBuilder Map(IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/{id}/status", async (string id, IMediator mediator, CancellationToken cancellationToken) => + await mediator.Send(new GetTenantStatusQuery(id), cancellationToken)) + .WithName("GetTenantStatus") + .WithSummary("Get tenant status") + .WithDescription("Retrieve status information for a tenant, including activation, validity, and basic metadata.") + .RequirePermission(MultitenancyConstants.Permissions.View) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenantStatus/GetTenantStatusQueryHandler.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenantStatus/GetTenantStatusQueryHandler.cs new file mode 100644 index 0000000000..570891515f --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenantStatus/GetTenantStatusQueryHandler.cs @@ -0,0 +1,17 @@ +using FSH.Modules.Multitenancy.Contracts; +using FSH.Modules.Multitenancy.Contracts.Dtos; +using FSH.Modules.Multitenancy.Contracts.v1.GetTenantStatus; +using Mediator; + +namespace FSH.Modules.Multitenancy.Features.v1.GetTenantStatus; + +public sealed class GetTenantStatusQueryHandler(ITenantService tenantService) + : IQueryHandler +{ + public async ValueTask Handle(GetTenantStatusQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + return await tenantService.GetStatusAsync(query.TenantId); + } +} + diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenantTheme/GetTenantThemeEndpoint.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenantTheme/GetTenantThemeEndpoint.cs new file mode 100644 index 0000000000..b541c13169 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenantTheme/GetTenantThemeEndpoint.cs @@ -0,0 +1,26 @@ +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Multitenancy.Contracts.Dtos; +using FSH.Modules.Multitenancy.Contracts.v1.GetTenantTheme; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Multitenancy.Features.v1.GetTenantTheme; + +public static class GetTenantThemeEndpoint +{ + public static RouteHandlerBuilder Map(IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/theme", async (IMediator mediator, CancellationToken cancellationToken) => + await mediator.Send(new GetTenantThemeQuery(), cancellationToken)) + .WithName("GetTenantTheme") + .WithSummary("Get current tenant theme") + .WithDescription("Retrieve the theme settings for the current tenant, including colors, typography, and brand assets.") + .RequirePermission(MultitenancyConstants.Permissions.ViewTheme) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenantTheme/GetTenantThemeQueryHandler.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenantTheme/GetTenantThemeQueryHandler.cs new file mode 100644 index 0000000000..987ff812a9 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenantTheme/GetTenantThemeQueryHandler.cs @@ -0,0 +1,15 @@ +using FSH.Modules.Multitenancy.Contracts; +using FSH.Modules.Multitenancy.Contracts.Dtos; +using FSH.Modules.Multitenancy.Contracts.v1.GetTenantTheme; +using Mediator; + +namespace FSH.Modules.Multitenancy.Features.v1.GetTenantTheme; + +public sealed class GetTenantThemeQueryHandler(ITenantThemeService themeService) + : IQueryHandler +{ + public async ValueTask Handle(GetTenantThemeQuery query, CancellationToken cancellationToken) + { + return await themeService.GetCurrentTenantThemeAsync(cancellationToken); + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenants/GetTenantsEndpoint.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenants/GetTenantsEndpoint.cs new file mode 100644 index 0000000000..3e46846772 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenants/GetTenantsEndpoint.cs @@ -0,0 +1,30 @@ +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Framework.Shared.Multitenancy; +using FSH.Framework.Shared.Persistence; +using FSH.Modules.Multitenancy.Contracts.Dtos; +using FSH.Modules.Multitenancy.Contracts.v1.GetTenants; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Multitenancy.Features.v1.GetTenants; + +public static class GetTenantsEndpoint +{ + public static RouteHandlerBuilder Map(IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet( + "/", + ([AsParameters] GetTenantsQuery query, IMediator mediator, CancellationToken cancellationToken) => + mediator.Send(query, cancellationToken)) + .WithName("ListTenants") + .WithSummary("List tenants") + .WithDescription("Retrieve tenants for the current environment with pagination and optional sorting.") + .RequirePermission(MultitenancyConstants.Permissions.View) + .Produces>(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenants/GetTenantsQueryHandler.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenants/GetTenantsQueryHandler.cs new file mode 100644 index 0000000000..7115128194 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenants/GetTenantsQueryHandler.cs @@ -0,0 +1,17 @@ +using FSH.Framework.Shared.Persistence; +using FSH.Modules.Multitenancy.Contracts.Dtos; +using FSH.Modules.Multitenancy.Contracts.v1.GetTenants; +using FSH.Modules.Multitenancy.Contracts; +using Mediator; + +namespace FSH.Modules.Multitenancy.Features.v1.GetTenants; + +public sealed class GetTenantsQueryHandler(ITenantService tenantService) + : IQueryHandler> +{ + public async ValueTask> Handle(GetTenantsQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + return await tenantService.GetAllAsync(query, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenants/GetTenantsSpecification.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenants/GetTenantsSpecification.cs new file mode 100644 index 0000000000..063c620cef --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenants/GetTenantsSpecification.cs @@ -0,0 +1,52 @@ +using FSH.Framework.Persistence; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Multitenancy.Contracts.Dtos; +using FSH.Modules.Multitenancy.Contracts.v1.GetTenants; +using System.Linq.Expressions; + +namespace FSH.Modules.Multitenancy.Features.v1.GetTenants; + +internal sealed class GetTenantsSpecification : Specification +{ + private static readonly IReadOnlyDictionary>> SortMappings = + new Dictionary>>( + StringComparer.OrdinalIgnoreCase) + { + ["id"] = t => t.Id!, + ["name"] = t => t.Name!, + ["connectionstring"] = t => t.ConnectionString!, + ["adminemail"] = t => t.AdminEmail!, + ["isactive"] = t => t.IsActive, + ["validupto"] = t => t.ValidUpto, + ["issuer"] = t => t.Issuer! + }; + + public GetTenantsSpecification(GetTenantsQuery query) + { + ArgumentNullException.ThrowIfNull(query); + + // Default projection to TenantDto. + Select(t => new TenantDto + { + Id = t.Id!, + Name = t.Name!, + ConnectionString = t.ConnectionString, + AdminEmail = t.AdminEmail!, + IsActive = t.IsActive, + ValidUpto = t.ValidUpto, + Issuer = t.Issuer + }); + + // Default behavior: no tracking. + AsNoTrackingQuery(); + + ApplySortingOverride( + query.Sort, + () => + { + OrderBy(t => t.Name!); + ThenBy(t => t.Id!); + }, + SortMappings); + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/ResetTenantTheme/ResetTenantThemeCommandHandler.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/ResetTenantTheme/ResetTenantThemeCommandHandler.cs new file mode 100644 index 0000000000..394fb1250a --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/ResetTenantTheme/ResetTenantThemeCommandHandler.cs @@ -0,0 +1,23 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Multitenancy.Contracts; +using FSH.Modules.Multitenancy.Contracts.v1.ResetTenantTheme; +using Mediator; + +namespace FSH.Modules.Multitenancy.Features.v1.ResetTenantTheme; + +public sealed class ResetTenantThemeCommandHandler( + ITenantThemeService themeService, + IMultiTenantContextAccessor tenantAccessor) + : ICommandHandler +{ + public async ValueTask Handle(ResetTenantThemeCommand command, CancellationToken cancellationToken) + { + var tenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id + ?? throw new InvalidOperationException("No tenant context available"); + + await themeService.ResetThemeAsync(tenantId, cancellationToken); + + return Unit.Value; + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/ResetTenantTheme/ResetTenantThemeEndpoint.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/ResetTenantTheme/ResetTenantThemeEndpoint.cs new file mode 100644 index 0000000000..1393d9f71a --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/ResetTenantTheme/ResetTenantThemeEndpoint.cs @@ -0,0 +1,28 @@ +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Multitenancy.Contracts.v1.ResetTenantTheme; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Multitenancy.Features.v1.ResetTenantTheme; + +public static class ResetTenantThemeEndpoint +{ + public static RouteHandlerBuilder Map(IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/theme/reset", async (IMediator mediator, CancellationToken cancellationToken) => + { + await mediator.Send(new ResetTenantThemeCommand(), cancellationToken); + return Results.NoContent(); + }) + .WithName("ResetTenantTheme") + .WithSummary("Reset tenant theme to defaults") + .WithDescription("Reset the theme settings for the current tenant to the default values.") + .RequirePermission(MultitenancyConstants.Permissions.UpdateTheme) + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/TenantProvisioning/GetTenantProvisioningStatus/GetTenantProvisioningStatusEndpoint.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/TenantProvisioning/GetTenantProvisioningStatus/GetTenantProvisioningStatusEndpoint.cs new file mode 100644 index 0000000000..c61c0b1633 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/TenantProvisioning/GetTenantProvisioningStatus/GetTenantProvisioningStatusEndpoint.cs @@ -0,0 +1,25 @@ +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Multitenancy.Contracts.v1.TenantProvisioning; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Multitenancy.Features.v1.TenantProvisioning.GetTenantProvisioningStatus; + +public static class GetTenantProvisioningStatusEndpoint +{ + public static RouteHandlerBuilder Map(IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/{tenantId}/provisioning", async ( + [FromRoute] string tenantId, + [FromServices] IMediator mediator) => + await mediator.Send(new GetTenantProvisioningStatusQuery(tenantId))) + .WithName("GetTenantProvisioningStatus") + .WithSummary("Get tenant provisioning status") + .RequirePermission(MultitenancyConstants.Permissions.View) + .WithDescription("Get latest provisioning status for a tenant."); + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/TenantProvisioning/GetTenantProvisioningStatus/GetTenantProvisioningStatusQueryHandler.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/TenantProvisioning/GetTenantProvisioningStatus/GetTenantProvisioningStatusQueryHandler.cs new file mode 100644 index 0000000000..38c8d46e9e --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/TenantProvisioning/GetTenantProvisioningStatus/GetTenantProvisioningStatusQueryHandler.cs @@ -0,0 +1,16 @@ +using FSH.Modules.Multitenancy.Contracts.Dtos; +using FSH.Modules.Multitenancy.Contracts.v1.TenantProvisioning; +using FSH.Modules.Multitenancy.Provisioning; +using Mediator; + +namespace FSH.Modules.Multitenancy.Features.v1.TenantProvisioning.GetTenantProvisioningStatus; + +public sealed class GetTenantProvisioningStatusQueryHandler(ITenantProvisioningService provisioningService) + : IQueryHandler +{ + public async ValueTask Handle(GetTenantProvisioningStatusQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + return await provisioningService.GetStatusAsync(query.TenantId, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/TenantProvisioning/RetryTenantProvisioning/RetryTenantProvisioningCommandHandler.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/TenantProvisioning/RetryTenantProvisioning/RetryTenantProvisioningCommandHandler.cs new file mode 100644 index 0000000000..a6142db2c6 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/TenantProvisioning/RetryTenantProvisioning/RetryTenantProvisioningCommandHandler.cs @@ -0,0 +1,18 @@ +using FSH.Modules.Multitenancy.Contracts.Dtos; +using FSH.Modules.Multitenancy.Contracts.v1.TenantProvisioning; +using FSH.Modules.Multitenancy.Provisioning; +using Mediator; + +namespace FSH.Modules.Multitenancy.Features.v1.TenantProvisioning.RetryTenantProvisioning; + +public sealed class RetryTenantProvisioningCommandHandler(ITenantProvisioningService provisioningService) + : ICommandHandler +{ + public async ValueTask Handle(RetryTenantProvisioningCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + var correlationId = await provisioningService.RetryAsync(command.TenantId, cancellationToken).ConfigureAwait(false); + var status = await provisioningService.GetStatusAsync(command.TenantId, cancellationToken).ConfigureAwait(false); + return status with { CorrelationId = correlationId }; + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/TenantProvisioning/RetryTenantProvisioning/RetryTenantProvisioningEndpoint.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/TenantProvisioning/RetryTenantProvisioning/RetryTenantProvisioningEndpoint.cs new file mode 100644 index 0000000000..65474415e9 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/TenantProvisioning/RetryTenantProvisioning/RetryTenantProvisioningEndpoint.cs @@ -0,0 +1,25 @@ +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Multitenancy.Contracts.v1.TenantProvisioning; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Multitenancy.Features.v1.TenantProvisioning.RetryTenantProvisioning; + +public static class RetryTenantProvisioningEndpoint +{ + public static RouteHandlerBuilder Map(IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/{tenantId}/provisioning/retry", async ( + [FromRoute] string tenantId, + [FromServices] IMediator mediator) => + await mediator.Send(new RetryTenantProvisioningCommand(tenantId))) + .WithName("RetryTenantProvisioning") + .WithSummary("Retry tenant provisioning") + .RequirePermission(MultitenancyConstants.Permissions.Update) + .WithDescription("Retry the provisioning workflow for a tenant."); + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpdateTenantTheme/UpdateTenantThemeCommandHandler.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpdateTenantTheme/UpdateTenantThemeCommandHandler.cs new file mode 100644 index 0000000000..b2a1afcec7 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpdateTenantTheme/UpdateTenantThemeCommandHandler.cs @@ -0,0 +1,25 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Multitenancy.Contracts; +using FSH.Modules.Multitenancy.Contracts.v1.UpdateTenantTheme; +using Mediator; + +namespace FSH.Modules.Multitenancy.Features.v1.UpdateTenantTheme; + +public sealed class UpdateTenantThemeCommandHandler( + ITenantThemeService themeService, + IMultiTenantContextAccessor tenantAccessor) + : ICommandHandler +{ + public async ValueTask Handle(UpdateTenantThemeCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + var tenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id + ?? throw new InvalidOperationException("No tenant context available"); + + await themeService.UpdateThemeAsync(tenantId, command.Theme, cancellationToken); + + return Unit.Value; + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpdateTenantTheme/UpdateTenantThemeCommandValidator.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpdateTenantTheme/UpdateTenantThemeCommandValidator.cs new file mode 100644 index 0000000000..783f74807d --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpdateTenantTheme/UpdateTenantThemeCommandValidator.cs @@ -0,0 +1,99 @@ +using System.Text.RegularExpressions; +using FluentValidation; +using FSH.Modules.Multitenancy.Contracts.Dtos; +using FSH.Modules.Multitenancy.Contracts.v1.UpdateTenantTheme; + +namespace FSH.Modules.Multitenancy.Features.v1.UpdateTenantTheme; + +public partial class UpdateTenantThemeCommandValidator : AbstractValidator +{ + public UpdateTenantThemeCommandValidator() + { + RuleFor(x => x.Theme) + .NotNull() + .WithMessage("Theme is required."); + + RuleFor(x => x.Theme.LightPalette) + .NotNull() + .SetValidator(new PaletteValidator()); + + RuleFor(x => x.Theme.DarkPalette) + .NotNull() + .SetValidator(new PaletteValidator()); + + RuleFor(x => x.Theme.Typography) + .NotNull() + .SetValidator(new TypographyValidator()); + + RuleFor(x => x.Theme.Layout) + .NotNull() + .SetValidator(new LayoutValidator()); + } + + [GeneratedRegex("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$")] + private static partial Regex HexColorRegex(); + + private sealed class PaletteValidator : AbstractValidator + { + public PaletteValidator() + { + 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."); + } + + private static bool BeValidHexColor(string color) => + !string.IsNullOrWhiteSpace(color) && HexColorRegex().IsMatch(color); + } + + private sealed class TypographyValidator : AbstractValidator + { + public TypographyValidator() + { + RuleFor(x => x.FontFamily) + .NotEmpty() + .MaximumLength(200) + .Must(BeValidFontFamily) + .WithMessage("FontFamily must be a valid web-safe font."); + + RuleFor(x => x.HeadingFontFamily) + .NotEmpty() + .MaximumLength(200) + .Must(BeValidFontFamily) + .WithMessage("HeadingFontFamily must be a valid web-safe font."); + + RuleFor(x => x.FontSizeBase) + .InclusiveBetween(10, 24) + .WithMessage("FontSizeBase must be between 10 and 24."); + + RuleFor(x => x.LineHeightBase) + .InclusiveBetween(1.0, 2.5) + .WithMessage("LineHeightBase must be between 1.0 and 2.5."); + } + + private static bool BeValidFontFamily(string fontFamily) => + TypographyDto.WebSafeFonts.Contains(fontFamily); + } + + private sealed class LayoutValidator : AbstractValidator + { + public LayoutValidator() + { + 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')."); + + RuleFor(x => x.DefaultElevation) + .InclusiveBetween(0, 24) + .WithMessage("DefaultElevation must be between 0 and 24."); + } + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpdateTenantTheme/UpdateTenantThemeEndpoint.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpdateTenantTheme/UpdateTenantThemeEndpoint.cs new file mode 100644 index 0000000000..271dbe57f0 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpdateTenantTheme/UpdateTenantThemeEndpoint.cs @@ -0,0 +1,30 @@ +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Multitenancy.Contracts.Dtos; +using FSH.Modules.Multitenancy.Contracts.v1.UpdateTenantTheme; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Multitenancy.Features.v1.UpdateTenantTheme; + +public static class UpdateTenantThemeEndpoint +{ + public static RouteHandlerBuilder Map(IEndpointRouteBuilder endpoints) + { + return endpoints.MapPut("/theme", async (TenantThemeDto theme, IMediator mediator, CancellationToken cancellationToken) => + { + await mediator.Send(new UpdateTenantThemeCommand(theme), cancellationToken); + return Results.NoContent(); + }) + .WithName("UpdateTenantTheme") + .WithSummary("Update current tenant theme") + .WithDescription("Update the theme settings for the current tenant, including colors, typography, and layout.") + .RequirePermission(MultitenancyConstants.Permissions.UpdateTheme) + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpgradeTenant/UpgradeTenantCommandHandler.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpgradeTenant/UpgradeTenantCommandHandler.cs new file mode 100644 index 0000000000..7b0bcbf2d2 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpgradeTenant/UpgradeTenantCommandHandler.cs @@ -0,0 +1,16 @@ +using FSH.Modules.Multitenancy.Contracts; +using FSH.Modules.Multitenancy.Contracts.v1.UpgradeTenant; +using Mediator; + +namespace FSH.Modules.Multitenancy.Features.v1.UpgradeTenant; + +public sealed class UpgradeTenantCommandHandler(ITenantService service) + : ICommandHandler +{ + public async ValueTask Handle(UpgradeTenantCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + var validUpto = await service.UpgradeSubscription(command.Tenant, command.ExtendedExpiryDate); + return new UpgradeTenantCommandResponse(validUpto, command.Tenant); + } +} \ No newline at end of file diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpgradeTenant/UpgradeTenantCommandValidator.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpgradeTenant/UpgradeTenantCommandValidator.cs new file mode 100644 index 0000000000..19d7415a2b --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpgradeTenant/UpgradeTenantCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; +using FSH.Modules.Multitenancy.Contracts.v1.UpgradeTenant; + +namespace FSH.Modules.Multitenancy.Features.v1.UpgradeTenant; + +public sealed class UpgradeTenantCommandValidator : AbstractValidator +{ + public UpgradeTenantCommandValidator() + { + RuleFor(t => t.Tenant).NotEmpty(); + RuleFor(t => t.ExtendedExpiryDate).GreaterThan(DateTime.UtcNow); + } +} \ No newline at end of file diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpgradeTenant/UpgradeTenantEndpoint.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpgradeTenant/UpgradeTenantEndpoint.cs new file mode 100644 index 0000000000..2784d4af8d --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpgradeTenant/UpgradeTenantEndpoint.cs @@ -0,0 +1,33 @@ +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Multitenancy.Contracts.v1.UpgradeTenant; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Multitenancy.Features.v1.UpgradeTenant; + +public static class UpgradeTenantEndpoint +{ + internal static RouteHandlerBuilder Map(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/{id}/upgrade", async ( + string id, + UpgradeTenantCommand command, + IMediator dispatcher) => + { + if (!string.Equals(id, command.Tenant, StringComparison.Ordinal)) + { + return Results.BadRequest(); + } + + var result = await dispatcher.Send(command); + return Results.Ok(result); + }) + .WithName("UpgradeTenant") + .WithSummary("Upgrade tenant subscription") + .RequirePermission(MultitenancyConstants.Permissions.Update) + .WithDescription("Extend or upgrade a tenant's subscription."); + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Modules.Multitenancy.csproj b/src/Modules/Multitenancy/Modules.Multitenancy/Modules.Multitenancy.csproj new file mode 100644 index 0000000000..7e0df4724c --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Modules.Multitenancy.csproj @@ -0,0 +1,25 @@ + + + FSH.Modules.Multitenancy + FSH.Modules.Multitenancy + FullStackHero.Modules.Multitenancy + $(NoWarn);CA1031;CA1056;CA1008;CA1716;CA1812;S1135;S2139;S6667;S3267;S1172 + + + + + + + + + + + + + + + + + + + diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyModule.cs b/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyModule.cs new file mode 100644 index 0000000000..04ee98d6cf --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyModule.cs @@ -0,0 +1,119 @@ +using Asp.Versioning; +using Finbuckle.MultiTenant.Abstractions; +using Finbuckle.MultiTenant.AspNetCore.Extensions; +using Finbuckle.MultiTenant.EntityFrameworkCore.Stores; +using Finbuckle.MultiTenant.Extensions; +using Finbuckle.MultiTenant.Stores; +using FSH.Framework.Persistence; +using FSH.Framework.Shared.Constants; +using FSH.Framework.Shared.Multitenancy; +using FSH.Framework.Web.Modules; +using FSH.Modules.Multitenancy.Contracts; +using FSH.Modules.Multitenancy.Data; +using FSH.Modules.Multitenancy.Features.v1.ChangeTenantActivation; +using FSH.Modules.Multitenancy.Features.v1.CreateTenant; +using FSH.Modules.Multitenancy.Features.v1.GetTenants; +using FSH.Modules.Multitenancy.Features.v1.GetTenantStatus; +using FSH.Modules.Multitenancy.Features.v1.GetTenantTheme; +using FSH.Modules.Multitenancy.Features.v1.ResetTenantTheme; +using FSH.Modules.Multitenancy.Features.v1.TenantProvisioning.GetTenantProvisioningStatus; +using FSH.Modules.Multitenancy.Features.v1.TenantProvisioning.RetryTenantProvisioning; +using FSH.Modules.Multitenancy.Features.v1.UpdateTenantTheme; +using FSH.Modules.Multitenancy.Features.v1.UpgradeTenant; +using FSH.Modules.Multitenancy.Provisioning; +using FSH.Modules.Multitenancy.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; + +namespace FSH.Modules.Multitenancy; + +public sealed class MultitenancyModule : IModule +{ + public void ConfigureServices(IHostApplicationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(nameof(MultitenancyOptions))); + + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddTransient(); + builder.Services.AddScoped(); + builder.Services.AddHostedService(); + builder.Services.AddTransient(); + builder.Services.AddHostedService(); + + builder.Services.AddHeroDbContext(); + + builder.Services + .AddMultiTenant(options => + { + options.Events.OnTenantResolveCompleted = async context => + { + if (context.MultiTenantContext.StoreInfo is null) return; + if (context.MultiTenantContext.StoreInfo.StoreType != typeof(DistributedCacheStore)) + { + var sp = ((HttpContext)context.Context!).RequestServices; + var distributedStore = sp + .GetRequiredService>>() + .FirstOrDefault(s => s.GetType() == typeof(DistributedCacheStore)); + + await distributedStore!.AddAsync(context.MultiTenantContext.TenantInfo!); + } + await Task.CompletedTask; + }; + }) + .WithClaimStrategy(ClaimConstants.Tenant) + .WithHeaderStrategy(MultitenancyConstants.Identifier) + .WithDelegateStrategy(async context => + { + if (context is not HttpContext httpContext) return null; + + if (!httpContext.Request.Query.TryGetValue("tenant", out var tenantIdentifier) || + string.IsNullOrEmpty(tenantIdentifier)) + return null; + + return await Task.FromResult(tenantIdentifier.ToString()); + }) + .WithDistributedCacheStore(TimeSpan.FromMinutes(60)) + .WithStore>(ServiceLifetime.Scoped); + + builder.Services.AddHealthChecks() + .AddDbContextCheck( + name: "db:multitenancy", + failureStatus: HealthStatus.Unhealthy) + .AddCheck( + name: "db:tenants-migrations", + failureStatus: HealthStatus.Healthy); + builder.Services.AddScoped(); + } + + public void MapEndpoints(IEndpointRouteBuilder endpoints) + { + var versionSet = endpoints.NewApiVersionSet() + .HasApiVersion(new ApiVersion(1)) + .ReportApiVersions() + .Build(); + + var group = endpoints.MapGroup("api/v{version:apiVersion}/tenants") + .WithTags("Tenants") + .WithApiVersionSet(versionSet); + ChangeTenantActivationEndpoint.Map(group); + GetTenantsEndpoint.Map(group); + UpgradeTenantEndpoint.Map(group); + CreateTenantEndpoint.Map(group); + GetTenantStatusEndpoint.Map(group); + GetTenantProvisioningStatusEndpoint.Map(group); + RetryTenantProvisioningEndpoint.Map(group); + + // Theme endpoints + GetTenantThemeEndpoint.Map(group); + UpdateTenantThemeEndpoint.Map(group); + ResetTenantThemeEndpoint.Map(group); + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyOptions.cs b/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyOptions.cs new file mode 100644 index 0000000000..c53e23f0f5 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyOptions.cs @@ -0,0 +1,20 @@ +namespace FSH.Modules.Multitenancy; + +/// +/// Options controlling multitenancy behavior at startup. +/// +public sealed class MultitenancyOptions +{ + /// + /// When true, runs per-tenant migrations and seeding for all registered IDbInitializer + /// implementations during UseHeroMultiTenantDatabases. Recommended for development and demos. + /// In production, prefer running migrations explicitly and leaving this disabled for faster startup. + /// + public bool RunTenantMigrationsOnStartup { get; set; } + + /// + /// When true, enqueues tenant provisioning (migrate/seed) jobs on startup for tenants that have not completed provisioning. + /// Useful to ensure the root tenant is provisioned automatically on first run when startup migrations are disabled. + /// + public bool AutoProvisionOnStartup { get; set; } = true; +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/ITenantProvisioningService.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/ITenantProvisioningService.cs new file mode 100644 index 0000000000..8d0db9b895 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/ITenantProvisioningService.cs @@ -0,0 +1,24 @@ +using FSH.Modules.Multitenancy.Contracts.Dtos; + +namespace FSH.Modules.Multitenancy.Provisioning; + +public interface ITenantProvisioningService +{ + Task StartAsync(string tenantId, CancellationToken cancellationToken); + + Task GetLatestAsync(string tenantId, CancellationToken cancellationToken); + + Task GetStatusAsync(string tenantId, CancellationToken cancellationToken); + + Task EnsureCanActivateAsync(string tenantId, CancellationToken cancellationToken); + + Task RetryAsync(string tenantId, CancellationToken cancellationToken); + + Task MarkRunningAsync(string tenantId, string correlationId, TenantProvisioningStepName step, CancellationToken cancellationToken); + + Task MarkStepCompletedAsync(string tenantId, string correlationId, TenantProvisioningStepName step, CancellationToken cancellationToken); + + Task MarkFailedAsync(string tenantId, string correlationId, TenantProvisioningStepName step, string error, CancellationToken cancellationToken); + + Task MarkCompletedAsync(string tenantId, string correlationId, CancellationToken cancellationToken); +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantAutoProvisioningHostedService.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantAutoProvisioningHostedService.cs new file mode 100644 index 0000000000..5ef2c2a90e --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantAutoProvisioningHostedService.cs @@ -0,0 +1,95 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Shared.Multitenancy; +using Hangfire; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace FSH.Modules.Multitenancy.Provisioning; + +public sealed class TenantAutoProvisioningHostedService : IHostedService +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly MultitenancyOptions _options; + + public TenantAutoProvisioningHostedService( + IServiceProvider serviceProvider, + ILogger logger, + IOptions options) + { + ArgumentNullException.ThrowIfNull(options); + _serviceProvider = serviceProvider; + _logger = logger; + _options = options.Value; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + if (!_options.AutoProvisionOnStartup && !_options.RunTenantMigrationsOnStartup) + { + return; + } + + if (!JobStorageAvailable()) + { + _logger.LogWarning("Hangfire storage not initialized; skipping auto-provisioning enqueue."); + return; + } + + using var scope = _serviceProvider.CreateScope(); + var tenantStore = scope.ServiceProvider.GetRequiredService>(); + var provisioning = scope.ServiceProvider.GetRequiredService(); + + var tenants = await tenantStore.GetAllAsync().ConfigureAwait(false); + foreach (var tenant in tenants) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + try + { + var latest = await provisioning.GetLatestAsync(tenant.Id, cancellationToken).ConfigureAwait(false); + + // When RunTenantMigrationsOnStartup is enabled, always re-provision to apply any new migrations + // Otherwise, only provision if not completed yet + bool shouldProvision = _options.RunTenantMigrationsOnStartup || + latest is null || + latest.Status != TenantProvisioningStatus.Completed; + + if (shouldProvision) + { + await provisioning.StartAsync(tenant.Id, cancellationToken).ConfigureAwait(false); + _logger.LogInformation("Enqueued provisioning for tenant {TenantId} on startup.", tenant.Id); + } + } + catch (CustomException ex) + { + _logger.LogInformation("Provisioning already in progress or recently queued for tenant {TenantId}: {Message}", tenant.Id, ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to enqueue provisioning for tenant {TenantId}", tenant.Id); + } + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + private static bool JobStorageAvailable() + { + try + { + _ = JobStorage.Current; + return true; + } + catch (InvalidOperationException) + { + return false; + } + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioning.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioning.cs new file mode 100644 index 0000000000..26d64b29e7 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioning.cs @@ -0,0 +1,62 @@ +namespace FSH.Modules.Multitenancy.Provisioning; + +public sealed class TenantProvisioning +{ + public Guid Id { get; private set; } = Guid.NewGuid(); + + public string TenantId { get; private set; } = default!; + + public string CorrelationId { get; private set; } = default!; + + public TenantProvisioningStatus Status { get; private set; } = TenantProvisioningStatus.Pending; + + public string? CurrentStep { get; private set; } + + public string? Error { get; private set; } + + public string? JobId { get; private set; } + + public DateTime CreatedUtc { get; private set; } = DateTime.UtcNow; + + public DateTime? StartedUtc { get; private set; } + + public DateTime? CompletedUtc { get; private set; } + + public ICollection Steps { get; private set; } = new List(); + + private TenantProvisioning() + { + } + + public TenantProvisioning(string tenantId, string correlationId) + { + TenantId = tenantId; + CorrelationId = correlationId; + CreatedUtc = DateTime.UtcNow; + } + + public void SetJobId(string jobId) => JobId = jobId; + + public void MarkRunning(string step) + { + Status = TenantProvisioningStatus.Running; + StartedUtc ??= DateTime.UtcNow; + CurrentStep = step; + } + + public void MarkCompleted() + { + Status = TenantProvisioningStatus.Completed; + CompletedUtc = DateTime.UtcNow; + CurrentStep = null; + Error = null; + } + + public void MarkFailed(string step, string error) + { + Status = TenantProvisioningStatus.Failed; + CurrentStep = step; + Error = error; + CompletedUtc = DateTime.UtcNow; + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningJob.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningJob.cs new file mode 100644 index 0000000000..b0bbab7b47 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningJob.cs @@ -0,0 +1,85 @@ +using Finbuckle.MultiTenant; +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Persistence; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Multitenancy.Contracts; +using FSH.Modules.Multitenancy.Services; +using Microsoft.Extensions.Logging; + +namespace FSH.Modules.Multitenancy.Provisioning; + +public sealed class TenantProvisioningJob +{ + private readonly ITenantProvisioningService _provisioningService; + private readonly IMultiTenantStore _tenantStore; + private readonly IMultiTenantContextSetter _tenantContextSetter; + private readonly ITenantService _tenantService; + private readonly ILogger _logger; + + public TenantProvisioningJob( + ITenantProvisioningService provisioningService, + IMultiTenantStore tenantStore, + IMultiTenantContextSetter tenantContextSetter, + ITenantService tenantService, + ILogger logger) + { + _provisioningService = provisioningService; + _tenantStore = tenantStore; + _tenantContextSetter = tenantContextSetter; + _tenantService = tenantService; + _logger = logger; + } + + public async Task RunAsync(string tenantId, string correlationId) + { + var tenant = await _tenantStore.GetAsync(tenantId).ConfigureAwait(false) + ?? throw new NotFoundException($"Tenant {tenantId} not found during provisioning."); + + var currentStep = TenantProvisioningStepName.Database; + try + { + var runDatabase = await _provisioningService.MarkRunningAsync(tenantId, correlationId, currentStep, CancellationToken.None).ConfigureAwait(false); + + _tenantContextSetter.MultiTenantContext = new MultiTenantContext(tenant); + + if (runDatabase) + { + await _provisioningService.MarkStepCompletedAsync(tenantId, correlationId, currentStep, CancellationToken.None).ConfigureAwait(false); + } + + currentStep = TenantProvisioningStepName.Migrations; + var runMigrations = await _provisioningService.MarkRunningAsync(tenantId, correlationId, currentStep, CancellationToken.None).ConfigureAwait(false); + if (runMigrations) + { + await _tenantService.MigrateTenantAsync(tenant, CancellationToken.None).ConfigureAwait(false); + await _provisioningService.MarkStepCompletedAsync(tenantId, correlationId, currentStep, CancellationToken.None).ConfigureAwait(false); + } + + currentStep = TenantProvisioningStepName.Seeding; + var runSeeding = await _provisioningService.MarkRunningAsync(tenantId, correlationId, currentStep, CancellationToken.None).ConfigureAwait(false); + if (runSeeding) + { + await _tenantService.SeedTenantAsync(tenant, CancellationToken.None).ConfigureAwait(false); + await _provisioningService.MarkStepCompletedAsync(tenantId, correlationId, currentStep, CancellationToken.None).ConfigureAwait(false); + } + + currentStep = TenantProvisioningStepName.CacheWarm; + var runCacheWarm = await _provisioningService.MarkRunningAsync(tenantId, correlationId, currentStep, CancellationToken.None).ConfigureAwait(false); + if (runCacheWarm) + { + await _provisioningService.MarkStepCompletedAsync(tenantId, correlationId, currentStep, CancellationToken.None).ConfigureAwait(false); + } + + await _provisioningService.MarkCompletedAsync(tenantId, correlationId, CancellationToken.None).ConfigureAwait(false); + + _logger.LogInformation("Provisioned tenant {TenantId} correlation {CorrelationId}", tenantId, correlationId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Provisioning failed for tenant {TenantId}", tenantId); + await _provisioningService.MarkFailedAsync(tenantId, correlationId, currentStep, ex.Message, CancellationToken.None).ConfigureAwait(false); + throw; + } + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningService.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningService.cs new file mode 100644 index 0000000000..86006561e7 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningService.cs @@ -0,0 +1,220 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Jobs.Services; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Multitenancy.Contracts.Dtos; +using FSH.Modules.Multitenancy.Data; +using Hangfire; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace FSH.Modules.Multitenancy.Provisioning; + +public sealed class TenantProvisioningService : ITenantProvisioningService +{ + private readonly TenantDbContext _dbContext; + private readonly IMultiTenantStore _tenantStore; + private readonly IJobService _jobService; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public TenantProvisioningService( + TenantDbContext dbContext, + IMultiTenantStore tenantStore, + IJobService jobService, + IServiceScopeFactory scopeFactory, + ILogger logger) + { + _dbContext = dbContext; + _tenantStore = tenantStore; + _jobService = jobService; + _scopeFactory = scopeFactory; + _logger = logger; + } + + 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."); + + 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}."); + } + + var correlationId = Guid.NewGuid().ToString(); + var provisioning = new TenantProvisioning(tenant.Id, correlationId); + + provisioning.Steps.Add(new TenantProvisioningStep(provisioning.Id, TenantProvisioningStepName.Database)); + provisioning.Steps.Add(new TenantProvisioningStep(provisioning.Id, TenantProvisioningStepName.Migrations)); + provisioning.Steps.Add(new TenantProvisioningStep(provisioning.Id, TenantProvisioningStepName.Seeding)); + provisioning.Steps.Add(new TenantProvisioningStep(provisioning.Id, TenantProvisioningStepName.CacheWarm)); + + _dbContext.Add(provisioning); + await _dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + if (!TryEnsureJobStorage()) + { + _logger.LogWarning("Background job storage not available; running provisioning inline for tenant {TenantId}.", tenantId); + provisioning.SetJobId("inline"); + await _dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + await RunInlineProvisioningAsync(tenant.Id, correlationId, cancellationToken).ConfigureAwait(false); + return provisioning; + } + + var jobId = _jobService.Enqueue(job => job.RunAsync(tenant.Id, correlationId)); + provisioning.SetJobId(jobId); + await _dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return provisioning; + } + + public async Task GetLatestAsync(string tenantId, CancellationToken cancellationToken) + { + return await _dbContext.Set() + .Include(p => p.Steps) + .Where(p => p.TenantId == tenantId) + .OrderByDescending(p => p.CreatedUtc) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + } + + 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}."); + + return ToDto(provisioning); + } + + public async Task EnsureCanActivateAsync(string tenantId, CancellationToken cancellationToken) + { + var provisioning = await GetLatestAsync(tenantId, cancellationToken).ConfigureAwait(false); + if (provisioning is null) + { + return; + } + + if (provisioning.Status != TenantProvisioningStatus.Completed) + { + throw new CustomException($"Tenant {tenantId} is not provisioned. Status: {provisioning.Status}."); + } + } + + public async Task RetryAsync(string tenantId, CancellationToken cancellationToken) + { + var provisioning = await StartAsync(tenantId, cancellationToken).ConfigureAwait(false); + return provisioning.CorrelationId; + } + + public async Task MarkRunningAsync(string tenantId, string correlationId, TenantProvisioningStepName step, CancellationToken cancellationToken) + { + var provisioning = await RequireAsync(tenantId, correlationId, cancellationToken).ConfigureAwait(false); + var stepEntity = provisioning.Steps.First(s => s.Step == step); + + if (stepEntity.Status == TenantProvisioningStatus.Completed) + { + return false; + } + + provisioning.MarkRunning(step.ToString()); + stepEntity.MarkRunning(); + + await _dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return true; + } + + public async Task MarkStepCompletedAsync(string tenantId, string correlationId, TenantProvisioningStepName step, CancellationToken cancellationToken) + { + var provisioning = await RequireAsync(tenantId, correlationId, cancellationToken).ConfigureAwait(false); + var stepEntity = provisioning.Steps.First(s => s.Step == step); + + if (stepEntity.Status == TenantProvisioningStatus.Completed) + { + return; + } + + stepEntity.MarkCompleted(); + await _dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task MarkFailedAsync(string tenantId, string correlationId, TenantProvisioningStepName step, string error, CancellationToken cancellationToken) + { + var provisioning = await RequireAsync(tenantId, correlationId, cancellationToken).ConfigureAwait(false); + provisioning.MarkFailed(step.ToString(), error); + + var stepEntity = provisioning.Steps.First(s => s.Step == step); + stepEntity.MarkFailed(error); + + await _dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task MarkCompletedAsync(string tenantId, string correlationId, CancellationToken cancellationToken) + { + var provisioning = await RequireAsync(tenantId, correlationId, cancellationToken).ConfigureAwait(false); + + if (provisioning.Status == TenantProvisioningStatus.Completed) + { + return; + } + + provisioning.MarkCompleted(); + await _dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task RequireAsync(string tenantId, string correlationId, CancellationToken cancellationToken) + { + return await _dbContext.Set() + .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."); + } + + private static bool TryEnsureJobStorage() + { + try + { + _ = JobStorage.Current; + return true; + } + catch (InvalidOperationException) + { + return false; + } + } + + private async Task RunInlineProvisioningAsync(string tenantId, string correlationId, CancellationToken cancellationToken) + { + using var scope = _scopeFactory.CreateScope(); + var job = scope.ServiceProvider.GetRequiredService(); + await job.RunAsync(tenantId, correlationId).ConfigureAwait(false); + } + + private static TenantProvisioningStatusDto ToDto(TenantProvisioning provisioning) + { + var steps = provisioning.Steps + .OrderBy(s => s.Step) + .Select(s => new TenantProvisioningStepDto( + s.Step.ToString(), + s.Status.ToString(), + s.StartedUtc, + s.CompletedUtc, + s.Error)) + .ToArray(); + + return new TenantProvisioningStatusDto( + provisioning.TenantId, + provisioning.Status.ToString(), + provisioning.CorrelationId, + provisioning.CurrentStep, + provisioning.Error, + provisioning.CreatedUtc, + provisioning.StartedUtc, + provisioning.CompletedUtc, + steps); + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningStatus.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningStatus.cs new file mode 100644 index 0000000000..00e560553b --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningStatus.cs @@ -0,0 +1,9 @@ +namespace FSH.Modules.Multitenancy.Provisioning; + +public enum TenantProvisioningStatus +{ + Pending = 0, + Running = 1, + Completed = 2, + Failed = 3 +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningStep.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningStep.cs new file mode 100644 index 0000000000..edb80564ac --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningStep.cs @@ -0,0 +1,52 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace FSH.Modules.Multitenancy.Provisioning; + +public sealed class TenantProvisioningStep +{ + public Guid Id { get; private set; } = Guid.NewGuid(); + + public Guid ProvisioningId { get; private set; } + + public TenantProvisioningStepName Step { get; private set; } + + public TenantProvisioningStatus Status { get; private set; } = TenantProvisioningStatus.Pending; + + public string? Error { get; private set; } + + public DateTime? StartedUtc { get; private set; } + + public DateTime? CompletedUtc { get; private set; } + + [ForeignKey(nameof(ProvisioningId))] + public TenantProvisioning? Provisioning { get; private set; } + + private TenantProvisioningStep() + { + } + + public TenantProvisioningStep(Guid provisioningId, TenantProvisioningStepName step) + { + ProvisioningId = provisioningId; + Step = step; + } + + public void MarkRunning() + { + Status = TenantProvisioningStatus.Running; + StartedUtc ??= DateTime.UtcNow; + } + + public void MarkCompleted() + { + Status = TenantProvisioningStatus.Completed; + CompletedUtc = DateTime.UtcNow; + } + + public void MarkFailed(string error) + { + Status = TenantProvisioningStatus.Failed; + Error = error; + CompletedUtc = DateTime.UtcNow; + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningStepName.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningStepName.cs new file mode 100644 index 0000000000..69701fde54 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningStepName.cs @@ -0,0 +1,9 @@ +namespace FSH.Modules.Multitenancy.Provisioning; + +public enum TenantProvisioningStepName +{ + Database = 1, + Migrations = 2, + Seeding = 3, + CacheWarm = 4 +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantStoreInitializerHostedService.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantStoreInitializerHostedService.cs new file mode 100644 index 0000000000..62a2a9847e --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantStoreInitializerHostedService.cs @@ -0,0 +1,53 @@ +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Multitenancy.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace FSH.Modules.Multitenancy.Provisioning; + +/// +/// Initializes the tenant catalog database and seeds the root tenant on startup. +/// +public sealed class TenantStoreInitializerHostedService : IHostedService +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public TenantStoreInitializerHostedService( + IServiceProvider serviceProvider, + ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + using var scope = _serviceProvider.CreateScope(); + + var tenantDbContext = scope.ServiceProvider.GetRequiredService(); + await tenantDbContext.Database.MigrateAsync(cancellationToken).ConfigureAwait(false); + _logger.LogInformation("Applied tenant catalog migrations."); + + if (await tenantDbContext.TenantInfo.FindAsync([MultitenancyConstants.Root.Id], cancellationToken).ConfigureAwait(false) is null) + { + var rootTenant = new AppTenantInfo( + MultitenancyConstants.Root.Id, + MultitenancyConstants.Root.Name, + string.Empty, + MultitenancyConstants.Root.EmailAddress, + issuer: MultitenancyConstants.Root.Issuer); + + var validUpto = DateTime.UtcNow.AddYears(1); + rootTenant.SetValidity(validUpto); + await tenantDbContext.TenantInfo.AddAsync(rootTenant, cancellationToken).ConfigureAwait(false); + await tenantDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + _logger.LogInformation("Seeded root tenant."); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantService.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantService.cs new file mode 100644 index 0000000000..0859ff85d4 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantService.cs @@ -0,0 +1,177 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Persistence; +using FSH.Framework.Shared.Multitenancy; +using FSH.Framework.Shared.Persistence; +using FSH.Modules.Multitenancy.Contracts; +using FSH.Modules.Multitenancy.Contracts.Dtos; +using FSH.Modules.Multitenancy.Contracts.v1.GetTenants; +using FSH.Modules.Multitenancy.Data; +using FSH.Modules.Multitenancy.Features.v1.GetTenants; +using FSH.Modules.Multitenancy.Provisioning; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace FSH.Modules.Multitenancy.Services; + +public sealed class TenantService : ITenantService +{ + private readonly IMultiTenantStore _tenantStore; + private readonly DatabaseOptions _config; + private readonly IServiceProvider _serviceProvider; + private readonly TenantDbContext _dbContext; + private readonly ITenantProvisioningService _provisioningService; + + public TenantService( + IMultiTenantStore tenantStore, + IOptions config, + IServiceProvider serviceProvider, + TenantDbContext dbContext, + ITenantProvisioningService provisioningService) + { + ArgumentNullException.ThrowIfNull(config); + _tenantStore = tenantStore; + _config = config.Value; + _serviceProvider = serviceProvider; + _dbContext = dbContext; + _provisioningService = provisioningService; + } + + public async Task ActivateAsync(string id, CancellationToken cancellationToken) + { + var tenant = await GetTenantInfoAsync(id).ConfigureAwait(false); + + if (tenant.IsActive) + { + throw new CustomException($"tenant {id} is already activated"); + } + + await _provisioningService.EnsureCanActivateAsync(id, cancellationToken).ConfigureAwait(false); + + tenant.Activate(); + + await _tenantStore.UpdateAsync(tenant).ConfigureAwait(false); + + return $"tenant {id} is now activated"; + } + + public async Task CreateAsync(string id, + string name, + string? connectionString, + string adminEmail, string? issuer, CancellationToken cancellationToken) + { + if (connectionString?.Trim() == _config.ConnectionString.Trim()) + { + connectionString = string.Empty; + } + + AppTenantInfo tenant = new(id, name, connectionString, adminEmail, issuer); + await _tenantStore.AddAsync(tenant).ConfigureAwait(false); + + return tenant.Id; + } + + public async Task MigrateTenantAsync(AppTenantInfo tenant, CancellationToken cancellationToken) + { + using var scope = _serviceProvider.CreateScope(); + + scope.ServiceProvider.GetRequiredService() + .MultiTenantContext = new MultiTenantContext(tenant); + + foreach (var initializer in scope.ServiceProvider.GetServices()) + { + await initializer.MigrateAsync(cancellationToken).ConfigureAwait(false); + } + } + + public async Task SeedTenantAsync(AppTenantInfo tenant, CancellationToken cancellationToken) + { + using var scope = _serviceProvider.CreateScope(); + + scope.ServiceProvider.GetRequiredService() + .MultiTenantContext = new MultiTenantContext(tenant); + + foreach (var initializer in scope.ServiceProvider.GetServices()) + { + await initializer.SeedAsync(cancellationToken).ConfigureAwait(false); + } + } + + public async Task DeactivateAsync(string id) + { + var tenant = await GetTenantInfoAsync(id).ConfigureAwait(false); + if (!tenant.IsActive) + { + throw new CustomException($"tenant {id} is already deactivated"); + } + + int tenantCount = (await _tenantStore.GetAllAsync().ConfigureAwait(false)).Count(t => t.IsActive); + if (tenantCount <= 1) + { + throw new CustomException("At least one active tenant is required."); + } + + if (tenant.Id.Equals(MultitenancyConstants.Root.Id, StringComparison.OrdinalIgnoreCase)) + { + throw new CustomException("The root tenant cannot be deactivated."); + } + + tenant.Deactivate(); + await _tenantStore.UpdateAsync(tenant).ConfigureAwait(false); + return $"tenant {id} is now deactivated"; + } + + public async Task ExistsWithIdAsync(string id) => + await _tenantStore.GetAsync(id).ConfigureAwait(false) is not null; + + public async Task ExistsWithNameAsync(string name) => + (await _tenantStore.GetAllAsync().ConfigureAwait(false)).Any(t => t.Name == name); + + public async Task> GetAllAsync(GetTenantsQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + + IQueryable tenants = _dbContext.TenantInfo; + var specification = new GetTenantsSpecification(query); + IQueryable projected = tenants.ApplySpecification(specification); + + return await projected + .ToPagedResponseAsync(query, cancellationToken) + .ConfigureAwait(false); + } + + public async Task GetStatusAsync(string id) + { + var tenant = await GetTenantInfoAsync(id).ConfigureAwait(false); + + return new TenantStatusDto + { + Id = tenant.Id!, + Name = tenant.Name!, + IsActive = tenant.IsActive, + ValidUpto = tenant.ValidUpto, + HasConnectionString = !string.IsNullOrWhiteSpace(tenant.ConnectionString), + AdminEmail = tenant.AdminEmail!, + Issuer = tenant.Issuer + }; + } + + public async Task UpgradeSubscription(string id, DateTime extendedExpiryDate) + { + var tenant = await GetTenantInfoAsync(id).ConfigureAwait(false); + + // Ensure the date is UTC for PostgreSQL compatibility + var utcExpiryDate = extendedExpiryDate.Kind == DateTimeKind.Utc + ? extendedExpiryDate + : DateTime.SpecifyKind(extendedExpiryDate, DateTimeKind.Utc); + + tenant.SetValidity(utcExpiryDate); + await _tenantStore.UpdateAsync(tenant).ConfigureAwait(false); + return tenant.ValidUpto; + } + + private async Task GetTenantInfoAsync(string id) => + await _tenantStore.GetAsync(id).ConfigureAwait(false) + ?? throw new NotFoundException($"{typeof(AppTenantInfo).Name} {id} Not Found."); +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantThemeService.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantThemeService.cs new file mode 100644 index 0000000000..7a6a348ebb --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantThemeService.cs @@ -0,0 +1,339 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Caching; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Shared.Multitenancy; +using FSH.Framework.Storage; +using FSH.Framework.Storage.DTOs; +using FSH.Framework.Storage.Services; +using FSH.Modules.Multitenancy.Contracts; +using FSH.Modules.Multitenancy.Contracts.Dtos; +using FSH.Modules.Multitenancy.Data; +using FSH.Modules.Multitenancy.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace FSH.Modules.Multitenancy.Services; + +public sealed class TenantThemeService : ITenantThemeService +{ + private const string CacheKeyPrefix = "theme:"; + private const string DefaultThemeCacheKey = "theme:default"; + private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1); + + private readonly ICacheService _cache; + private readonly TenantDbContext _dbContext; + private readonly IMultiTenantContextAccessor _tenantAccessor; + private readonly IStorageService _storageService; + private readonly ILogger _logger; + + public TenantThemeService( + ICacheService cache, + TenantDbContext dbContext, + IMultiTenantContextAccessor tenantAccessor, + IStorageService storageService, + ILogger logger) + { + _cache = cache; + _dbContext = dbContext; + _tenantAccessor = tenantAccessor; + _storageService = storageService; + _logger = logger; + } + + public async Task GetCurrentTenantThemeAsync(CancellationToken ct = default) + { + var tenantId = _tenantAccessor.MultiTenantContext?.TenantInfo?.Id + ?? throw new InvalidOperationException("No tenant context available"); + return await GetThemeAsync(tenantId, ct).ConfigureAwait(false); + } + + public async Task GetThemeAsync(string tenantId, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + + var cacheKey = $"{CacheKeyPrefix}{tenantId}"; + + var theme = await _cache.GetOrSetAsync( + cacheKey, + async () => await LoadThemeFromDbAsync(tenantId, ct).ConfigureAwait(false), + CacheDuration, + ct).ConfigureAwait(false); + + return theme ?? TenantThemeDto.Default; + } + + public async Task GetDefaultThemeAsync(CancellationToken ct = default) + { + var theme = await _cache.GetOrSetAsync( + DefaultThemeCacheKey, + async () => await LoadDefaultThemeFromDbAsync(ct).ConfigureAwait(false), + CacheDuration, + ct).ConfigureAwait(false); + + return theme ?? TenantThemeDto.Default; + } + + public async Task UpdateThemeAsync(string tenantId, TenantThemeDto theme, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentNullException.ThrowIfNull(theme); + + var entity = await _dbContext.TenantThemes + .FirstOrDefaultAsync(t => t.TenantId == tenantId, ct) + .ConfigureAwait(false); + + if (entity is null) + { + entity = TenantTheme.Create(tenantId); + _dbContext.TenantThemes.Add(entity); + } + + // Handle brand asset uploads + await HandleBrandAssetUploadsAsync(theme.BrandAssets, entity, ct).ConfigureAwait(false); + + MapDtoToEntity(theme, entity); + entity.Update(null); // TODO: Get current user + + await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false); + await InvalidateCacheAsync(tenantId, ct).ConfigureAwait(false); + + _logger.LogInformation("Updated theme for tenant {TenantId}", tenantId); + } + + private async Task HandleBrandAssetUploadsAsync(BrandAssetsDto assets, TenantTheme entity, CancellationToken ct) + { + // Handle logo upload (same pattern as profile picture) + if (assets.Logo?.Data is { Count: > 0 }) + { + var oldLogoUrl = entity.LogoUrl; + entity.LogoUrl = await _storageService.UploadAsync(assets.Logo, FileType.Image, ct).ConfigureAwait(false); + if (!string.IsNullOrEmpty(oldLogoUrl)) + { + await _storageService.RemoveAsync(oldLogoUrl, ct).ConfigureAwait(false); + } + } + else if (assets.DeleteLogo && !string.IsNullOrEmpty(entity.LogoUrl)) + { + await _storageService.RemoveAsync(entity.LogoUrl, ct).ConfigureAwait(false); + entity.LogoUrl = null; + } + + // Handle logo dark upload + if (assets.LogoDark?.Data is { Count: > 0 }) + { + var oldLogoUrl = entity.LogoDarkUrl; + entity.LogoDarkUrl = await _storageService.UploadAsync(assets.LogoDark, FileType.Image, ct).ConfigureAwait(false); + if (!string.IsNullOrEmpty(oldLogoUrl)) + { + await _storageService.RemoveAsync(oldLogoUrl, ct).ConfigureAwait(false); + } + } + else if (assets.DeleteLogoDark && !string.IsNullOrEmpty(entity.LogoDarkUrl)) + { + await _storageService.RemoveAsync(entity.LogoDarkUrl, ct).ConfigureAwait(false); + entity.LogoDarkUrl = null; + } + + // Handle favicon upload + if (assets.Favicon?.Data is { Count: > 0 }) + { + var oldFaviconUrl = entity.FaviconUrl; + entity.FaviconUrl = await _storageService.UploadAsync(assets.Favicon, FileType.Image, ct).ConfigureAwait(false); + if (!string.IsNullOrEmpty(oldFaviconUrl)) + { + await _storageService.RemoveAsync(oldFaviconUrl, ct).ConfigureAwait(false); + } + } + else if (assets.DeleteFavicon && !string.IsNullOrEmpty(entity.FaviconUrl)) + { + await _storageService.RemoveAsync(entity.FaviconUrl, ct).ConfigureAwait(false); + entity.FaviconUrl = null; + } + } + + public async Task ResetThemeAsync(string tenantId, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + + var entity = await _dbContext.TenantThemes + .FirstOrDefaultAsync(t => t.TenantId == tenantId, ct) + .ConfigureAwait(false); + + if (entity is not null) + { + entity.ResetToDefaults(); + entity.Update(null); // TODO: Get current user + await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false); + } + + await InvalidateCacheAsync(tenantId, ct).ConfigureAwait(false); + + _logger.LogInformation("Reset theme to defaults for tenant {TenantId}", tenantId); + } + + public async Task SetAsDefaultThemeAsync(string tenantId, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + + // Ensure only root tenant can set default theme + var currentTenantId = _tenantAccessor.MultiTenantContext?.TenantInfo?.Id; + if (currentTenantId != MultitenancyConstants.Root.Id) + { + throw new ForbiddenException("Only the root tenant can set the default theme"); + } + + // Clear existing default + var existingDefault = await _dbContext.TenantThemes + .FirstOrDefaultAsync(t => t.IsDefault, ct) + .ConfigureAwait(false); + + if (existingDefault is not null) + { + existingDefault.IsDefault = false; + } + + // Set new default + var entity = await _dbContext.TenantThemes + .FirstOrDefaultAsync(t => t.TenantId == tenantId, ct) + .ConfigureAwait(false); + + if (entity is null) + { + throw new NotFoundException($"Theme for tenant {tenantId} not found"); + } + + entity.IsDefault = true; + await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false); + + // Invalidate default theme cache + await _cache.RemoveItemAsync(DefaultThemeCacheKey, ct).ConfigureAwait(false); + + _logger.LogInformation("Set theme for tenant {TenantId} as default", tenantId); + } + + public async Task InvalidateCacheAsync(string tenantId, CancellationToken ct = default) + { + var cacheKey = $"{CacheKeyPrefix}{tenantId}"; + await _cache.RemoveItemAsync(cacheKey, ct).ConfigureAwait(false); + } + + private async Task LoadThemeFromDbAsync(string tenantId, CancellationToken ct) + { + var entity = await _dbContext.TenantThemes + .AsNoTracking() + .FirstOrDefaultAsync(t => t.TenantId == tenantId, ct) + .ConfigureAwait(false); + + return entity is null ? null : MapEntityToDto(entity); + } + + private async Task LoadDefaultThemeFromDbAsync(CancellationToken ct) + { + var entity = await _dbContext.TenantThemes + .AsNoTracking() + .FirstOrDefaultAsync(t => t.IsDefault, ct) + .ConfigureAwait(false); + + return entity is null ? null : MapEntityToDto(entity); + } + + private static TenantThemeDto MapEntityToDto(TenantTheme entity) + { + return new TenantThemeDto + { + LightPalette = new PaletteDto + { + Primary = entity.PrimaryColor, + Secondary = entity.SecondaryColor, + Tertiary = entity.TertiaryColor, + Background = entity.BackgroundColor, + Surface = entity.SurfaceColor, + Error = entity.ErrorColor, + Warning = entity.WarningColor, + Success = entity.SuccessColor, + Info = entity.InfoColor + }, + DarkPalette = new PaletteDto + { + Primary = entity.DarkPrimaryColor, + Secondary = entity.DarkSecondaryColor, + Tertiary = entity.DarkTertiaryColor, + Background = entity.DarkBackgroundColor, + Surface = entity.DarkSurfaceColor, + Error = entity.DarkErrorColor, + Warning = entity.DarkWarningColor, + Success = entity.DarkSuccessColor, + Info = entity.DarkInfoColor + }, + BrandAssets = new BrandAssetsDto + { + LogoUrl = entity.LogoUrl, + LogoDarkUrl = entity.LogoDarkUrl, + FaviconUrl = entity.FaviconUrl + }, + Typography = new TypographyDto + { + FontFamily = entity.FontFamily, + HeadingFontFamily = entity.HeadingFontFamily, + FontSizeBase = entity.FontSizeBase, + LineHeightBase = entity.LineHeightBase + }, + Layout = new LayoutDto + { + BorderRadius = entity.BorderRadius, + DefaultElevation = entity.DefaultElevation + }, + IsDefault = entity.IsDefault + }; + } + + private static void MapDtoToEntity(TenantThemeDto dto, TenantTheme entity) + { + // Light Palette + entity.PrimaryColor = dto.LightPalette.Primary; + entity.SecondaryColor = dto.LightPalette.Secondary; + entity.TertiaryColor = dto.LightPalette.Tertiary; + entity.BackgroundColor = dto.LightPalette.Background; + entity.SurfaceColor = dto.LightPalette.Surface; + entity.ErrorColor = dto.LightPalette.Error; + entity.WarningColor = dto.LightPalette.Warning; + entity.SuccessColor = dto.LightPalette.Success; + entity.InfoColor = dto.LightPalette.Info; + + // Dark Palette + entity.DarkPrimaryColor = dto.DarkPalette.Primary; + entity.DarkSecondaryColor = dto.DarkPalette.Secondary; + entity.DarkTertiaryColor = dto.DarkPalette.Tertiary; + entity.DarkBackgroundColor = dto.DarkPalette.Background; + entity.DarkSurfaceColor = dto.DarkPalette.Surface; + entity.DarkErrorColor = dto.DarkPalette.Error; + entity.DarkWarningColor = dto.DarkPalette.Warning; + entity.DarkSuccessColor = dto.DarkPalette.Success; + entity.DarkInfoColor = dto.DarkPalette.Info; + + // Brand Assets - URLs are handled by HandleBrandAssetUploadsAsync + // Only copy URL if it's a real URL (not a data URL preview) + if (!string.IsNullOrEmpty(dto.BrandAssets.LogoUrl) && !dto.BrandAssets.LogoUrl.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + entity.LogoUrl = dto.BrandAssets.LogoUrl; + } + if (!string.IsNullOrEmpty(dto.BrandAssets.LogoDarkUrl) && !dto.BrandAssets.LogoDarkUrl.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + entity.LogoDarkUrl = dto.BrandAssets.LogoDarkUrl; + } + if (!string.IsNullOrEmpty(dto.BrandAssets.FaviconUrl) && !dto.BrandAssets.FaviconUrl.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + entity.FaviconUrl = dto.BrandAssets.FaviconUrl; + } + + // Typography + entity.FontFamily = dto.Typography.FontFamily; + entity.HeadingFontFamily = dto.Typography.HeadingFontFamily; + entity.FontSizeBase = dto.Typography.FontSizeBase; + entity.LineHeightBase = dto.Typography.LineHeightBase; + + // Layout + entity.BorderRadius = dto.Layout.BorderRadius; + entity.DefaultElevation = dto.Layout.DefaultElevation; + } +} diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/TenantMigrationsHealthCheck.cs b/src/Modules/Multitenancy/Modules.Multitenancy/TenantMigrationsHealthCheck.cs new file mode 100644 index 0000000000..8a87f10708 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/TenantMigrationsHealthCheck.cs @@ -0,0 +1,69 @@ +using Finbuckle.MultiTenant; +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Multitenancy.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace FSH.Modules.Multitenancy; + +public sealed class TenantMigrationsHealthCheck : IHealthCheck +{ + private readonly IServiceScopeFactory _scopeFactory; + + public TenantMigrationsHealthCheck(IServiceScopeFactory scopeFactory) + { + _scopeFactory = scopeFactory; + } + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + using IServiceScope scope = _scopeFactory.CreateScope(); + + var tenantStore = scope.ServiceProvider.GetRequiredService>(); + var tenants = await tenantStore.GetAllAsync().ConfigureAwait(false); + + var details = new Dictionary(); + + foreach (var tenant in tenants) + { + try + { + using IServiceScope tenantScope = scope.ServiceProvider.CreateScope(); + + tenantScope.ServiceProvider.GetRequiredService() + .MultiTenantContext = new MultiTenantContext(tenant); + + var dbContext = tenantScope.ServiceProvider.GetRequiredService(); + + var pendingMigrations = await dbContext.Database + .GetPendingMigrationsAsync(cancellationToken) + .ConfigureAwait(false); + + bool hasPending = pendingMigrations.Any(); + + details[tenant.Id] = new + { + tenant.Name, + tenant.IsActive, + tenant.ValidUpto, + HasPendingMigrations = hasPending, + PendingMigrations = pendingMigrations.ToArray() + }; + } + catch (Exception ex) + { + details[tenant.Id] = new + { + tenant.Name, + tenant.IsActive, + tenant.ValidUpto, + Error = ex.Message + }; + } + } + + return HealthCheckResult.Healthy("Tenant migrations status collected.", details); + } +} diff --git a/src/Playground/FSH.Playground.AppHost/.aspire/settings.json b/src/Playground/FSH.Playground.AppHost/.aspire/settings.json new file mode 100644 index 0000000000..5b94f51308 --- /dev/null +++ b/src/Playground/FSH.Playground.AppHost/.aspire/settings.json @@ -0,0 +1,3 @@ +{ + "appHostPath": "../FSH.Playground.AppHost.csproj" +} \ No newline at end of file diff --git a/src/Playground/FSH.Playground.AppHost/AppHost.cs b/src/Playground/FSH.Playground.AppHost/AppHost.cs new file mode 100644 index 0000000000..b1041818f2 --- /dev/null +++ b/src/Playground/FSH.Playground.AppHost/AppHost.cs @@ -0,0 +1,24 @@ +var builder = DistributedApplication.CreateBuilder(args); + +// Postgres container + database +var postgres = builder.AddPostgres("postgres").WithDataVolume("fsh-postgres-data").AddDatabase("fsh"); + +var redis = builder.AddRedis("redis").WithDataVolume("fsh-redis-data"); + +builder.AddProject("playground-api") + .WithReference(postgres) + .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development") + .WithEnvironment("OpenTelemetryOptions__Exporter__Otlp__Endpoint", "https://localhost:4317") + .WithEnvironment("OpenTelemetryOptions__Exporter__Otlp__Protocol", "grpc") + .WithEnvironment("OpenTelemetryOptions__Exporter__Otlp__Enabled", "true") + .WithEnvironment("DatabaseOptions__Provider", "POSTGRESQL") + .WithEnvironment("DatabaseOptions__ConnectionString", postgres.Resource.ConnectionStringExpression) + .WithEnvironment("DatabaseOptions__MigrationsAssembly", "FSH.Playground.Migrations.PostgreSQL") + .WaitFor(postgres) + .WithReference(redis) + .WithEnvironment("CachingOptions__Redis", redis.Resource.ConnectionStringExpression) + .WaitFor(redis); + +builder.AddProject("playground-blazor"); + +await builder.Build().RunAsync(); diff --git a/src/Playground/FSH.Playground.AppHost/FSH.Playground.AppHost.csproj b/src/Playground/FSH.Playground.AppHost/FSH.Playground.AppHost.csproj new file mode 100644 index 0000000000..7c106a518f --- /dev/null +++ b/src/Playground/FSH.Playground.AppHost/FSH.Playground.AppHost.csproj @@ -0,0 +1,21 @@ + + + + Exe + enable + enable + 9fe5df9a-b9b2-4202-bdb4-d30b01b71d1a + false + + + + + + + + + + + + + diff --git a/src/Playground/FSH.Playground.AppHost/Properties/launchSettings.json b/src/Playground/FSH.Playground.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000000..ee96e89c16 --- /dev/null +++ b/src/Playground/FSH.Playground.AppHost/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17273;http://localhost:15036", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:4317", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23045", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22203" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15036", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:4317", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18209", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20105" + } + } + } +} diff --git a/src/aspire/Host/appsettings.Development.json b/src/Playground/FSH.Playground.AppHost/appsettings.Development.json similarity index 100% rename from src/aspire/Host/appsettings.Development.json rename to src/Playground/FSH.Playground.AppHost/appsettings.Development.json diff --git a/src/aspire/Host/appsettings.json b/src/Playground/FSH.Playground.AppHost/appsettings.json similarity index 100% rename from src/aspire/Host/appsettings.json rename to src/Playground/FSH.Playground.AppHost/appsettings.json diff --git a/src/Playground/Migrations.PostgreSQL/Audit/20251203033647_Add Audits.Designer.cs b/src/Playground/Migrations.PostgreSQL/Audit/20251203033647_Add Audits.Designer.cs new file mode 100644 index 0000000000..35a549f49d --- /dev/null +++ b/src/Playground/Migrations.PostgreSQL/Audit/20251203033647_Add Audits.Designer.cs @@ -0,0 +1,94 @@ +// +using System; +using FSH.Modules.Auditing.Persistence; +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.Playground.Migrations.PostgreSQL.Audit +{ + [DbContext(typeof(AuditDbContext))] + [Migration("20251203033647_Add Audits")] + partial class AddAudits + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FSH.Modules.Auditing.AuditRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("text"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestId") + .HasColumnType("text"); + + b.Property("Severity") + .HasColumnType("smallint"); + + b.Property("Source") + .HasColumnType("text"); + + b.Property("SpanId") + .HasColumnType("text"); + + b.Property("Tags") + .HasColumnType("bigint"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TraceId") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("UserName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("EventType"); + + b.HasIndex("OccurredAtUtc"); + + b.HasIndex("TenantId"); + + b.ToTable("AuditRecords", "audit"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Playground/Migrations.PostgreSQL/Audit/20251203033647_Add Audits.cs b/src/Playground/Migrations.PostgreSQL/Audit/20251203033647_Add Audits.cs new file mode 100644 index 0000000000..cf9def668f --- /dev/null +++ b/src/Playground/Migrations.PostgreSQL/Audit/20251203033647_Add Audits.cs @@ -0,0 +1,70 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FSH.Playground.Migrations.PostgreSQL.Audit +{ + /// + public partial class AddAudits : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "audit"); + + migrationBuilder.CreateTable( + name: "AuditRecords", + schema: "audit", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OccurredAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ReceivedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + EventType = table.Column(type: "integer", nullable: false), + Severity = table.Column(type: "smallint", nullable: false), + TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + UserId = table.Column(type: "text", nullable: true), + UserName = table.Column(type: "text", nullable: true), + TraceId = table.Column(type: "text", nullable: true), + SpanId = table.Column(type: "text", nullable: true), + CorrelationId = table.Column(type: "text", nullable: true), + RequestId = table.Column(type: "text", nullable: true), + Source = table.Column(type: "text", nullable: true), + Tags = table.Column(type: "bigint", nullable: false), + PayloadJson = table.Column(type: "jsonb", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditRecords", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_AuditRecords_EventType", + schema: "audit", + table: "AuditRecords", + column: "EventType"); + + migrationBuilder.CreateIndex( + name: "IX_AuditRecords_OccurredAtUtc", + schema: "audit", + table: "AuditRecords", + column: "OccurredAtUtc"); + + migrationBuilder.CreateIndex( + name: "IX_AuditRecords_TenantId", + schema: "audit", + table: "AuditRecords", + column: "TenantId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AuditRecords", + schema: "audit"); + } + } +} diff --git a/src/Playground/Migrations.PostgreSQL/Audit/AuditDbContextModelSnapshot.cs b/src/Playground/Migrations.PostgreSQL/Audit/AuditDbContextModelSnapshot.cs new file mode 100644 index 0000000000..ce7d9967a8 --- /dev/null +++ b/src/Playground/Migrations.PostgreSQL/Audit/AuditDbContextModelSnapshot.cs @@ -0,0 +1,91 @@ +// +using System; +using FSH.Modules.Auditing.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FSH.Playground.Migrations.PostgreSQL.Audit +{ + [DbContext(typeof(AuditDbContext))] + partial class AuditDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FSH.Modules.Auditing.AuditRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("text"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestId") + .HasColumnType("text"); + + b.Property("Severity") + .HasColumnType("smallint"); + + b.Property("Source") + .HasColumnType("text"); + + b.Property("SpanId") + .HasColumnType("text"); + + b.Property("Tags") + .HasColumnType("bigint"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TraceId") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("UserName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("EventType"); + + b.HasIndex("OccurredAtUtc"); + + b.HasIndex("TenantId"); + + b.ToTable("AuditRecords", "audit"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Playground/Migrations.PostgreSQL/Identity/20251222232937_Initial.Designer.cs b/src/Playground/Migrations.PostgreSQL/Identity/20251222232937_Initial.Designer.cs new file mode 100644 index 0000000000..c1856df683 --- /dev/null +++ b/src/Playground/Migrations.PostgreSQL/Identity/20251222232937_Initial.Designer.cs @@ -0,0 +1,469 @@ +// +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.Playground.Migrations.PostgreSQL.Identity +{ + [DbContext(typeof(IdentityDbContext))] + [Migration("20251222232937_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FSH.Framework.Eventing.Inbox.InboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("HandlerName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TenantId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id", "HandlerName"); + + b.ToTable("InboxMessages", "identity"); + }); + + modelBuilder.Entity("FSH.Framework.Eventing.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDead") + .HasColumnType("boolean"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.ToTable("OutboxMessages", "identity"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Features.v1.RoleClaims.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.Features.v1.Roles.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.Features.v1.Users.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("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.Features.v1.Users.PasswordHistory.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("UserId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "CreatedAt"); + + b.ToTable("PasswordHistory", "identity"); + }); + + 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.Features.v1.RoleClaims.FshRoleClaim", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Roles.FshRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Features.v1.Users.PasswordHistory.PasswordHistory", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Users.FshUser", "User") + .WithMany("PasswordHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Users.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Users.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Roles.FshRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Features.v1.Users.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Users.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Features.v1.Users.FshUser", b => + { + b.Navigation("PasswordHistories"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Playground/Migrations.PostgreSQL/Identity/20251222232937_Initial.cs b/src/Playground/Migrations.PostgreSQL/Identity/20251222232937_Initial.cs new file mode 100644 index 0000000000..7cc964d064 --- /dev/null +++ b/src/Playground/Migrations.PostgreSQL/Identity/20251222232937_Initial.cs @@ -0,0 +1,355 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FSH.Playground.Migrations.PostgreSQL.Identity +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "identity"); + + migrationBuilder.CreateTable( + name: "InboxMessages", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + HandlerName = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + EventType = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + ProcessedOnUtc = table.Column(type: "timestamp with time zone", nullable: false), + TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_InboxMessages", x => new { x.Id, x.HandlerName }); + }); + + migrationBuilder.CreateTable( + name: "OutboxMessages", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CreatedOnUtc = table.Column(type: "timestamp with time zone", nullable: false), + Type = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + Payload = table.Column(type: "text", nullable: false), + TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + CorrelationId = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + ProcessedOnUtc = table.Column(type: "timestamp with time zone", nullable: true), + RetryCount = table.Column(type: "integer", nullable: false), + LastError = table.Column(type: "text", nullable: true), + IsDead = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_OutboxMessages", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Roles", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: true), + TenantId = table.Column(type: "text", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Roles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + FirstName = table.Column(type: "text", nullable: true), + LastName = table.Column(type: "text", nullable: true), + ImageUrl = table.Column(type: "text", nullable: true), + IsActive = table.Column(type: "boolean", nullable: false), + RefreshToken = table.Column(type: "text", nullable: true), + RefreshTokenExpiryTime = table.Column(type: "timestamp with time zone", nullable: false), + ObjectId = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + LastPasswordChangeDate = table.Column(type: "timestamp with time zone", nullable: false), + TenantId = table.Column(type: "text", nullable: false), + UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "boolean", nullable: false), + PasswordHash = table.Column(type: "text", nullable: true), + SecurityStamp = table.Column(type: "text", nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true), + PhoneNumber = table.Column(type: "text", nullable: true), + PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), + TwoFactorEnabled = table.Column(type: "boolean", nullable: false), + LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), + LockoutEnabled = table.Column(type: "boolean", nullable: false), + AccessFailedCount = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RoleClaims", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CreatedBy = table.Column(type: "text", nullable: true), + CreatedOn = table.Column(type: "timestamp with time zone", nullable: false), + TenantId = table.Column(type: "text", nullable: false), + RoleId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_RoleClaims_Roles_RoleId", + column: x => x.RoleId, + principalSchema: "identity", + principalTable: "Roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PasswordHistory", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + PasswordHash = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_PasswordHistory", x => x.Id); + table.ForeignKey( + name: "FK_PasswordHistory_Users_UserId", + column: x => x.UserId, + principalSchema: "identity", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserClaims", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true), + TenantId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserClaims", x => x.Id); + table.ForeignKey( + name: "FK_UserClaims_Users_UserId", + column: x => x.UserId, + principalSchema: "identity", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserLogins", + schema: "identity", + columns: table => new + { + LoginProvider = table.Column(type: "text", nullable: false), + ProviderKey = table.Column(type: "text", nullable: false), + ProviderDisplayName = table.Column(type: "text", nullable: true), + UserId = table.Column(type: "text", nullable: false), + TenantId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_UserLogins_Users_UserId", + column: x => x.UserId, + principalSchema: "identity", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserRoles", + schema: "identity", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + RoleId = table.Column(type: "text", nullable: false), + TenantId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_UserRoles_Roles_RoleId", + column: x => x.RoleId, + principalSchema: "identity", + principalTable: "Roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserRoles_Users_UserId", + column: x => x.UserId, + principalSchema: "identity", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserTokens", + schema: "identity", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + LoginProvider = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Value = table.Column(type: "text", nullable: true), + TenantId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_UserTokens_Users_UserId", + column: x => x.UserId, + principalSchema: "identity", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PasswordHistory_UserId", + schema: "identity", + table: "PasswordHistory", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_PasswordHistory_UserId_CreatedAt", + schema: "identity", + table: "PasswordHistory", + columns: new[] { "UserId", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_RoleClaims_RoleId", + schema: "identity", + table: "RoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + schema: "identity", + table: "Roles", + columns: new[] { "NormalizedName", "TenantId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UserClaims_UserId", + schema: "identity", + table: "UserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_UserLogins_UserId", + schema: "identity", + table: "UserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_UserRoles_RoleId", + schema: "identity", + table: "UserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + schema: "identity", + table: "Users", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + schema: "identity", + table: "Users", + columns: new[] { "NormalizedUserName", "TenantId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "InboxMessages", + schema: "identity"); + + migrationBuilder.DropTable( + name: "OutboxMessages", + schema: "identity"); + + migrationBuilder.DropTable( + name: "PasswordHistory", + schema: "identity"); + + migrationBuilder.DropTable( + name: "RoleClaims", + schema: "identity"); + + migrationBuilder.DropTable( + name: "UserClaims", + schema: "identity"); + + migrationBuilder.DropTable( + name: "UserLogins", + schema: "identity"); + + migrationBuilder.DropTable( + name: "UserRoles", + schema: "identity"); + + migrationBuilder.DropTable( + name: "UserTokens", + schema: "identity"); + + migrationBuilder.DropTable( + name: "Roles", + schema: "identity"); + + migrationBuilder.DropTable( + name: "Users", + schema: "identity"); + } + } +} diff --git a/src/Playground/Migrations.PostgreSQL/Identity/20251223002642_SessionManagement.Designer.cs b/src/Playground/Migrations.PostgreSQL/Identity/20251223002642_SessionManagement.Designer.cs new file mode 100644 index 0000000000..723c74c265 --- /dev/null +++ b/src/Playground/Migrations.PostgreSQL/Identity/20251223002642_SessionManagement.Designer.cs @@ -0,0 +1,562 @@ +// +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.Playground.Migrations.PostgreSQL.Identity +{ + [DbContext(typeof(IdentityDbContext))] + [Migration("20251223002642_SessionManagement")] + partial class SessionManagement + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FSH.Framework.Eventing.Inbox.InboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("HandlerName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TenantId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id", "HandlerName"); + + b.ToTable("InboxMessages", "identity"); + }); + + modelBuilder.Entity("FSH.Framework.Eventing.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDead") + .HasColumnType("boolean"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.ToTable("OutboxMessages", "identity"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Features.v1.RoleClaims.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.Features.v1.Roles.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.Features.v1.Sessions.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("UserAgent") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("RefreshTokenHash"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsRevoked"); + + b.ToTable("UserSessions", "identity"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Features.v1.Users.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("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.Features.v1.Users.PasswordHistory.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("UserId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "CreatedAt"); + + b.ToTable("PasswordHistory", "identity"); + }); + + 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.Features.v1.RoleClaims.FshRoleClaim", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Roles.FshRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Features.v1.Sessions.UserSession", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Users.FshUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Features.v1.Users.PasswordHistory.PasswordHistory", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Users.FshUser", "User") + .WithMany("PasswordHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Users.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Users.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Roles.FshRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Features.v1.Users.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Users.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Features.v1.Users.FshUser", b => + { + b.Navigation("PasswordHistories"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Playground/Migrations.PostgreSQL/Identity/20251223002642_SessionManagement.cs b/src/Playground/Migrations.PostgreSQL/Identity/20251223002642_SessionManagement.cs new file mode 100644 index 0000000000..d94d21747b --- /dev/null +++ b/src/Playground/Migrations.PostgreSQL/Identity/20251223002642_SessionManagement.cs @@ -0,0 +1,76 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FSH.Playground.Migrations.PostgreSQL.Identity +{ + /// + public partial class SessionManagement : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserSessions", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "character varying(450)", maxLength: 450, nullable: false), + RefreshTokenHash = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + IpAddress = table.Column(type: "character varying(45)", maxLength: 45, nullable: false), + UserAgent = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false), + DeviceType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Browser = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + BrowserVersion = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + OperatingSystem = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + OsVersion = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + LastActivityAt = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + IsRevoked = table.Column(type: "boolean", nullable: false), + RevokedAt = table.Column(type: "timestamp with time zone", nullable: true), + RevokedBy = table.Column(type: "character varying(450)", maxLength: 450, nullable: true), + RevokedReason = table.Column(type: "character varying(500)", maxLength: 500, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserSessions", x => x.Id); + table.ForeignKey( + name: "FK_UserSessions_Users_UserId", + column: x => x.UserId, + principalSchema: "identity", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_UserSessions_RefreshTokenHash", + schema: "identity", + table: "UserSessions", + column: "RefreshTokenHash"); + + migrationBuilder.CreateIndex( + name: "IX_UserSessions_UserId", + schema: "identity", + table: "UserSessions", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_UserSessions_UserId_IsRevoked", + schema: "identity", + table: "UserSessions", + columns: new[] { "UserId", "IsRevoked" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserSessions", + schema: "identity"); + } + } +} diff --git a/src/Playground/Migrations.PostgreSQL/Identity/20251223042602_UserGroups.Designer.cs b/src/Playground/Migrations.PostgreSQL/Identity/20251223042602_UserGroups.Designer.cs new file mode 100644 index 0000000000..c4f1590f42 --- /dev/null +++ b/src/Playground/Migrations.PostgreSQL/Identity/20251223042602_UserGroups.Designer.cs @@ -0,0 +1,728 @@ +// +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.Playground.Migrations.PostgreSQL.Identity +{ + [DbContext(typeof(IdentityDbContext))] + [Migration("20251223042602_UserGroups")] + partial class UserGroups + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FSH.Framework.Eventing.Inbox.InboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("HandlerName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TenantId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id", "HandlerName"); + + b.ToTable("InboxMessages", "identity"); + }); + + modelBuilder.Entity("FSH.Framework.Eventing.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDead") + .HasColumnType("boolean"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.ToTable("OutboxMessages", "identity"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Features.v1.Groups.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + 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("ModifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ModifiedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + 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.Features.v1.Groups.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.Features.v1.Groups.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.Features.v1.RoleClaims.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.Features.v1.Roles.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.Features.v1.Sessions.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("UserAgent") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("RefreshTokenHash"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsRevoked"); + + b.ToTable("UserSessions", "identity"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Features.v1.Users.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("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.Features.v1.Users.PasswordHistory.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("UserId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "CreatedAt"); + + b.ToTable("PasswordHistory", "identity"); + }); + + 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.Features.v1.Groups.GroupRole", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Groups.Group", "Group") + .WithMany("GroupRoles") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Features.v1.Roles.FshRole", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Features.v1.Groups.UserGroup", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Groups.Group", "Group") + .WithMany("UserGroups") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Features.v1.Users.FshUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Features.v1.RoleClaims.FshRoleClaim", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Roles.FshRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Features.v1.Sessions.UserSession", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Users.FshUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Features.v1.Users.PasswordHistory.PasswordHistory", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Users.FshUser", "User") + .WithMany("PasswordHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Users.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Users.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Roles.FshRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Features.v1.Users.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Users.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Features.v1.Groups.Group", b => + { + b.Navigation("GroupRoles"); + + b.Navigation("UserGroups"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Features.v1.Users.FshUser", b => + { + b.Navigation("PasswordHistories"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Playground/Migrations.PostgreSQL/Identity/20251223042602_UserGroups.cs b/src/Playground/Migrations.PostgreSQL/Identity/20251223042602_UserGroups.cs new file mode 100644 index 0000000000..105bc5849a --- /dev/null +++ b/src/Playground/Migrations.PostgreSQL/Identity/20251223042602_UserGroups.cs @@ -0,0 +1,155 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FSH.Playground.Migrations.PostgreSQL.Identity +{ + /// + public partial class UserGroups : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Groups", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Description = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: true), + IsDefault = table.Column(type: "boolean", nullable: false), + IsSystemGroup = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + CreatedBy = table.Column(type: "character varying(450)", maxLength: 450, nullable: true), + ModifiedAt = table.Column(type: "timestamp with time zone", nullable: true), + ModifiedBy = table.Column(type: "character varying(450)", maxLength: 450, nullable: true), + IsDeleted = table.Column(type: "boolean", nullable: false), + DeletedOnUtc = table.Column(type: "timestamp with time zone", nullable: true), + DeletedBy = table.Column(type: "character varying(450)", maxLength: 450, nullable: true), + TenantId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Groups", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "GroupRoles", + schema: "identity", + columns: table => new + { + GroupId = table.Column(type: "uuid", nullable: false), + RoleId = table.Column(type: "character varying(450)", maxLength: 450, nullable: false), + TenantId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GroupRoles", x => new { x.GroupId, x.RoleId }); + table.ForeignKey( + name: "FK_GroupRoles_Groups_GroupId", + column: x => x.GroupId, + principalSchema: "identity", + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_GroupRoles_Roles_RoleId", + column: x => x.RoleId, + principalSchema: "identity", + principalTable: "Roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserGroups", + schema: "identity", + columns: table => new + { + UserId = table.Column(type: "character varying(450)", maxLength: 450, nullable: false), + GroupId = table.Column(type: "uuid", nullable: false), + AddedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + AddedBy = table.Column(type: "character varying(450)", maxLength: 450, nullable: true), + TenantId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserGroups", x => new { x.UserId, x.GroupId }); + table.ForeignKey( + name: "FK_UserGroups_Groups_GroupId", + column: x => x.GroupId, + principalSchema: "identity", + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserGroups_Users_UserId", + column: x => x.UserId, + principalSchema: "identity", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_GroupRoles_GroupId", + schema: "identity", + table: "GroupRoles", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "IX_GroupRoles_RoleId", + schema: "identity", + table: "GroupRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "IX_Groups_IsDefault", + schema: "identity", + table: "Groups", + column: "IsDefault"); + + migrationBuilder.CreateIndex( + name: "IX_Groups_IsDeleted", + schema: "identity", + table: "Groups", + column: "IsDeleted"); + + migrationBuilder.CreateIndex( + name: "IX_Groups_Name", + schema: "identity", + table: "Groups", + column: "Name"); + + migrationBuilder.CreateIndex( + name: "IX_UserGroups_GroupId", + schema: "identity", + table: "UserGroups", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "IX_UserGroups_UserId", + schema: "identity", + table: "UserGroups", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "GroupRoles", + schema: "identity"); + + migrationBuilder.DropTable( + name: "UserGroups", + schema: "identity"); + + migrationBuilder.DropTable( + name: "Groups", + schema: "identity"); + } + } +} diff --git a/src/Playground/Migrations.PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs b/src/Playground/Migrations.PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs new file mode 100644 index 0000000000..6d38657b29 --- /dev/null +++ b/src/Playground/Migrations.PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs @@ -0,0 +1,725 @@ +// +using System; +using FSH.Modules.Identity.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FSH.Playground.Migrations.PostgreSQL.Identity +{ + [DbContext(typeof(IdentityDbContext))] + partial class IdentityDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FSH.Framework.Eventing.Inbox.InboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("HandlerName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TenantId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id", "HandlerName"); + + b.ToTable("InboxMessages", "identity"); + }); + + modelBuilder.Entity("FSH.Framework.Eventing.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDead") + .HasColumnType("boolean"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.ToTable("OutboxMessages", "identity"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Features.v1.Groups.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + 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("ModifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ModifiedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + 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.Features.v1.Groups.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.Features.v1.Groups.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.Features.v1.RoleClaims.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.Features.v1.Roles.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.Features.v1.Sessions.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("UserAgent") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("RefreshTokenHash"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsRevoked"); + + b.ToTable("UserSessions", "identity"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Features.v1.Users.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("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.Features.v1.Users.PasswordHistory.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("UserId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "CreatedAt"); + + b.ToTable("PasswordHistory", "identity"); + }); + + 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.Features.v1.Groups.GroupRole", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Groups.Group", "Group") + .WithMany("GroupRoles") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Features.v1.Roles.FshRole", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Features.v1.Groups.UserGroup", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Groups.Group", "Group") + .WithMany("UserGroups") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Features.v1.Users.FshUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Features.v1.RoleClaims.FshRoleClaim", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Roles.FshRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Features.v1.Sessions.UserSession", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Users.FshUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Features.v1.Users.PasswordHistory.PasswordHistory", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Users.FshUser", "User") + .WithMany("PasswordHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Users.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Users.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Roles.FshRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Features.v1.Users.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FSH.Modules.Identity.Features.v1.Users.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Features.v1.Groups.Group", b => + { + b.Navigation("GroupRoles"); + + b.Navigation("UserGroups"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Features.v1.Users.FshUser", b => + { + b.Navigation("PasswordHistories"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Playground/Migrations.PostgreSQL/Migrations.PostgreSQL.csproj b/src/Playground/Migrations.PostgreSQL/Migrations.PostgreSQL.csproj new file mode 100644 index 0000000000..39da8f2199 --- /dev/null +++ b/src/Playground/Migrations.PostgreSQL/Migrations.PostgreSQL.csproj @@ -0,0 +1,21 @@ + + + + FSH.Playground.Migrations.PostgreSQL + FSH.Playground.Migrations.PostgreSQL + false + + + + + + + + + + + + + + + diff --git a/src/Playground/Migrations.PostgreSQL/MultiTenancy/20251203034638_Add Multitenancy.Designer.cs b/src/Playground/Migrations.PostgreSQL/MultiTenancy/20251203034638_Add Multitenancy.Designer.cs new file mode 100644 index 0000000000..5931faf162 --- /dev/null +++ b/src/Playground/Migrations.PostgreSQL/MultiTenancy/20251203034638_Add Multitenancy.Designer.cs @@ -0,0 +1,156 @@ +// +using System; +using FSH.Modules.Multitenancy.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.Playground.Migrations.PostgreSQL.MultiTenancy +{ + [DbContext(typeof(TenantDbContext))] + [Migration("20251203034638_Add Multitenancy")] + partial class AddMultitenancy + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FSH.Framework.Shared.Multitenancy.AppTenantInfo", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AdminEmail") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Issuer") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ValidUpto") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .IsUnique(); + + b.ToTable("Tenants", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioning", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentStep") + .HasColumnType("text"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("JobId") + .HasColumnType("text"); + + b.Property("StartedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TenantProvisionings", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioningStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("ProvisioningId") + .HasColumnType("uuid"); + + b.Property("StartedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Step") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProvisioningId"); + + b.ToTable("TenantProvisioningSteps", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioningStep", b => + { + b.HasOne("FSH.Modules.Multitenancy.Provisioning.TenantProvisioning", "Provisioning") + .WithMany("Steps") + .HasForeignKey("ProvisioningId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provisioning"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioning", b => + { + b.Navigation("Steps"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Playground/Migrations.PostgreSQL/MultiTenancy/20251203034638_Add Multitenancy.cs b/src/Playground/Migrations.PostgreSQL/MultiTenancy/20251203034638_Add Multitenancy.cs new file mode 100644 index 0000000000..8cbe84039a --- /dev/null +++ b/src/Playground/Migrations.PostgreSQL/MultiTenancy/20251203034638_Add Multitenancy.cs @@ -0,0 +1,112 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FSH.Playground.Migrations.PostgreSQL.MultiTenancy +{ + /// + public partial class AddMultitenancy : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "tenant"); + + migrationBuilder.CreateTable( + name: "TenantProvisionings", + schema: "tenant", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "text", nullable: false), + CorrelationId = table.Column(type: "text", nullable: false), + Status = table.Column(type: "integer", nullable: false), + CurrentStep = table.Column(type: "text", nullable: true), + Error = table.Column(type: "text", nullable: true), + JobId = table.Column(type: "text", nullable: true), + CreatedUtc = table.Column(type: "timestamp with time zone", nullable: false), + StartedUtc = table.Column(type: "timestamp with time zone", nullable: true), + CompletedUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TenantProvisionings", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Tenants", + schema: "tenant", + columns: table => new + { + Id = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Identifier = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + ConnectionString = table.Column(type: "text", nullable: false), + AdminEmail = table.Column(type: "text", nullable: false), + IsActive = table.Column(type: "boolean", nullable: false), + ValidUpto = table.Column(type: "timestamp with time zone", nullable: false), + Issuer = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Tenants", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "TenantProvisioningSteps", + schema: "tenant", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ProvisioningId = table.Column(type: "uuid", nullable: false), + Step = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "integer", nullable: false), + Error = table.Column(type: "text", nullable: true), + StartedUtc = table.Column(type: "timestamp with time zone", nullable: true), + CompletedUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TenantProvisioningSteps", x => x.Id); + table.ForeignKey( + name: "FK_TenantProvisioningSteps_TenantProvisionings_ProvisioningId", + column: x => x.ProvisioningId, + principalSchema: "tenant", + principalTable: "TenantProvisionings", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_TenantProvisioningSteps_ProvisioningId", + schema: "tenant", + table: "TenantProvisioningSteps", + column: "ProvisioningId"); + + migrationBuilder.CreateIndex( + name: "IX_Tenants_Identifier", + schema: "tenant", + table: "Tenants", + column: "Identifier", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TenantProvisioningSteps", + schema: "tenant"); + + migrationBuilder.DropTable( + name: "Tenants", + schema: "tenant"); + + migrationBuilder.DropTable( + name: "TenantProvisionings", + schema: "tenant"); + } + } +} diff --git a/src/Playground/Migrations.PostgreSQL/MultiTenancy/20251204082748_Update Multitenancy.Designer.cs b/src/Playground/Migrations.PostgreSQL/MultiTenancy/20251204082748_Update Multitenancy.Designer.cs new file mode 100644 index 0000000000..9d02bd9806 --- /dev/null +++ b/src/Playground/Migrations.PostgreSQL/MultiTenancy/20251204082748_Update Multitenancy.Designer.cs @@ -0,0 +1,154 @@ +// +using System; +using FSH.Modules.Multitenancy.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.Playground.Migrations.PostgreSQL.MultiTenancy +{ + [DbContext(typeof(TenantDbContext))] + [Migration("20251204082748_Update Multitenancy")] + partial class UpdateMultitenancy + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FSH.Framework.Shared.Multitenancy.AppTenantInfo", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AdminEmail") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Issuer") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("ValidUpto") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .IsUnique(); + + b.ToTable("Tenants", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioning", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentStep") + .HasColumnType("text"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("JobId") + .HasColumnType("text"); + + b.Property("StartedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TenantProvisionings", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioningStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("ProvisioningId") + .HasColumnType("uuid"); + + b.Property("StartedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Step") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProvisioningId"); + + b.ToTable("TenantProvisioningSteps", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioningStep", b => + { + b.HasOne("FSH.Modules.Multitenancy.Provisioning.TenantProvisioning", "Provisioning") + .WithMany("Steps") + .HasForeignKey("ProvisioningId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provisioning"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioning", b => + { + b.Navigation("Steps"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Playground/Migrations.PostgreSQL/MultiTenancy/20251204082748_Update Multitenancy.cs b/src/Playground/Migrations.PostgreSQL/MultiTenancy/20251204082748_Update Multitenancy.cs new file mode 100644 index 0000000000..0b04820dd6 --- /dev/null +++ b/src/Playground/Migrations.PostgreSQL/MultiTenancy/20251204082748_Update Multitenancy.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FSH.Playground.Migrations.PostgreSQL.MultiTenancy +{ + /// + public partial class UpdateMultitenancy : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Name", + schema: "tenant", + table: "Tenants", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "Id", + schema: "tenant", + table: "Tenants", + type: "text", + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Name", + schema: "tenant", + table: "Tenants", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Id", + schema: "tenant", + table: "Tenants", + type: "character varying(64)", + maxLength: 64, + nullable: false, + oldClrType: typeof(string), + oldType: "text"); + } + } +} diff --git a/src/Playground/Migrations.PostgreSQL/MultiTenancy/20251212100109_AddTenantTheme.Designer.cs b/src/Playground/Migrations.PostgreSQL/MultiTenancy/20251212100109_AddTenantTheme.Designer.cs new file mode 100644 index 0000000000..fae52c4a0d --- /dev/null +++ b/src/Playground/Migrations.PostgreSQL/MultiTenancy/20251212100109_AddTenantTheme.Designer.cs @@ -0,0 +1,316 @@ +// +using System; +using FSH.Modules.Multitenancy.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.Playground.Migrations.PostgreSQL.MultiTenancy +{ + [DbContext(typeof(TenantDbContext))] + [Migration("20251212100109_AddTenantTheme")] + partial class AddTenantTheme + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FSH.Framework.Shared.Multitenancy.AppTenantInfo", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AdminEmail") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Issuer") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("ValidUpto") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .IsUnique(); + + b.ToTable("Tenants", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Domain.TenantTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("BorderRadius") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DarkBackgroundColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DarkErrorColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DarkInfoColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DarkPrimaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DarkSecondaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DarkSuccessColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DarkSurfaceColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DarkTertiaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DarkWarningColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DefaultElevation") + .HasColumnType("integer"); + + b.Property("ErrorColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("FaviconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("FontFamily") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FontSizeBase") + .HasColumnType("double precision"); + + b.Property("HeadingFontFamily") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("InfoColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("LastModifiedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastModifiedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LineHeightBase") + .HasColumnType("double precision"); + + b.Property("LogoDarkUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("LogoUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("PrimaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("SecondaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("SuccessColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("SurfaceColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TertiaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("WarningColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("TenantThemes", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioning", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentStep") + .HasColumnType("text"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("JobId") + .HasColumnType("text"); + + b.Property("StartedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TenantProvisionings", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioningStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("ProvisioningId") + .HasColumnType("uuid"); + + b.Property("StartedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Step") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProvisioningId"); + + b.ToTable("TenantProvisioningSteps", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioningStep", b => + { + b.HasOne("FSH.Modules.Multitenancy.Provisioning.TenantProvisioning", "Provisioning") + .WithMany("Steps") + .HasForeignKey("ProvisioningId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provisioning"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioning", b => + { + b.Navigation("Steps"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Playground/Migrations.PostgreSQL/MultiTenancy/20251212100109_AddTenantTheme.cs b/src/Playground/Migrations.PostgreSQL/MultiTenancy/20251212100109_AddTenantTheme.cs new file mode 100644 index 0000000000..e4e01e4d26 --- /dev/null +++ b/src/Playground/Migrations.PostgreSQL/MultiTenancy/20251212100109_AddTenantTheme.cs @@ -0,0 +1,75 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FSH.Playground.Migrations.PostgreSQL.MultiTenancy +{ + /// + public partial class AddTenantTheme : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TenantThemes", + schema: "tenant", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + PrimaryColor = table.Column(type: "character varying(9)", maxLength: 9, nullable: false), + SecondaryColor = table.Column(type: "character varying(9)", maxLength: 9, nullable: false), + TertiaryColor = table.Column(type: "character varying(9)", maxLength: 9, nullable: false), + BackgroundColor = table.Column(type: "character varying(9)", maxLength: 9, nullable: false), + SurfaceColor = table.Column(type: "character varying(9)", maxLength: 9, nullable: false), + ErrorColor = table.Column(type: "character varying(9)", maxLength: 9, nullable: false), + WarningColor = table.Column(type: "character varying(9)", maxLength: 9, nullable: false), + SuccessColor = table.Column(type: "character varying(9)", maxLength: 9, nullable: false), + InfoColor = table.Column(type: "character varying(9)", maxLength: 9, nullable: false), + DarkPrimaryColor = table.Column(type: "character varying(9)", maxLength: 9, nullable: false), + DarkSecondaryColor = table.Column(type: "character varying(9)", maxLength: 9, nullable: false), + DarkTertiaryColor = table.Column(type: "character varying(9)", maxLength: 9, nullable: false), + DarkBackgroundColor = table.Column(type: "character varying(9)", maxLength: 9, nullable: false), + DarkSurfaceColor = table.Column(type: "character varying(9)", maxLength: 9, nullable: false), + DarkErrorColor = table.Column(type: "character varying(9)", maxLength: 9, nullable: false), + DarkWarningColor = table.Column(type: "character varying(9)", maxLength: 9, nullable: false), + DarkSuccessColor = table.Column(type: "character varying(9)", maxLength: 9, nullable: false), + DarkInfoColor = table.Column(type: "character varying(9)", maxLength: 9, nullable: false), + LogoUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + LogoDarkUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + FaviconUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + FontFamily = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + HeadingFontFamily = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + FontSizeBase = table.Column(type: "double precision", nullable: false), + LineHeightBase = table.Column(type: "double precision", nullable: false), + BorderRadius = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + DefaultElevation = table.Column(type: "integer", nullable: false), + IsDefault = table.Column(type: "boolean", nullable: false), + CreatedOnUtc = table.Column(type: "timestamp with time zone", nullable: false), + CreatedBy = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + LastModifiedOnUtc = table.Column(type: "timestamp with time zone", nullable: true), + LastModifiedBy = table.Column(type: "character varying(256)", maxLength: 256, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TenantThemes", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_TenantThemes_TenantId", + schema: "tenant", + table: "TenantThemes", + column: "TenantId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TenantThemes", + schema: "tenant"); + } + } +} diff --git a/src/Playground/Migrations.PostgreSQL/MultiTenancy/20251212152839_IncreaseTenantThemeUrlLength.Designer.cs b/src/Playground/Migrations.PostgreSQL/MultiTenancy/20251212152839_IncreaseTenantThemeUrlLength.Designer.cs new file mode 100644 index 0000000000..b2edb39d15 --- /dev/null +++ b/src/Playground/Migrations.PostgreSQL/MultiTenancy/20251212152839_IncreaseTenantThemeUrlLength.Designer.cs @@ -0,0 +1,316 @@ +// +using System; +using FSH.Modules.Multitenancy.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.Playground.Migrations.PostgreSQL.MultiTenancy +{ + [DbContext(typeof(TenantDbContext))] + [Migration("20251212152839_IncreaseTenantThemeUrlLength")] + partial class IncreaseTenantThemeUrlLength + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FSH.Framework.Shared.Multitenancy.AppTenantInfo", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AdminEmail") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Issuer") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("ValidUpto") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .IsUnique(); + + b.ToTable("Tenants", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Domain.TenantTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("BorderRadius") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DarkBackgroundColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DarkErrorColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DarkInfoColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DarkPrimaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DarkSecondaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DarkSuccessColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DarkSurfaceColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DarkTertiaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DarkWarningColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DefaultElevation") + .HasColumnType("integer"); + + b.Property("ErrorColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("FaviconUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FontFamily") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FontSizeBase") + .HasColumnType("double precision"); + + b.Property("HeadingFontFamily") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("InfoColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("LastModifiedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastModifiedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LineHeightBase") + .HasColumnType("double precision"); + + b.Property("LogoDarkUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("LogoUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("PrimaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("SecondaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("SuccessColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("SurfaceColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TertiaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("WarningColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("TenantThemes", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioning", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentStep") + .HasColumnType("text"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("JobId") + .HasColumnType("text"); + + b.Property("StartedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TenantProvisionings", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioningStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("ProvisioningId") + .HasColumnType("uuid"); + + b.Property("StartedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Step") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProvisioningId"); + + b.ToTable("TenantProvisioningSteps", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioningStep", b => + { + b.HasOne("FSH.Modules.Multitenancy.Provisioning.TenantProvisioning", "Provisioning") + .WithMany("Steps") + .HasForeignKey("ProvisioningId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provisioning"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioning", b => + { + b.Navigation("Steps"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Playground/Migrations.PostgreSQL/MultiTenancy/20251212152839_IncreaseTenantThemeUrlLength.cs b/src/Playground/Migrations.PostgreSQL/MultiTenancy/20251212152839_IncreaseTenantThemeUrlLength.cs new file mode 100644 index 0000000000..73fd7fe83a --- /dev/null +++ b/src/Playground/Migrations.PostgreSQL/MultiTenancy/20251212152839_IncreaseTenantThemeUrlLength.cs @@ -0,0 +1,90 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FSH.Playground.Migrations.PostgreSQL.MultiTenancy +{ + /// + public partial class IncreaseTenantThemeUrlLength : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "LogoUrl", + schema: "tenant", + table: "TenantThemes", + type: "character varying(2048)", + maxLength: 2048, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(500)", + oldMaxLength: 500, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "LogoDarkUrl", + schema: "tenant", + table: "TenantThemes", + type: "character varying(2048)", + maxLength: 2048, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(500)", + oldMaxLength: 500, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "FaviconUrl", + schema: "tenant", + table: "TenantThemes", + type: "character varying(2048)", + maxLength: 2048, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(500)", + oldMaxLength: 500, + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "LogoUrl", + schema: "tenant", + table: "TenantThemes", + type: "character varying(500)", + maxLength: 500, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(2048)", + oldMaxLength: 2048, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "LogoDarkUrl", + schema: "tenant", + table: "TenantThemes", + type: "character varying(500)", + maxLength: 500, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(2048)", + oldMaxLength: 2048, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "FaviconUrl", + schema: "tenant", + table: "TenantThemes", + type: "character varying(500)", + maxLength: 500, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(2048)", + oldMaxLength: 2048, + oldNullable: true); + } + } +} diff --git a/src/Playground/Migrations.PostgreSQL/MultiTenancy/TenantDbContextModelSnapshot.cs b/src/Playground/Migrations.PostgreSQL/MultiTenancy/TenantDbContextModelSnapshot.cs new file mode 100644 index 0000000000..726512635e --- /dev/null +++ b/src/Playground/Migrations.PostgreSQL/MultiTenancy/TenantDbContextModelSnapshot.cs @@ -0,0 +1,313 @@ +// +using System; +using FSH.Modules.Multitenancy.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FSH.Playground.Migrations.PostgreSQL.MultiTenancy +{ + [DbContext(typeof(TenantDbContext))] + partial class TenantDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FSH.Framework.Shared.Multitenancy.AppTenantInfo", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AdminEmail") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Issuer") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("ValidUpto") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .IsUnique(); + + b.ToTable("Tenants", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Domain.TenantTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("BorderRadius") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DarkBackgroundColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DarkErrorColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DarkInfoColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DarkPrimaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DarkSecondaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DarkSuccessColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DarkSurfaceColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DarkTertiaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DarkWarningColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("DefaultElevation") + .HasColumnType("integer"); + + b.Property("ErrorColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("FaviconUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FontFamily") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FontSizeBase") + .HasColumnType("double precision"); + + b.Property("HeadingFontFamily") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("InfoColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("LastModifiedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastModifiedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LineHeightBase") + .HasColumnType("double precision"); + + b.Property("LogoDarkUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("LogoUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("PrimaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("SecondaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("SuccessColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("SurfaceColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TertiaryColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("WarningColor") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("TenantThemes", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioning", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentStep") + .HasColumnType("text"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("JobId") + .HasColumnType("text"); + + b.Property("StartedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TenantProvisionings", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioningStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("ProvisioningId") + .HasColumnType("uuid"); + + b.Property("StartedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Step") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProvisioningId"); + + b.ToTable("TenantProvisioningSteps", "tenant"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioningStep", b => + { + b.HasOne("FSH.Modules.Multitenancy.Provisioning.TenantProvisioning", "Provisioning") + .WithMany("Steps") + .HasForeignKey("ProvisioningId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provisioning"); + }); + + modelBuilder.Entity("FSH.Modules.Multitenancy.Provisioning.TenantProvisioning", b => + { + b.Navigation("Steps"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Playground/Playground.Api/Playground.Api.csproj b/src/Playground/Playground.Api/Playground.Api.csproj new file mode 100644 index 0000000000..31190473d7 --- /dev/null +++ b/src/Playground/Playground.Api/Playground.Api.csproj @@ -0,0 +1,40 @@ + + + + FSH.Playground.Api + FSH.Playground.Api + false + + + 8080 + root + fsh-playground-api + DefaultContainer + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + diff --git a/src/Playground/Playground.Api/Playground.Api.http b/src/Playground/Playground.Api/Playground.Api.http new file mode 100644 index 0000000000..959a8cbe38 --- /dev/null +++ b/src/Playground/Playground.Api/Playground.Api.http @@ -0,0 +1,6 @@ +@Playground.Api_HostAddress = http://localhost:5014 + +GET {{Playground.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/src/Playground/Playground.Api/Program.cs b/src/Playground/Playground.Api/Program.cs new file mode 100644 index 0000000000..01c1cbfde2 --- /dev/null +++ b/src/Playground/Playground.Api/Program.cs @@ -0,0 +1,69 @@ +using FSH.Framework.Web; +using FSH.Framework.Web.Modules; +using FSH.Modules.Auditing; +using FSH.Modules.Identity; +using FSH.Modules.Identity.Contracts.v1.Tokens.TokenGeneration; +using FSH.Modules.Identity.Features.v1.Tokens.TokenGeneration; +using FSH.Modules.Multitenancy; +using FSH.Modules.Multitenancy.Contracts.v1.GetTenantStatus; +using FSH.Modules.Multitenancy.Features.v1.GetTenantStatus; +using System.Reflection; + +var builder = WebApplication.CreateBuilder(args); + +if (builder.Environment.IsProduction()) +{ + static void Require(IConfiguration config, string key) + { + if (string.IsNullOrWhiteSpace(config[key])) + { + throw new InvalidOperationException($"Missing required configuration '{key}' in Production."); + } + } + + var config = builder.Configuration; + Require(config, "DatabaseOptions:ConnectionString"); + Require(config, "CachingOptions:Redis"); + Require(config, "JwtOptions:SigningKey"); +} + +builder.Services.AddMediator(o => +{ + o.ServiceLifetime = ServiceLifetime.Scoped; + o.Assemblies = [ + typeof(GenerateTokenCommand), + typeof(GenerateTokenCommandHandler), + typeof(GetTenantStatusQuery), + typeof(GetTenantStatusQueryHandler), + typeof(FSH.Modules.Auditing.Contracts.AuditEnvelope), + typeof(FSH.Modules.Auditing.Persistence.AuditDbContext)]; +}); + +var moduleAssemblies = new Assembly[] +{ + typeof(IdentityModule).Assembly, + typeof(MultitenancyModule).Assembly, + typeof(AuditingModule).Assembly +}; + +builder.AddHeroPlatform(o => +{ + o.EnableCaching = true; + o.EnableMailing = true; + o.EnableJobs = true; +}); + +builder.AddModules(moduleAssemblies); +var app = builder.Build(); + +app.UseHeroMultiTenantDatabases(); +app.UseHeroPlatform(p => +{ + p.MapModules = true; + p.ServeStaticFiles = true; +}); + +app.MapGet("/", () => Results.Ok(new { message = "hello world!" })) + .WithTags("PlayGround") + .AllowAnonymous(); +await app.RunAsync(); diff --git a/src/Playground/Playground.Api/Properties/launchSettings.json b/src/Playground/Playground.Api/Properties/launchSettings.json new file mode 100644 index 0000000000..e3543d899a --- /dev/null +++ b/src/Playground/Playground.Api/Properties/launchSettings.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5030", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "scalar", + "applicationUrl": "https://localhost:7030;http://localhost:5030", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Playground/Playground.Api/Requests/Identity/identity-roles.http b/src/Playground/Playground.Api/Requests/Identity/identity-roles.http new file mode 100644 index 0000000000..898b501e4b --- /dev/null +++ b/src/Playground/Playground.Api/Requests/Identity/identity-roles.http @@ -0,0 +1,53 @@ +@baseUrl = https://localhost:7030 +@tenant = root +@token = {{access_token}} + +### List roles +GET {{baseUrl}}/api/v1/identity/roles +Accept: application/json +tenant: {{tenant}} +Authorization: Bearer {{token}} + +### Get role by id +GET {{baseUrl}}/api/v1/identity/roles/{{roleId}} +Accept: application/json +tenant: {{tenant}} +Authorization: Bearer {{token}} + +### Get role with permissions +GET {{baseUrl}}/api/v1/identity/roles/{{roleId}}/permissions +Accept: application/json +tenant: {{tenant}} +Authorization: Bearer {{token}} + +### Create or update role +POST {{baseUrl}}/api/v1/identity +Content-Type: application/json +tenant: {{tenant}} +Authorization: Bearer {{token}} + +{ + "id": "{{roleId}}", + "name": "Administrators", + "description": "Administrator role" +} + +### Update role permissions +PUT {{baseUrl}}/api/v1/identity/{{roleId}}/permissions +Content-Type: application/json +tenant: {{tenant}} +Authorization: Bearer {{token}} + +{ + "roleId": "{{roleId}}", + "permissions": [ + "Permissions.Users.View", + "Permissions.Users.Create" + ] +} + +### Delete role +DELETE {{baseUrl}}/api/v1/identity/roles/{{roleId}} +tenant: {{tenant}} +Authorization: Bearer {{token}} + diff --git a/src/Playground/Playground.Api/Requests/Identity/identity-token.http b/src/Playground/Playground.Api/Requests/Identity/identity-token.http new file mode 100644 index 0000000000..bb162a44b8 --- /dev/null +++ b/src/Playground/Playground.Api/Requests/Identity/identity-token.http @@ -0,0 +1,13 @@ +@baseUrl = https://localhost:7030 +@tenant = root + +### Issue JWT token +POST {{baseUrl}}/api/v1/identity/token +Content-Type: application/json +tenant: {{tenant}} + +{ + "email": "admin@root.com", + "password": "123Pa$$word!" +} + diff --git a/src/Playground/Playground.Api/Requests/Identity/identity-users.http b/src/Playground/Playground.Api/Requests/Identity/identity-users.http new file mode 100644 index 0000000000..b937d16150 --- /dev/null +++ b/src/Playground/Playground.Api/Requests/Identity/identity-users.http @@ -0,0 +1,145 @@ +@baseUrl = https://localhost:7030 +@tenant = root +@token = {{access_token}} + +### List users +GET {{baseUrl}}/api/v1/identity/users +Accept: application/json +tenant: {{tenant}} +Authorization: Bearer {{token}} + +### Get user by id +GET {{baseUrl}}/api/v1/identity/{{userId}} +Accept: application/json +tenant: {{tenant}} +Authorization: Bearer {{token}} + +### Get current user profile +GET {{baseUrl}}/api/v1/identity/profile +Accept: application/json +tenant: {{tenant}} +Authorization: Bearer {{token}} + +### Get current user permissions +GET {{baseUrl}}/api/v1/identity/permissions +Accept: application/json +tenant: {{tenant}} +Authorization: Bearer {{token}} + +### Register user (admin-driven) +POST {{baseUrl}}/api/v1/identity/register +Content-Type: application/json +tenant: {{tenant}} +Authorization: Bearer {{token}} + +{ + "firstName": "Jane", + "lastName": "Doe", + "email": "jane.doe@example.com", + "userName": "jane.doe", + "password": "P@ssword123", + "confirmPassword": "P@ssword123", + "phoneNumber": "+1-555-0000" +} + +### Self-register user +POST {{baseUrl}}/api/v1/identity/self-register +Content-Type: application/json +tenant: {{tenant}} + +{ + "firstName": "John", + "lastName": "SelfService", + "email": "john.self@example.com", + "userName": "john.self", + "password": "P@ssword123", + "confirmPassword": "P@ssword123", + "phoneNumber": "+1-555-0001" +} + +### Change password for current user +POST {{baseUrl}}/api/v1/identity/change-password +Content-Type: application/json +tenant: {{tenant}} +Authorization: Bearer {{token}} + +{ + "password": "P@ssword123", + "newPassword": "N3wP@ssword123", + "confirmNewPassword": "N3wP@ssword123" +} + +### Forgot password +POST {{baseUrl}}/api/v1/identity/forgot-password +Content-Type: application/json +tenant: {{tenant}} + +{ + "email": "jane.doe@example.com" +} + +### Reset password +POST {{baseUrl}}/api/v1/identity/reset-password +Content-Type: application/json +tenant: {{tenant}} + +{ + "email": "jane.doe@example.com", + "password": "N3wP@ssword123", + "token": "{{resetTokenFromEmail}}" +} + +### Confirm email +GET {{baseUrl}}/api/v1/identity/confirm-email?userId={{userId}}&code={{code}}&tenant={{tenant}} +Accept: application/json + +### Assign roles to user +POST {{baseUrl}}/api/v1/identity/{{userId}}/roles +Content-Type: application/json +tenant: {{tenant}} +Authorization: Bearer {{token}} + +{ + "userId": "{{userId}}", + "userRoles": [ + { "roleId": "{{roleId}}", "roleName": "Administrators" } + ] +} + +### Get roles for user +GET {{baseUrl}}/api/v1/identity/{{userId}}/roles +Accept: application/json +tenant: {{tenant}} +Authorization: Bearer {{token}} + +### Update user profile +PUT {{baseUrl}}/api/v1/identity/profile +Content-Type: application/json +tenant: {{tenant}} +Authorization: Bearer {{token}} + +{ + "id": "{{userId}}", + "firstName": "Jane", + "lastName": "Doe", + "phoneNumber": "+1-555-9999", + "email": "jane.doe@example.com", + "deleteCurrentImage": false +} + +### Toggle user status (activate/deactivate) +POST {{baseUrl}}/api/v1/identity/{{userId}}/toggle-status +Content-Type: application/json +tenant: {{tenant}} +Authorization: Bearer {{token}} + +{ + "activateUser": true, + "userId": "{{userId}}" +} + +### Delete user +DELETE {{baseUrl}}/api/v1/identity/{{userId}} +tenant: {{tenant}} +Authorization: Bearer {{token}} + diff --git a/src/Playground/Playground.Api/Requests/Multitenancy/tenants.http b/src/Playground/Playground.Api/Requests/Multitenancy/tenants.http new file mode 100644 index 0000000000..7650fe927c --- /dev/null +++ b/src/Playground/Playground.Api/Requests/Multitenancy/tenants.http @@ -0,0 +1,57 @@ +@baseUrl = https://localhost:7030 +@tenant = root +@token = {{access_token}} + +### List tenants +GET {{baseUrl}}/api/v1/tenants +Accept: application/json +tenant: {{tenant}} +Authorization: Bearer {{token}} + +### Get tenant by id +GET {{baseUrl}}/api/v1/tenants/{{tenantId}} +Accept: application/json +tenant: {{tenant}} +Authorization: Bearer {{token}} + +### Get tenant status +GET {{baseUrl}}/api/v1/tenants/{{tenantId}}/status +Accept: application/json +tenant: {{tenant}} +Authorization: Bearer {{token}} + +### Create tenant +POST {{baseUrl}}/api/v1/tenants +Content-Type: application/json +tenant: {{tenant}} +Authorization: Bearer {{token}} + +{ + "id": "demo", + "name": "Demo tenant", + "connectionString": "", + "adminEmail": "admin@demo.example.com", + "issuer": "https://demo.example.com" +} + +### Activate tenant +POST {{baseUrl}}/api/v1/tenants/{{tenantId}}/activate +tenant: {{tenant}} +Authorization: Bearer {{token}} + +### Deactivate tenant +POST {{baseUrl}}/api/v1/tenants/{{tenantId}}/deactivate +tenant: {{tenant}} +Authorization: Bearer {{token}} + +### Upgrade tenant subscription +POST {{baseUrl}}/api/v1/tenants/upgrade +Content-Type: application/json +tenant: {{tenant}} +Authorization: Bearer {{token}} + +{ + "tenant": "{{tenantId}}", + "extendedExpiryDate": "2030-01-01T00:00:00Z" +} + diff --git a/src/Playground/Playground.Api/Requests/root-and-health.http b/src/Playground/Playground.Api/Requests/root-and-health.http new file mode 100644 index 0000000000..3930483b0b --- /dev/null +++ b/src/Playground/Playground.Api/Requests/root-and-health.http @@ -0,0 +1,14 @@ +@baseUrl = https://localhost:7030 + +### Root hello +GET {{baseUrl}}/ +Accept: application/json + +### Liveness probe +GET {{baseUrl}}/health/live +Accept: application/json + +### Readiness probe (includes DB checks) +GET {{baseUrl}}/health/ready +Accept: application/json + diff --git a/src/Playground/Playground.Api/appsettings.Development.json b/src/Playground/Playground.Api/appsettings.Development.json new file mode 100644 index 0000000000..d86243e6f4 --- /dev/null +++ b/src/Playground/Playground.Api/appsettings.Development.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "CorsOptions": { + "AllowAll": true + }, + "OpenApiOptions": { + "Enabled": true + } +} diff --git a/src/Playground/Playground.Api/appsettings.Production.json b/src/Playground/Playground.Api/appsettings.Production.json new file mode 100644 index 0000000000..5ad5069700 --- /dev/null +++ b/src/Playground/Playground.Api/appsettings.Production.json @@ -0,0 +1,98 @@ +{ + "OpenTelemetryOptions": { + "Enabled": true, + "Tracing": { "Enabled": true }, + "Metrics": { "Enabled": true }, + "Exporter": { + "Otlp": { + "Enabled": false, + "Endpoint": "", + "Protocol": "grpc" + } + }, + "Jobs": { "Enabled": true }, + "Mediator": { "Enabled": true }, + "Http": { "Histograms": { "Enabled": true } }, + "Data": { "FilterEfStatements": true, "FilterRedisCommands": true } + }, + "Serilog": { + "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.OpenTelemetry" ], + "Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId", "WithCorrelationId", "WithProcessId", "WithProcessName" ], + "MinimumLevel": { "Default": "Information" }, + "WriteTo": [ + { "Name": "Console", "Args": { "restrictedToMinimumLevel": "Information" } }, + { "Name": "OpenTelemetry", "Args": { "endpoint": "", "protocol": "grpc", "resourceAttributes": { "service.name": "Playground.Api" } } } + ] + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Hangfire": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "DatabaseOptions": { + "Provider": "POSTGRESQL", + "ConnectionString": "" + }, + "OriginOptions": { + "OriginUrl": "" + }, + "CachingOptions": { + "Redis": "" + }, + "HangfireOptions": { + "Username": "", + "Password": "", + "Route": "/jobs" + }, + "AllowedHosts": "api.example.com", + "OpenApiOptions": { + "Enabled": false, + "Title": "FSH PlayGround API", + "Version": "v1", + "Description": "The FSH Starter Kit API for Modular/Multitenant Architecture.", + "Contact": { "Name": "Mukesh Murugan", "Url": "https://codewithmukesh.com", "Email": "mukesh@codewithmukesh.com" }, + "License": { "Name": "MIT License", "Url": "https://opensource.org/licenses/MIT" } + }, + "CorsOptions": { + "AllowAll": false, + "AllowedOrigins": [], + "AllowedHeaders": [ "content-type", "authorization" ], + "AllowedMethods": [ "GET", "POST", "PUT", "DELETE" ] + }, + "JwtOptions": { + "Issuer": "fsh.local", + "Audience": "fsh.clients", + "SigningKey": "", + "AccessTokenMinutes": 60, + "RefreshTokenDays": 7 + }, + "SecurityHeadersOptions": { + "Enabled": true, + "ExcludedPaths": [ "/scalar", "/openapi" ], + "AllowInlineStyles": true, + "ScriptSources": [], + "StyleSources": [] + }, + "MailOptions": { + "From": "", + "Host": "", + "Port": 0, + "UserName": "", + "Password": "", + "DisplayName": "" + }, + "RateLimitingOptions": { + "Enabled": true, + "Global": { "PermitLimit": 100, "WindowSeconds": 60, "QueueLimit": 0 }, + "Auth": { "PermitLimit": 10, "WindowSeconds": 60, "QueueLimit": 0 } + }, + "MultitenancyOptions": { + "RunTenantMigrationsOnStartup": false + }, + "Storage": { + "Provider": "local" + } +} diff --git a/src/Playground/Playground.Api/appsettings.json b/src/Playground/Playground.Api/appsettings.json new file mode 100644 index 0000000000..d3f33a548a --- /dev/null +++ b/src/Playground/Playground.Api/appsettings.json @@ -0,0 +1,154 @@ +{ + "OpenTelemetryOptions": { + "Enabled": true, + "Tracing": { + "Enabled": true + }, + "Metrics": { + "Enabled": true, + "MeterNames": [ "FSH.Modules.Identity", "FSH.Modules.Multitenancy", "FSH.Modules.Auditing" ] + }, + "Exporter": { + "Otlp": { + "Enabled": true, + "Endpoint": "http://localhost:4317", + "Protocol": "grpc" + } + }, + "Jobs": { "Enabled": true }, + "Mediator": { "Enabled": true }, + "Http": { + "Histograms": { + "Enabled": true + } + }, + "Data": { + "FilterEfStatements": true, + "FilterRedisCommands": true + } + }, + "Serilog": { + "Using": [ + "Serilog.Sinks.Console", + "Serilog.Sinks.OpenTelemetry" + ], + "Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId", "WithCorrelationId", "WithProcessId", "WithProcessName" ], + "MinimumLevel": { + "Default": "Debug" + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "restrictedToMinimumLevel": "Information" + } + }, + { + "Name": "OpenTelemetry", + "Args": { + "endpoint": "http://localhost:4317", + "protocol": "grpc", + "resourceAttributes": { + "service.name": "Playground.Api" + } + } + } + ] + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Hangfire": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "DatabaseOptions": { + "Provider": "POSTGRESQL", + "ConnectionString": "Server=localhost;Database=fsh;User Id=postgres;Password=password", + "MigrationsAssembly": "FSH.Playground.Migrations.PostgreSQL" + }, + "OriginOptions": { + "OriginUrl": "https://localhost:7030" + }, + "CachingOptions": { + "Redis": "" + }, + "HangfireOptions": { + "Username": "admin", + "Password": "Secure1234!Me", + "Route": "/jobs" + }, + "PasswordPolicy": { + "PasswordHistoryCount": 5, + "PasswordExpiryDays": 90, + "PasswordExpiryWarningDays": 14, + "EnforcePasswordExpiry": true + }, + "AllowedHosts": "*", + "OpenApiOptions": { + "Enabled": true, + "Title": "FSH PlayGround API", + "Version": "v1", + "Description": "The FSH Starter Kit API for Modular/Multitenant Architecture.", + "Contact": { + "Name": "Mukesh Murugan", + "Url": "https://codewithmukesh.com", + "Email": "mukesh@codewithmukesh.com" + }, + "License": { + "Name": "MIT License", + "Url": "https://opensource.org/licenses/MIT" + } + }, + "CorsOptions": { + "AllowAll": false, + "AllowedOrigins": [ + "https://localhost:4200", + "https://localhost:7140" + ], + "AllowedHeaders": [ "content-type", "authorization" ], + "AllowedMethods": [ "GET", "POST", "PUT", "DELETE" ] + }, + "JwtOptions": { + "Issuer": "fsh.local", + "Audience": "fsh.clients", + "SigningKey": "replace-with-256-bit-secret-min-32-chars", + "AccessTokenMinutes": 2, + "RefreshTokenDays": 7 + }, + "SecurityHeadersOptions": { + "Enabled": true, + "ExcludedPaths": [ "/scalar", "/openapi" ], + "AllowInlineStyles": true, + "ScriptSources": [], + "StyleSources": [] + }, + "MailOptions": { + "From": "mukesh@fullstackhero.net", + "Host": "smtp.ethereal.email", + "Port": 587, + "UserName": "anderson22@ethereal.email", + "Password": "rqD44sq5P6U2UDCqD1", + "DisplayName": "Mukesh Murugan" + }, + "RateLimitingOptions": { + "Enabled": false, + "Global": { + "PermitLimit": 100, + "WindowSeconds": 60, + "QueueLimit": 0 + }, + "Auth": { + "PermitLimit": 10, + "WindowSeconds": 60, + "QueueLimit": 0 + } + }, + "MultitenancyOptions": { + "RunTenantMigrationsOnStartup": true + }, + "Storage": { + "Provider": "local" + } +} diff --git a/src/Playground/Playground.Blazor/ApiClient/Generated.cs b/src/Playground/Playground.Blazor/ApiClient/Generated.cs new file mode 100644 index 0000000000..43f0f8ba1d --- /dev/null +++ b/src/Playground/Playground.Blazor/ApiClient/Generated.cs @@ -0,0 +1,9260 @@ +//---------------------- +// +// Generated using the NSwag toolchain v14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org) +// +//---------------------- + +#pragma warning disable 108 // Disable "CS0108 '{derivedDto}.ToJson()' hides inherited member '{dtoBase}.ToJson()'. Use the new keyword if hiding was intended." +#pragma warning disable 114 // Disable "CS0114 '{derivedDto}.RaisePropertyChanged(String)' hides inherited member 'dtoBase.RaisePropertyChanged(String)'. To make the current member override that implementation, add the override keyword. Otherwise add the new keyword." +#pragma warning disable 472 // Disable "CS0472 The result of the expression is always 'false' since a value of type 'Int32' is never equal to 'null' of type 'Int32?' +#pragma warning disable 612 // Disable "CS0612 '...' is obsolete" +#pragma warning disable 649 // Disable "CS0649 Field is never assigned to, and will always have its default value null" +#pragma warning disable 1573 // Disable "CS1573 Parameter '...' has no matching param tag in the XML comment for ... +#pragma warning disable 1591 // Disable "CS1591 Missing XML comment for publicly visible type or member ..." +#pragma warning disable 8073 // Disable "CS8073 The result of the expression is always 'false' since a value of type 'T' is never equal to 'null' of type 'T?'" +#pragma warning disable 3016 // Disable "CS3016 Arrays as attribute arguments is not CLS-compliant" +#pragma warning disable 8600 // Disable "CS8600 Converting null literal or possible null value to non-nullable type" +#pragma warning disable 8602 // Disable "CS8602 Dereference of a possibly null reference" +#pragma warning disable 8603 // Disable "CS8603 Possible null reference return" +#pragma warning disable 8604 // Disable "CS8604 Possible null reference argument for parameter" +#pragma warning disable 8625 // Disable "CS8625 Cannot convert null literal to non-nullable reference type" +#pragma warning disable 8765 // Disable "CS8765 Nullability of type of parameter doesn't match overridden member (possibly because of nullability attributes)." + +namespace FSH.Playground.Blazor.ApiClient +{ + using System = global::System; + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial interface ITokenClient + { + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Issue JWT access and refresh tokens + /// + /// + /// Submit credentials to receive a JWT access token and a refresh token. Provide the 'tenant' header to select the tenant context (defaults to 'root'). + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task IssueAsync(string tenant, GenerateTokenCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Refresh JWT access and refresh tokens + /// + /// + /// Use a valid (possibly expired) access token together with a valid refresh token to obtain a new access token and a rotated refresh token. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task RefreshAsync(string tenant, RefreshTokenCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class TokenClient : ITokenClient + { + private System.Net.Http.HttpClient _httpClient; + private static System.Lazy _settings = new System.Lazy(CreateSerializerSettings, true); + private System.Text.Json.JsonSerializerOptions _instanceSettings; + + #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public TokenClient(System.Net.Http.HttpClient httpClient) + #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + { + _httpClient = httpClient; + Initialize(); + } + + private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() + { + var settings = new System.Text.Json.JsonSerializerOptions(); + UpdateJsonSerializerSettings(settings); + return settings; + } + + protected System.Text.Json.JsonSerializerOptions JsonSerializerSettings { get { return _instanceSettings ?? _settings.Value; } } + + static partial void UpdateJsonSerializerSettings(System.Text.Json.JsonSerializerOptions settings); + + partial void Initialize(); + + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, string url); + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, System.Text.StringBuilder urlBuilder); + partial void ProcessResponse(System.Net.Http.HttpClient client, System.Net.Http.HttpResponseMessage response); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Issue JWT access and refresh tokens + /// + /// + /// Submit credentials to receive a JWT access token and a refresh token. Provide the 'tenant' header to select the tenant context (defaults to 'root'). + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task IssueAsync(string tenant, GenerateTokenCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (body == null) + throw new System.ArgumentNullException("body"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + + if (tenant == null) + throw new System.ArgumentNullException("tenant"); + request_.Headers.TryAddWithoutValidation("tenant", ConvertToString(tenant, System.Globalization.CultureInfo.InvariantCulture)); + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/token/issue" + urlBuilder_.Append("api/v1/identity/token/issue"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 400) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Bad Request", status_, responseText_, headers_, null); + } + else + if (status_ == 401) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Unauthorized", status_, responseText_, headers_, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Refresh JWT access and refresh tokens + /// + /// + /// Use a valid (possibly expired) access token together with a valid refresh token to obtain a new access token and a rotated refresh token. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task RefreshAsync(string tenant, RefreshTokenCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (body == null) + throw new System.ArgumentNullException("body"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + + if (tenant == null) + throw new System.ArgumentNullException("tenant"); + request_.Headers.TryAddWithoutValidation("tenant", ConvertToString(tenant, System.Globalization.CultureInfo.InvariantCulture)); + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/token/refresh" + urlBuilder_.Append("api/v1/identity/token/refresh"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 400) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Bad Request", status_, responseText_, headers_, null); + } + else + if (status_ == 401) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Unauthorized", status_, responseText_, headers_, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + protected struct ObjectResponseResult + { + public ObjectResponseResult(T responseObject, string responseText) + { + this.Object = responseObject; + this.Text = responseText; + } + + public T Object { get; } + + public string Text { get; } + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStringAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStringAsync(cancellationToken); + #else + return content.ReadAsStringAsync(); + #endif + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStreamAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStreamAsync(cancellationToken); + #else + return content.ReadAsStreamAsync(); + #endif + } + + public bool ReadResponseAsString { get; set; } + + protected virtual async System.Threading.Tasks.Task> ReadObjectResponseAsync(System.Net.Http.HttpResponseMessage response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Threading.CancellationToken cancellationToken) + { + if (response == null || response.Content == null) + { + return new ObjectResponseResult(default(T), string.Empty); + } + + if (ReadResponseAsString) + { + var responseText = await ReadAsStringAsync(response.Content, cancellationToken).ConfigureAwait(false); + try + { + var typedBody = System.Text.Json.JsonSerializer.Deserialize(responseText, JsonSerializerSettings); + return new ObjectResponseResult(typedBody, responseText); + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body string as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, responseText, headers, exception); + } + } + else + { + try + { + using (var responseStream = await ReadAsStreamAsync(response.Content, cancellationToken).ConfigureAwait(false)) + { + var typedBody = await System.Text.Json.JsonSerializer.DeserializeAsync(responseStream, JsonSerializerSettings, cancellationToken).ConfigureAwait(false); + return new ObjectResponseResult(typedBody, string.Empty); + } + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body stream as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, string.Empty, headers, exception); + } + } + } + + private string ConvertToString(object value, System.Globalization.CultureInfo cultureInfo) + { + if (value == null) + { + return ""; + } + + if (value is System.Enum) + { + var name = System.Enum.GetName(value.GetType(), value); + if (name != null) + { + var field_ = System.Reflection.IntrospectionExtensions.GetTypeInfo(value.GetType()).GetDeclaredField(name); + if (field_ != null) + { + var attribute = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(field_, typeof(System.Runtime.Serialization.EnumMemberAttribute)) + as System.Runtime.Serialization.EnumMemberAttribute; + if (attribute != null) + { + return attribute.Value != null ? attribute.Value : name; + } + } + + var converted = System.Convert.ToString(System.Convert.ChangeType(value, System.Enum.GetUnderlyingType(value.GetType()), cultureInfo)); + return converted == null ? string.Empty : converted; + } + } + else if (value is bool) + { + return System.Convert.ToString((bool)value, cultureInfo).ToLowerInvariant(); + } + else if (value is byte[]) + { + return System.Convert.ToBase64String((byte[]) value); + } + else if (value is string[]) + { + return string.Join(",", (string[])value); + } + else if (value.GetType().IsArray) + { + var valueArray = (System.Array)value; + var valueTextArray = new string[valueArray.Length]; + for (var i = 0; i < valueArray.Length; i++) + { + valueTextArray[i] = ConvertToString(valueArray.GetValue(i), cultureInfo); + } + return string.Join(",", valueTextArray); + } + + var result = System.Convert.ToString(value, cultureInfo); + return result == null ? "" : result; + } + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial interface IIdentityClient + { + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// List all roles + /// + /// + /// Retrieve all roles available for the current tenant. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task> RolesGetAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Create or update role + /// + /// + /// Create a new role or update an existing role's name and description. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task RolesPostAsync(UpsertRoleCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get role by ID + /// + /// + /// Retrieve details of a specific role by its unique identifier. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task RolesGetAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Delete role by ID + /// + /// + /// Remove an existing role by its unique identifier. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task RolesDeleteAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get role permissions + /// + /// + /// Retrieve a role along with its assigned permissions. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task PermissionsGetAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Update role permissions + /// + /// + /// Replace the set of permissions assigned to a role. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task PermissionsPutAsync(string id, UpdatePermissionsCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Change password + /// + /// + /// Change the current user's password. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task ChangePasswordAsync(ChangePasswordCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Confirm user email + /// + /// + /// Confirm a user's email address. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task ConfirmEmailAsync(string userId, string code, string tenant, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Delete user + /// + /// + /// Delete a user by unique identifier. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task UsersDeleteAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get user by ID + /// + /// + /// Retrieve a user's profile details by unique user identifier. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task UsersGetAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Toggle user status + /// + /// + /// Activate or deactivate a user account. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task UsersPatchAsync(System.Guid id, ToggleUserStatusCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get current user permissions + /// + /// + /// Retrieve permissions for the authenticated user. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task> PermissionsGetAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get current user profile + /// + /// + /// Retrieve the authenticated user's profile from the access token. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task ProfileGetAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Update user profile + /// + /// + /// Update profile details for the authenticated user. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task ProfilePutAsync(UpdateUserCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// List users + /// + /// + /// Retrieve a list of users for the current tenant. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task> UsersGetAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Register user + /// + /// + /// Create a new user account. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task RegisterAsync(RegisterUserCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Reset password + /// + /// + /// Reset the user's password using the provided verification token. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task ResetPasswordAsync(string tenant, ResetPasswordCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Self register user + /// + /// + /// Allow a user to self-register. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task SelfRegisterAsync(string tenant, RegisterUserCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Revoke a session + /// + /// + /// Revoke a specific session for the currently authenticated user. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task SessionsAsync(System.Guid sessionId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// List all groups + /// + /// + /// Retrieve all groups for the current tenant with optional search filter. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task> GroupsGetAsync(string search = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Create a new group + /// + /// + /// Create a new group with optional role assignments. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task GroupsPostAsync(CreateGroupCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get group by ID + /// + /// + /// Retrieve a specific group by its ID including roles and member count. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task GroupsGetAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Update a group + /// + /// + /// Update a group's name, description, default status, and role assignments. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task GroupsPutAsync(System.Guid id, UpdateGroupRequest body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Delete a group + /// + /// + /// Soft delete a group. System groups cannot be deleted. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task GroupsDeleteAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class IdentityClient : IIdentityClient + { + private System.Net.Http.HttpClient _httpClient; + private static System.Lazy _settings = new System.Lazy(CreateSerializerSettings, true); + private System.Text.Json.JsonSerializerOptions _instanceSettings; + + #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public IdentityClient(System.Net.Http.HttpClient httpClient) + #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + { + _httpClient = httpClient; + Initialize(); + } + + private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() + { + var settings = new System.Text.Json.JsonSerializerOptions(); + UpdateJsonSerializerSettings(settings); + return settings; + } + + protected System.Text.Json.JsonSerializerOptions JsonSerializerSettings { get { return _instanceSettings ?? _settings.Value; } } + + static partial void UpdateJsonSerializerSettings(System.Text.Json.JsonSerializerOptions settings); + + partial void Initialize(); + + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, string url); + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, System.Text.StringBuilder urlBuilder); + partial void ProcessResponse(System.Net.Http.HttpClient client, System.Net.Http.HttpResponseMessage response); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// List all roles + /// + /// + /// Retrieve all roles available for the current tenant. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task> RolesGetAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/roles" + urlBuilder_.Append("api/v1/identity/roles"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Create or update role + /// + /// + /// Create a new role or update an existing role's name and description. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task RolesPostAsync(UpsertRoleCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (body == null) + throw new System.ArgumentNullException("body"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/roles" + urlBuilder_.Append("api/v1/identity/roles"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get role by ID + /// + /// + /// Retrieve details of a specific role by its unique identifier. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task RolesGetAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/roles/{id}" + urlBuilder_.Append("api/v1/identity/roles/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Delete role by ID + /// + /// + /// Remove an existing role by its unique identifier. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task RolesDeleteAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("DELETE"); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/roles/{id}" + urlBuilder_.Append("api/v1/identity/roles/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + return; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get role permissions + /// + /// + /// Retrieve a role along with its assigned permissions. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task PermissionsGetAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/{id}/permissions" + urlBuilder_.Append("api/v1/identity/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/permissions"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Update role permissions + /// + /// + /// Replace the set of permissions assigned to a role. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task PermissionsPutAsync(string id, UpdatePermissionsCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + if (body == null) + throw new System.ArgumentNullException("body"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("PUT"); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/{id}/permissions" + urlBuilder_.Append("api/v1/identity/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/permissions"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + return; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Change password + /// + /// + /// Change the current user's password. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task ChangePasswordAsync(ChangePasswordCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (body == null) + throw new System.ArgumentNullException("body"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/change-password" + urlBuilder_.Append("api/v1/identity/change-password"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + return; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Confirm user email + /// + /// + /// Confirm a user's email address. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task ConfirmEmailAsync(string userId, string code, string tenant, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (userId == null) + throw new System.ArgumentNullException("userId"); + + if (code == null) + throw new System.ArgumentNullException("code"); + + if (tenant == null) + throw new System.ArgumentNullException("tenant"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/confirm-email" + urlBuilder_.Append("api/v1/identity/confirm-email"); + urlBuilder_.Append('?'); + urlBuilder_.Append(System.Uri.EscapeDataString("userId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(userId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + urlBuilder_.Append(System.Uri.EscapeDataString("code")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(code, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + urlBuilder_.Append(System.Uri.EscapeDataString("tenant")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(tenant, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + return; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Delete user + /// + /// + /// Delete a user by unique identifier. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task UsersDeleteAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("DELETE"); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/users/{id}" + urlBuilder_.Append("api/v1/identity/users/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + return; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get user by ID + /// + /// + /// Retrieve a user's profile details by unique user identifier. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task UsersGetAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/users/{id}" + urlBuilder_.Append("api/v1/identity/users/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Toggle user status + /// + /// + /// Activate or deactivate a user account. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task UsersPatchAsync(System.Guid id, ToggleUserStatusCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + if (body == null) + throw new System.ArgumentNullException("body"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("PATCH"); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/users/{id}" + urlBuilder_.Append("api/v1/identity/users/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + return; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get current user permissions + /// + /// + /// Retrieve permissions for the authenticated user. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task> PermissionsGetAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/permissions" + urlBuilder_.Append("api/v1/identity/permissions"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get current user profile + /// + /// + /// Retrieve the authenticated user's profile from the access token. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task ProfileGetAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/profile" + urlBuilder_.Append("api/v1/identity/profile"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Update user profile + /// + /// + /// Update profile details for the authenticated user. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task ProfilePutAsync(UpdateUserCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (body == null) + throw new System.ArgumentNullException("body"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("PUT"); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/profile" + urlBuilder_.Append("api/v1/identity/profile"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + return; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// List users + /// + /// + /// Retrieve a list of users for the current tenant. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task> UsersGetAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/users" + urlBuilder_.Append("api/v1/identity/users"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Register user + /// + /// + /// Create a new user account. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task RegisterAsync(RegisterUserCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (body == null) + throw new System.ArgumentNullException("body"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/register" + urlBuilder_.Append("api/v1/identity/register"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Reset password + /// + /// + /// Reset the user's password using the provided verification token. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task ResetPasswordAsync(string tenant, ResetPasswordCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (body == null) + throw new System.ArgumentNullException("body"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + + if (tenant == null) + throw new System.ArgumentNullException("tenant"); + request_.Headers.TryAddWithoutValidation("tenant", ConvertToString(tenant, System.Globalization.CultureInfo.InvariantCulture)); + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/reset-password" + urlBuilder_.Append("api/v1/identity/reset-password"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + return; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Self register user + /// + /// + /// Allow a user to self-register. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task SelfRegisterAsync(string tenant, RegisterUserCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (body == null) + throw new System.ArgumentNullException("body"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + + if (tenant == null) + throw new System.ArgumentNullException("tenant"); + request_.Headers.TryAddWithoutValidation("tenant", ConvertToString(tenant, System.Globalization.CultureInfo.InvariantCulture)); + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/self-register" + urlBuilder_.Append("api/v1/identity/self-register"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Revoke a session + /// + /// + /// Revoke a specific session for the currently authenticated user. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task SessionsAsync(System.Guid sessionId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (sessionId == null) + throw new System.ArgumentNullException("sessionId"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("DELETE"); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/sessions/{sessionId}" + urlBuilder_.Append("api/v1/identity/sessions/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(sessionId, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + return; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// List all groups + /// + /// + /// Retrieve all groups for the current tenant with optional search filter. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task> GroupsGetAsync(string search = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/groups" + urlBuilder_.Append("api/v1/identity/groups"); + urlBuilder_.Append('?'); + if (search != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("search")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(search, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Create a new group + /// + /// + /// Create a new group with optional role assignments. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GroupsPostAsync(CreateGroupCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (body == null) + throw new System.ArgumentNullException("body"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/groups" + urlBuilder_.Append("api/v1/identity/groups"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get group by ID + /// + /// + /// Retrieve a specific group by its ID including roles and member count. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GroupsGetAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/groups/{id}" + urlBuilder_.Append("api/v1/identity/groups/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Update a group + /// + /// + /// Update a group's name, description, default status, and role assignments. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GroupsPutAsync(System.Guid id, UpdateGroupRequest body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + if (body == null) + throw new System.ArgumentNullException("body"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("PUT"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/groups/{id}" + urlBuilder_.Append("api/v1/identity/groups/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Delete a group + /// + /// + /// Soft delete a group. System groups cannot be deleted. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GroupsDeleteAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("DELETE"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/groups/{id}" + urlBuilder_.Append("api/v1/identity/groups/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + protected struct ObjectResponseResult + { + public ObjectResponseResult(T responseObject, string responseText) + { + this.Object = responseObject; + this.Text = responseText; + } + + public T Object { get; } + + public string Text { get; } + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStringAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStringAsync(cancellationToken); + #else + return content.ReadAsStringAsync(); + #endif + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStreamAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStreamAsync(cancellationToken); + #else + return content.ReadAsStreamAsync(); + #endif + } + + public bool ReadResponseAsString { get; set; } + + protected virtual async System.Threading.Tasks.Task> ReadObjectResponseAsync(System.Net.Http.HttpResponseMessage response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Threading.CancellationToken cancellationToken) + { + if (response == null || response.Content == null) + { + return new ObjectResponseResult(default(T), string.Empty); + } + + if (ReadResponseAsString) + { + var responseText = await ReadAsStringAsync(response.Content, cancellationToken).ConfigureAwait(false); + try + { + var typedBody = System.Text.Json.JsonSerializer.Deserialize(responseText, JsonSerializerSettings); + return new ObjectResponseResult(typedBody, responseText); + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body string as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, responseText, headers, exception); + } + } + else + { + try + { + using (var responseStream = await ReadAsStreamAsync(response.Content, cancellationToken).ConfigureAwait(false)) + { + var typedBody = await System.Text.Json.JsonSerializer.DeserializeAsync(responseStream, JsonSerializerSettings, cancellationToken).ConfigureAwait(false); + return new ObjectResponseResult(typedBody, string.Empty); + } + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body stream as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, string.Empty, headers, exception); + } + } + } + + private string ConvertToString(object value, System.Globalization.CultureInfo cultureInfo) + { + if (value == null) + { + return ""; + } + + if (value is System.Enum) + { + var name = System.Enum.GetName(value.GetType(), value); + if (name != null) + { + var field_ = System.Reflection.IntrospectionExtensions.GetTypeInfo(value.GetType()).GetDeclaredField(name); + if (field_ != null) + { + var attribute = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(field_, typeof(System.Runtime.Serialization.EnumMemberAttribute)) + as System.Runtime.Serialization.EnumMemberAttribute; + if (attribute != null) + { + return attribute.Value != null ? attribute.Value : name; + } + } + + var converted = System.Convert.ToString(System.Convert.ChangeType(value, System.Enum.GetUnderlyingType(value.GetType()), cultureInfo)); + return converted == null ? string.Empty : converted; + } + } + else if (value is bool) + { + return System.Convert.ToString((bool)value, cultureInfo).ToLowerInvariant(); + } + else if (value is byte[]) + { + return System.Convert.ToBase64String((byte[]) value); + } + else if (value is string[]) + { + return string.Join(",", (string[])value); + } + else if (value.GetType().IsArray) + { + var valueArray = (System.Array)value; + var valueTextArray = new string[valueArray.Length]; + for (var i = 0; i < valueArray.Length; i++) + { + valueTextArray[i] = ConvertToString(valueArray.GetValue(i), cultureInfo); + } + return string.Join(",", valueTextArray); + } + + var result = System.Convert.ToString(value, cultureInfo); + return result == null ? "" : result; + } + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial interface IUsersClient + { + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Assign roles to user + /// + /// + /// Assign one or more roles to a user. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task RolesPostAsync(System.Guid id, AssignUserRolesCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get user roles + /// + /// + /// Retrieve the roles assigned to a specific user. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task> RolesGetAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Search users with pagination + /// + /// + /// Search and filter users with server-side pagination, sorting, and filtering by status, email confirmation, and role. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task SearchAsync(int? pageNumber = null, int? pageSize = null, string sort = null, string search = null, bool? isActive = null, bool? emailConfirmed = null, string roleId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get user's sessions (Admin) + /// + /// + /// Retrieve all active sessions for a specific user. Requires admin permission. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task> SessionsGetAsync(System.Guid userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Revoke a user's session (Admin) + /// + /// + /// Revoke a specific session for a user. Requires admin permission. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task SessionsDeleteAsync(System.Guid userId, System.Guid sessionId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get groups for a user + /// + /// + /// Retrieve all groups that a specific user belongs to. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task> GroupsAsync(string userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class UsersClient : IUsersClient + { + private System.Net.Http.HttpClient _httpClient; + private static System.Lazy _settings = new System.Lazy(CreateSerializerSettings, true); + private System.Text.Json.JsonSerializerOptions _instanceSettings; + + #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public UsersClient(System.Net.Http.HttpClient httpClient) + #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + { + _httpClient = httpClient; + Initialize(); + } + + private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() + { + var settings = new System.Text.Json.JsonSerializerOptions(); + UpdateJsonSerializerSettings(settings); + return settings; + } + + protected System.Text.Json.JsonSerializerOptions JsonSerializerSettings { get { return _instanceSettings ?? _settings.Value; } } + + static partial void UpdateJsonSerializerSettings(System.Text.Json.JsonSerializerOptions settings); + + partial void Initialize(); + + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, string url); + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, System.Text.StringBuilder urlBuilder); + partial void ProcessResponse(System.Net.Http.HttpClient client, System.Net.Http.HttpResponseMessage response); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Assign roles to user + /// + /// + /// Assign one or more roles to a user. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task RolesPostAsync(System.Guid id, AssignUserRolesCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + if (body == null) + throw new System.ArgumentNullException("body"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/users/{id}/roles" + urlBuilder_.Append("api/v1/identity/users/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/roles"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + return; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get user roles + /// + /// + /// Retrieve the roles assigned to a specific user. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task> RolesGetAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/users/{id}/roles" + urlBuilder_.Append("api/v1/identity/users/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/roles"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Search users with pagination + /// + /// + /// Search and filter users with server-side pagination, sorting, and filtering by status, email confirmation, and role. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task SearchAsync(int? pageNumber = null, int? pageSize = null, string sort = null, string search = null, bool? isActive = null, bool? emailConfirmed = null, string roleId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/users/search" + urlBuilder_.Append("api/v1/identity/users/search"); + urlBuilder_.Append('?'); + if (pageNumber != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("PageNumber")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(pageNumber, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (pageSize != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("PageSize")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(pageSize, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (sort != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Sort")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(sort, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (search != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Search")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(search, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (isActive != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("IsActive")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(isActive, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (emailConfirmed != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("EmailConfirmed")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(emailConfirmed, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (roleId != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("RoleId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(roleId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get user's sessions (Admin) + /// + /// + /// Retrieve all active sessions for a specific user. Requires admin permission. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task> SessionsGetAsync(System.Guid userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (userId == null) + throw new System.ArgumentNullException("userId"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/users/{userId}/sessions" + urlBuilder_.Append("api/v1/identity/users/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(userId, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/sessions"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Revoke a user's session (Admin) + /// + /// + /// Revoke a specific session for a user. Requires admin permission. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task SessionsDeleteAsync(System.Guid userId, System.Guid sessionId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (userId == null) + throw new System.ArgumentNullException("userId"); + + if (sessionId == null) + throw new System.ArgumentNullException("sessionId"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("DELETE"); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/users/{userId}/sessions/{sessionId}" + urlBuilder_.Append("api/v1/identity/users/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(userId, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/sessions/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(sessionId, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + return; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get groups for a user + /// + /// + /// Retrieve all groups that a specific user belongs to. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task> GroupsAsync(string userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (userId == null) + throw new System.ArgumentNullException("userId"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/users/{userId}/groups" + urlBuilder_.Append("api/v1/identity/users/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(userId, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/groups"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + protected struct ObjectResponseResult + { + public ObjectResponseResult(T responseObject, string responseText) + { + this.Object = responseObject; + this.Text = responseText; + } + + public T Object { get; } + + public string Text { get; } + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStringAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStringAsync(cancellationToken); + #else + return content.ReadAsStringAsync(); + #endif + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStreamAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStreamAsync(cancellationToken); + #else + return content.ReadAsStreamAsync(); + #endif + } + + public bool ReadResponseAsString { get; set; } + + protected virtual async System.Threading.Tasks.Task> ReadObjectResponseAsync(System.Net.Http.HttpResponseMessage response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Threading.CancellationToken cancellationToken) + { + if (response == null || response.Content == null) + { + return new ObjectResponseResult(default(T), string.Empty); + } + + if (ReadResponseAsString) + { + var responseText = await ReadAsStringAsync(response.Content, cancellationToken).ConfigureAwait(false); + try + { + var typedBody = System.Text.Json.JsonSerializer.Deserialize(responseText, JsonSerializerSettings); + return new ObjectResponseResult(typedBody, responseText); + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body string as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, responseText, headers, exception); + } + } + else + { + try + { + using (var responseStream = await ReadAsStreamAsync(response.Content, cancellationToken).ConfigureAwait(false)) + { + var typedBody = await System.Text.Json.JsonSerializer.DeserializeAsync(responseStream, JsonSerializerSettings, cancellationToken).ConfigureAwait(false); + return new ObjectResponseResult(typedBody, string.Empty); + } + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body stream as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, string.Empty, headers, exception); + } + } + } + + private string ConvertToString(object value, System.Globalization.CultureInfo cultureInfo) + { + if (value == null) + { + return ""; + } + + if (value is System.Enum) + { + var name = System.Enum.GetName(value.GetType(), value); + if (name != null) + { + var field_ = System.Reflection.IntrospectionExtensions.GetTypeInfo(value.GetType()).GetDeclaredField(name); + if (field_ != null) + { + var attribute = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(field_, typeof(System.Runtime.Serialization.EnumMemberAttribute)) + as System.Runtime.Serialization.EnumMemberAttribute; + if (attribute != null) + { + return attribute.Value != null ? attribute.Value : name; + } + } + + var converted = System.Convert.ToString(System.Convert.ChangeType(value, System.Enum.GetUnderlyingType(value.GetType()), cultureInfo)); + return converted == null ? string.Empty : converted; + } + } + else if (value is bool) + { + return System.Convert.ToString((bool)value, cultureInfo).ToLowerInvariant(); + } + else if (value is byte[]) + { + return System.Convert.ToBase64String((byte[]) value); + } + else if (value is string[]) + { + return string.Join(",", (string[])value); + } + else if (value.GetType().IsArray) + { + var valueArray = (System.Array)value; + var valueTextArray = new string[valueArray.Length]; + for (var i = 0; i < valueArray.Length; i++) + { + valueTextArray[i] = ConvertToString(valueArray.GetValue(i), cultureInfo); + } + return string.Join(",", valueTextArray); + } + + var result = System.Convert.ToString(value, cultureInfo); + return result == null ? "" : result; + } + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial interface ISessionsClient + { + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get current user's sessions + /// + /// + /// Retrieve all active sessions for the currently authenticated user. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task> MeAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Revoke all sessions + /// + /// + /// Revoke all sessions for the currently authenticated user except the current one. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task RevokeAllPostAsync(RevokeAllSessionsCommand body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Revoke all user's sessions (Admin) + /// + /// + /// Revoke all sessions for a specific user. Requires admin permission. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task RevokeAllPostAsync(System.Guid userId, AdminRevokeAllSessionsCommand body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class SessionsClient : ISessionsClient + { + private System.Net.Http.HttpClient _httpClient; + private static System.Lazy _settings = new System.Lazy(CreateSerializerSettings, true); + private System.Text.Json.JsonSerializerOptions _instanceSettings; + + #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public SessionsClient(System.Net.Http.HttpClient httpClient) + #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + { + _httpClient = httpClient; + Initialize(); + } + + private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() + { + var settings = new System.Text.Json.JsonSerializerOptions(); + UpdateJsonSerializerSettings(settings); + return settings; + } + + protected System.Text.Json.JsonSerializerOptions JsonSerializerSettings { get { return _instanceSettings ?? _settings.Value; } } + + static partial void UpdateJsonSerializerSettings(System.Text.Json.JsonSerializerOptions settings); + + partial void Initialize(); + + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, string url); + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, System.Text.StringBuilder urlBuilder); + partial void ProcessResponse(System.Net.Http.HttpClient client, System.Net.Http.HttpResponseMessage response); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get current user's sessions + /// + /// + /// Retrieve all active sessions for the currently authenticated user. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task> MeAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/sessions/me" + urlBuilder_.Append("api/v1/identity/sessions/me"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Revoke all sessions + /// + /// + /// Revoke all sessions for the currently authenticated user except the current one. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task RevokeAllPostAsync(RevokeAllSessionsCommand body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/sessions/revoke-all" + urlBuilder_.Append("api/v1/identity/sessions/revoke-all"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Revoke all user's sessions (Admin) + /// + /// + /// Revoke all sessions for a specific user. Requires admin permission. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task RevokeAllPostAsync(System.Guid userId, AdminRevokeAllSessionsCommand body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (userId == null) + throw new System.ArgumentNullException("userId"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/users/{userId}/sessions/revoke-all" + urlBuilder_.Append("api/v1/identity/users/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(userId, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/sessions/revoke-all"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + protected struct ObjectResponseResult + { + public ObjectResponseResult(T responseObject, string responseText) + { + this.Object = responseObject; + this.Text = responseText; + } + + public T Object { get; } + + public string Text { get; } + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStringAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStringAsync(cancellationToken); + #else + return content.ReadAsStringAsync(); + #endif + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStreamAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStreamAsync(cancellationToken); + #else + return content.ReadAsStreamAsync(); + #endif + } + + public bool ReadResponseAsString { get; set; } + + protected virtual async System.Threading.Tasks.Task> ReadObjectResponseAsync(System.Net.Http.HttpResponseMessage response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Threading.CancellationToken cancellationToken) + { + if (response == null || response.Content == null) + { + return new ObjectResponseResult(default(T), string.Empty); + } + + if (ReadResponseAsString) + { + var responseText = await ReadAsStringAsync(response.Content, cancellationToken).ConfigureAwait(false); + try + { + var typedBody = System.Text.Json.JsonSerializer.Deserialize(responseText, JsonSerializerSettings); + return new ObjectResponseResult(typedBody, responseText); + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body string as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, responseText, headers, exception); + } + } + else + { + try + { + using (var responseStream = await ReadAsStreamAsync(response.Content, cancellationToken).ConfigureAwait(false)) + { + var typedBody = await System.Text.Json.JsonSerializer.DeserializeAsync(responseStream, JsonSerializerSettings, cancellationToken).ConfigureAwait(false); + return new ObjectResponseResult(typedBody, string.Empty); + } + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body stream as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, string.Empty, headers, exception); + } + } + } + + private string ConvertToString(object value, System.Globalization.CultureInfo cultureInfo) + { + if (value == null) + { + return ""; + } + + if (value is System.Enum) + { + var name = System.Enum.GetName(value.GetType(), value); + if (name != null) + { + var field_ = System.Reflection.IntrospectionExtensions.GetTypeInfo(value.GetType()).GetDeclaredField(name); + if (field_ != null) + { + var attribute = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(field_, typeof(System.Runtime.Serialization.EnumMemberAttribute)) + as System.Runtime.Serialization.EnumMemberAttribute; + if (attribute != null) + { + return attribute.Value != null ? attribute.Value : name; + } + } + + var converted = System.Convert.ToString(System.Convert.ChangeType(value, System.Enum.GetUnderlyingType(value.GetType()), cultureInfo)); + return converted == null ? string.Empty : converted; + } + } + else if (value is bool) + { + return System.Convert.ToString((bool)value, cultureInfo).ToLowerInvariant(); + } + else if (value is byte[]) + { + return System.Convert.ToBase64String((byte[]) value); + } + else if (value is string[]) + { + return string.Join(",", (string[])value); + } + else if (value.GetType().IsArray) + { + var valueArray = (System.Array)value; + var valueTextArray = new string[valueArray.Length]; + for (var i = 0; i < valueArray.Length; i++) + { + valueTextArray[i] = ConvertToString(valueArray.GetValue(i), cultureInfo); + } + return string.Join(",", valueTextArray); + } + + var result = System.Convert.ToString(value, cultureInfo); + return result == null ? "" : result; + } + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial interface IGroupsClient + { + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get members of a group + /// + /// + /// Retrieve all users that belong to a specific group. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task> MembersGetAsync(System.Guid groupId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Add users to a group + /// + /// + /// Add one or more users to a group. Returns count of added users and list of users already in the group. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task MembersPostAsync(System.Guid groupId, AddUsersRequest body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Remove a user from a group + /// + /// + /// Remove a specific user from a group. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task MembersDeleteAsync(System.Guid groupId, string userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class GroupsClient : IGroupsClient + { + private System.Net.Http.HttpClient _httpClient; + private static System.Lazy _settings = new System.Lazy(CreateSerializerSettings, true); + private System.Text.Json.JsonSerializerOptions _instanceSettings; + + #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public GroupsClient(System.Net.Http.HttpClient httpClient) + #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + { + _httpClient = httpClient; + Initialize(); + } + + private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() + { + var settings = new System.Text.Json.JsonSerializerOptions(); + UpdateJsonSerializerSettings(settings); + return settings; + } + + protected System.Text.Json.JsonSerializerOptions JsonSerializerSettings { get { return _instanceSettings ?? _settings.Value; } } + + static partial void UpdateJsonSerializerSettings(System.Text.Json.JsonSerializerOptions settings); + + partial void Initialize(); + + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, string url); + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, System.Text.StringBuilder urlBuilder); + partial void ProcessResponse(System.Net.Http.HttpClient client, System.Net.Http.HttpResponseMessage response); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get members of a group + /// + /// + /// Retrieve all users that belong to a specific group. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task> MembersGetAsync(System.Guid groupId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (groupId == null) + throw new System.ArgumentNullException("groupId"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/groups/{groupId}/members" + urlBuilder_.Append("api/v1/identity/groups/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(groupId, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/members"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Add users to a group + /// + /// + /// Add one or more users to a group. Returns count of added users and list of users already in the group. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task MembersPostAsync(System.Guid groupId, AddUsersRequest body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (groupId == null) + throw new System.ArgumentNullException("groupId"); + + if (body == null) + throw new System.ArgumentNullException("body"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/groups/{groupId}/members" + urlBuilder_.Append("api/v1/identity/groups/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(groupId, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/members"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Remove a user from a group + /// + /// + /// Remove a specific user from a group. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task MembersDeleteAsync(System.Guid groupId, string userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (groupId == null) + throw new System.ArgumentNullException("groupId"); + + if (userId == null) + throw new System.ArgumentNullException("userId"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("DELETE"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/identity/groups/{groupId}/members/{userId}" + urlBuilder_.Append("api/v1/identity/groups/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(groupId, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/members/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(userId, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + protected struct ObjectResponseResult + { + public ObjectResponseResult(T responseObject, string responseText) + { + this.Object = responseObject; + this.Text = responseText; + } + + public T Object { get; } + + public string Text { get; } + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStringAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStringAsync(cancellationToken); + #else + return content.ReadAsStringAsync(); + #endif + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStreamAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStreamAsync(cancellationToken); + #else + return content.ReadAsStreamAsync(); + #endif + } + + public bool ReadResponseAsString { get; set; } + + protected virtual async System.Threading.Tasks.Task> ReadObjectResponseAsync(System.Net.Http.HttpResponseMessage response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Threading.CancellationToken cancellationToken) + { + if (response == null || response.Content == null) + { + return new ObjectResponseResult(default(T), string.Empty); + } + + if (ReadResponseAsString) + { + var responseText = await ReadAsStringAsync(response.Content, cancellationToken).ConfigureAwait(false); + try + { + var typedBody = System.Text.Json.JsonSerializer.Deserialize(responseText, JsonSerializerSettings); + return new ObjectResponseResult(typedBody, responseText); + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body string as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, responseText, headers, exception); + } + } + else + { + try + { + using (var responseStream = await ReadAsStreamAsync(response.Content, cancellationToken).ConfigureAwait(false)) + { + var typedBody = await System.Text.Json.JsonSerializer.DeserializeAsync(responseStream, JsonSerializerSettings, cancellationToken).ConfigureAwait(false); + return new ObjectResponseResult(typedBody, string.Empty); + } + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body stream as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, string.Empty, headers, exception); + } + } + } + + private string ConvertToString(object value, System.Globalization.CultureInfo cultureInfo) + { + if (value == null) + { + return ""; + } + + if (value is System.Enum) + { + var name = System.Enum.GetName(value.GetType(), value); + if (name != null) + { + var field_ = System.Reflection.IntrospectionExtensions.GetTypeInfo(value.GetType()).GetDeclaredField(name); + if (field_ != null) + { + var attribute = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(field_, typeof(System.Runtime.Serialization.EnumMemberAttribute)) + as System.Runtime.Serialization.EnumMemberAttribute; + if (attribute != null) + { + return attribute.Value != null ? attribute.Value : name; + } + } + + var converted = System.Convert.ToString(System.Convert.ChangeType(value, System.Enum.GetUnderlyingType(value.GetType()), cultureInfo)); + return converted == null ? string.Empty : converted; + } + } + else if (value is bool) + { + return System.Convert.ToString((bool)value, cultureInfo).ToLowerInvariant(); + } + else if (value is byte[]) + { + return System.Convert.ToBase64String((byte[]) value); + } + else if (value is string[]) + { + return string.Join(",", (string[])value); + } + else if (value.GetType().IsArray) + { + var valueArray = (System.Array)value; + var valueTextArray = new string[valueArray.Length]; + for (var i = 0; i < valueArray.Length; i++) + { + valueTextArray[i] = ConvertToString(valueArray.GetValue(i), cultureInfo); + } + return string.Join(",", valueTextArray); + } + + var result = System.Convert.ToString(value, cultureInfo); + return result == null ? "" : result; + } + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial interface ITenantsClient + { + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Change tenant activation state + /// + /// + /// Activate or deactivate a tenant in a single endpoint. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task ActivationAsync(string id, ChangeTenantActivationCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Upgrade tenant subscription + /// + /// + /// Extend or upgrade a tenant's subscription. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task UpgradeAsync(string id, UpgradeTenantCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get tenant status + /// + /// + /// Retrieve status information for a tenant, including activation, validity, and basic metadata. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task StatusAsync(string id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get tenant provisioning status + /// + /// + /// Get latest provisioning status for a tenant. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task ProvisioningAsync(string tenantId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get current tenant theme + /// + /// + /// Retrieve the theme settings for the current tenant, including colors, typography, and brand assets. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task ThemeGetAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Update current tenant theme + /// + /// + /// Update the theme settings for the current tenant, including colors, typography, and layout. + /// + /// No Content + /// A server side error occurred. + System.Threading.Tasks.Task ThemePutAsync(TenantThemeDto body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class TenantsClient : ITenantsClient + { + private System.Net.Http.HttpClient _httpClient; + private static System.Lazy _settings = new System.Lazy(CreateSerializerSettings, true); + private System.Text.Json.JsonSerializerOptions _instanceSettings; + + #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public TenantsClient(System.Net.Http.HttpClient httpClient) + #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + { + _httpClient = httpClient; + Initialize(); + } + + private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() + { + var settings = new System.Text.Json.JsonSerializerOptions(); + UpdateJsonSerializerSettings(settings); + return settings; + } + + protected System.Text.Json.JsonSerializerOptions JsonSerializerSettings { get { return _instanceSettings ?? _settings.Value; } } + + static partial void UpdateJsonSerializerSettings(System.Text.Json.JsonSerializerOptions settings); + + partial void Initialize(); + + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, string url); + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, System.Text.StringBuilder urlBuilder); + partial void ProcessResponse(System.Net.Http.HttpClient client, System.Net.Http.HttpResponseMessage response); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Change tenant activation state + /// + /// + /// Activate or deactivate a tenant in a single endpoint. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task ActivationAsync(string id, ChangeTenantActivationCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + if (body == null) + throw new System.ArgumentNullException("body"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/tenants/{id}/activation" + urlBuilder_.Append("api/v1/tenants/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/activation"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 400) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Bad Request", status_, responseText_, headers_, null); + } + else + if (status_ == 401) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Unauthorized", status_, responseText_, headers_, null); + } + else + if (status_ == 403) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Forbidden", status_, responseText_, headers_, null); + } + else + if (status_ == 404) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Not Found", status_, responseText_, headers_, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Upgrade tenant subscription + /// + /// + /// Extend or upgrade a tenant's subscription. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task UpgradeAsync(string id, UpgradeTenantCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + if (body == null) + throw new System.ArgumentNullException("body"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/tenants/{id}/upgrade" + urlBuilder_.Append("api/v1/tenants/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/upgrade"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + return; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get tenant status + /// + /// + /// Retrieve status information for a tenant, including activation, validity, and basic metadata. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task StatusAsync(string id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/tenants/{id}/status" + urlBuilder_.Append("api/v1/tenants/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/status"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 401) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Unauthorized", status_, responseText_, headers_, null); + } + else + if (status_ == 403) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Forbidden", status_, responseText_, headers_, null); + } + else + if (status_ == 404) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Not Found", status_, responseText_, headers_, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get tenant provisioning status + /// + /// + /// Get latest provisioning status for a tenant. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task ProvisioningAsync(string tenantId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (tenantId == null) + throw new System.ArgumentNullException("tenantId"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/tenants/{tenantId}/provisioning" + urlBuilder_.Append("api/v1/tenants/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(tenantId, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/provisioning"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get current tenant theme + /// + /// + /// Retrieve the theme settings for the current tenant, including colors, typography, and brand assets. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task ThemeGetAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/tenants/theme" + urlBuilder_.Append("api/v1/tenants/theme"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 401) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Unauthorized", status_, responseText_, headers_, null); + } + else + if (status_ == 403) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Forbidden", status_, responseText_, headers_, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Update current tenant theme + /// + /// + /// Update the theme settings for the current tenant, including colors, typography, and layout. + /// + /// No Content + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task ThemePutAsync(TenantThemeDto body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (body == null) + throw new System.ArgumentNullException("body"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("PUT"); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/tenants/theme" + urlBuilder_.Append("api/v1/tenants/theme"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 204) + { + return; + } + else + if (status_ == 400) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Bad Request", status_, responseText_, headers_, null); + } + else + if (status_ == 401) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Unauthorized", status_, responseText_, headers_, null); + } + else + if (status_ == 403) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Forbidden", status_, responseText_, headers_, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + protected struct ObjectResponseResult + { + public ObjectResponseResult(T responseObject, string responseText) + { + this.Object = responseObject; + this.Text = responseText; + } + + public T Object { get; } + + public string Text { get; } + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStringAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStringAsync(cancellationToken); + #else + return content.ReadAsStringAsync(); + #endif + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStreamAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStreamAsync(cancellationToken); + #else + return content.ReadAsStreamAsync(); + #endif + } + + public bool ReadResponseAsString { get; set; } + + protected virtual async System.Threading.Tasks.Task> ReadObjectResponseAsync(System.Net.Http.HttpResponseMessage response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Threading.CancellationToken cancellationToken) + { + if (response == null || response.Content == null) + { + return new ObjectResponseResult(default(T), string.Empty); + } + + if (ReadResponseAsString) + { + var responseText = await ReadAsStringAsync(response.Content, cancellationToken).ConfigureAwait(false); + try + { + var typedBody = System.Text.Json.JsonSerializer.Deserialize(responseText, JsonSerializerSettings); + return new ObjectResponseResult(typedBody, responseText); + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body string as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, responseText, headers, exception); + } + } + else + { + try + { + using (var responseStream = await ReadAsStreamAsync(response.Content, cancellationToken).ConfigureAwait(false)) + { + var typedBody = await System.Text.Json.JsonSerializer.DeserializeAsync(responseStream, JsonSerializerSettings, cancellationToken).ConfigureAwait(false); + return new ObjectResponseResult(typedBody, string.Empty); + } + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body stream as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, string.Empty, headers, exception); + } + } + } + + private string ConvertToString(object value, System.Globalization.CultureInfo cultureInfo) + { + if (value == null) + { + return ""; + } + + if (value is System.Enum) + { + var name = System.Enum.GetName(value.GetType(), value); + if (name != null) + { + var field_ = System.Reflection.IntrospectionExtensions.GetTypeInfo(value.GetType()).GetDeclaredField(name); + if (field_ != null) + { + var attribute = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(field_, typeof(System.Runtime.Serialization.EnumMemberAttribute)) + as System.Runtime.Serialization.EnumMemberAttribute; + if (attribute != null) + { + return attribute.Value != null ? attribute.Value : name; + } + } + + var converted = System.Convert.ToString(System.Convert.ChangeType(value, System.Enum.GetUnderlyingType(value.GetType()), cultureInfo)); + return converted == null ? string.Empty : converted; + } + } + else if (value is bool) + { + return System.Convert.ToString((bool)value, cultureInfo).ToLowerInvariant(); + } + else if (value is byte[]) + { + return System.Convert.ToBase64String((byte[]) value); + } + else if (value is string[]) + { + return string.Join(",", (string[])value); + } + else if (value.GetType().IsArray) + { + var valueArray = (System.Array)value; + var valueTextArray = new string[valueArray.Length]; + for (var i = 0; i < valueArray.Length; i++) + { + valueTextArray[i] = ConvertToString(valueArray.GetValue(i), cultureInfo); + } + return string.Join(",", valueTextArray); + } + + var result = System.Convert.ToString(value, cultureInfo); + return result == null ? "" : result; + } + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial interface IV1Client + { + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// List tenants + /// + /// + /// Retrieve tenants for the current environment with pagination and optional sorting. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task TenantsGetAsync(int? pageNumber = null, int? pageSize = null, string sort = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Create tenant + /// + /// + /// Create a new tenant. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task TenantsPostAsync(CreateTenantCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// List and search audit events + /// + /// + /// Retrieve audit events with pagination and filters. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task AuditsGetAsync(int? pageNumber = null, int? pageSize = null, string sort = null, System.DateTimeOffset? fromUtc = null, System.DateTimeOffset? toUtc = null, string tenantId = null, string userId = null, int? eventType = null, int? severity = null, int? tags = null, string source = null, string correlationId = null, string traceId = null, string search = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get audit event by ID + /// + /// + /// Retrieve full details for a single audit event. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task AuditsGetAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class V1Client : IV1Client + { + private System.Net.Http.HttpClient _httpClient; + private static System.Lazy _settings = new System.Lazy(CreateSerializerSettings, true); + private System.Text.Json.JsonSerializerOptions _instanceSettings; + + #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public V1Client(System.Net.Http.HttpClient httpClient) + #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + { + _httpClient = httpClient; + Initialize(); + } + + private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() + { + var settings = new System.Text.Json.JsonSerializerOptions(); + UpdateJsonSerializerSettings(settings); + return settings; + } + + protected System.Text.Json.JsonSerializerOptions JsonSerializerSettings { get { return _instanceSettings ?? _settings.Value; } } + + static partial void UpdateJsonSerializerSettings(System.Text.Json.JsonSerializerOptions settings); + + partial void Initialize(); + + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, string url); + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, System.Text.StringBuilder urlBuilder); + partial void ProcessResponse(System.Net.Http.HttpClient client, System.Net.Http.HttpResponseMessage response); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// List tenants + /// + /// + /// Retrieve tenants for the current environment with pagination and optional sorting. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task TenantsGetAsync(int? pageNumber = null, int? pageSize = null, string sort = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/tenants" + urlBuilder_.Append("api/v1/tenants"); + urlBuilder_.Append('?'); + if (pageNumber != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("PageNumber")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(pageNumber, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (pageSize != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("PageSize")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(pageSize, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (sort != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Sort")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(sort, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 401) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Unauthorized", status_, responseText_, headers_, null); + } + else + if (status_ == 403) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Forbidden", status_, responseText_, headers_, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Create tenant + /// + /// + /// Create a new tenant. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task TenantsPostAsync(CreateTenantCommand body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (body == null) + throw new System.ArgumentNullException("body"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/tenants" + urlBuilder_.Append("api/v1/tenants"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// List and search audit events + /// + /// + /// Retrieve audit events with pagination and filters. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task AuditsGetAsync(int? pageNumber = null, int? pageSize = null, string sort = null, System.DateTimeOffset? fromUtc = null, System.DateTimeOffset? toUtc = null, string tenantId = null, string userId = null, int? eventType = null, int? severity = null, int? tags = null, string source = null, string correlationId = null, string traceId = null, string search = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/audits" + urlBuilder_.Append("api/v1/audits"); + urlBuilder_.Append('?'); + if (pageNumber != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("PageNumber")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(pageNumber, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (pageSize != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("PageSize")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(pageSize, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (sort != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Sort")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(sort, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (fromUtc != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("FromUtc")).Append('=').Append(System.Uri.EscapeDataString(fromUtc.Value.ToString("o", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (toUtc != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("ToUtc")).Append('=').Append(System.Uri.EscapeDataString(toUtc.Value.ToString("o", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (tenantId != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("TenantId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(tenantId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (userId != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("UserId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(userId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (eventType != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("EventType")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(eventType, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (severity != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Severity")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(severity, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (tags != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Tags")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(tags, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (source != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Source")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(source, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (correlationId != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("CorrelationId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(correlationId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (traceId != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("TraceId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(traceId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (search != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Search")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(search, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get audit event by ID + /// + /// + /// Retrieve full details for a single audit event. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task AuditsGetAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/audits/{id}" + urlBuilder_.Append("api/v1/audits/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + protected struct ObjectResponseResult + { + public ObjectResponseResult(T responseObject, string responseText) + { + this.Object = responseObject; + this.Text = responseText; + } + + public T Object { get; } + + public string Text { get; } + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStringAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStringAsync(cancellationToken); + #else + return content.ReadAsStringAsync(); + #endif + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStreamAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStreamAsync(cancellationToken); + #else + return content.ReadAsStreamAsync(); + #endif + } + + public bool ReadResponseAsString { get; set; } + + protected virtual async System.Threading.Tasks.Task> ReadObjectResponseAsync(System.Net.Http.HttpResponseMessage response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Threading.CancellationToken cancellationToken) + { + if (response == null || response.Content == null) + { + return new ObjectResponseResult(default(T), string.Empty); + } + + if (ReadResponseAsString) + { + var responseText = await ReadAsStringAsync(response.Content, cancellationToken).ConfigureAwait(false); + try + { + var typedBody = System.Text.Json.JsonSerializer.Deserialize(responseText, JsonSerializerSettings); + return new ObjectResponseResult(typedBody, responseText); + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body string as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, responseText, headers, exception); + } + } + else + { + try + { + using (var responseStream = await ReadAsStreamAsync(response.Content, cancellationToken).ConfigureAwait(false)) + { + var typedBody = await System.Text.Json.JsonSerializer.DeserializeAsync(responseStream, JsonSerializerSettings, cancellationToken).ConfigureAwait(false); + return new ObjectResponseResult(typedBody, string.Empty); + } + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body stream as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, string.Empty, headers, exception); + } + } + } + + private string ConvertToString(object value, System.Globalization.CultureInfo cultureInfo) + { + if (value == null) + { + return ""; + } + + if (value is System.Enum) + { + var name = System.Enum.GetName(value.GetType(), value); + if (name != null) + { + var field_ = System.Reflection.IntrospectionExtensions.GetTypeInfo(value.GetType()).GetDeclaredField(name); + if (field_ != null) + { + var attribute = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(field_, typeof(System.Runtime.Serialization.EnumMemberAttribute)) + as System.Runtime.Serialization.EnumMemberAttribute; + if (attribute != null) + { + return attribute.Value != null ? attribute.Value : name; + } + } + + var converted = System.Convert.ToString(System.Convert.ChangeType(value, System.Enum.GetUnderlyingType(value.GetType()), cultureInfo)); + return converted == null ? string.Empty : converted; + } + } + else if (value is bool) + { + return System.Convert.ToString((bool)value, cultureInfo).ToLowerInvariant(); + } + else if (value is byte[]) + { + return System.Convert.ToBase64String((byte[]) value); + } + else if (value is string[]) + { + return string.Join(",", (string[])value); + } + else if (value.GetType().IsArray) + { + var valueArray = (System.Array)value; + var valueTextArray = new string[valueArray.Length]; + for (var i = 0; i < valueArray.Length; i++) + { + valueTextArray[i] = ConvertToString(valueArray.GetValue(i), cultureInfo); + } + return string.Join(",", valueTextArray); + } + + var result = System.Convert.ToString(value, cultureInfo); + return result == null ? "" : result; + } + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial interface IProvisioningClient + { + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Retry tenant provisioning + /// + /// + /// Retry the provisioning workflow for a tenant. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task RetryAsync(string tenantId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class ProvisioningClient : IProvisioningClient + { + private System.Net.Http.HttpClient _httpClient; + private static System.Lazy _settings = new System.Lazy(CreateSerializerSettings, true); + private System.Text.Json.JsonSerializerOptions _instanceSettings; + + #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public ProvisioningClient(System.Net.Http.HttpClient httpClient) + #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + { + _httpClient = httpClient; + Initialize(); + } + + private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() + { + var settings = new System.Text.Json.JsonSerializerOptions(); + UpdateJsonSerializerSettings(settings); + return settings; + } + + protected System.Text.Json.JsonSerializerOptions JsonSerializerSettings { get { return _instanceSettings ?? _settings.Value; } } + + static partial void UpdateJsonSerializerSettings(System.Text.Json.JsonSerializerOptions settings); + + partial void Initialize(); + + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, string url); + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, System.Text.StringBuilder urlBuilder); + partial void ProcessResponse(System.Net.Http.HttpClient client, System.Net.Http.HttpResponseMessage response); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Retry tenant provisioning + /// + /// + /// Retry the provisioning workflow for a tenant. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task RetryAsync(string tenantId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (tenantId == null) + throw new System.ArgumentNullException("tenantId"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/tenants/{tenantId}/provisioning/retry" + urlBuilder_.Append("api/v1/tenants/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(tenantId, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/provisioning/retry"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + protected struct ObjectResponseResult + { + public ObjectResponseResult(T responseObject, string responseText) + { + this.Object = responseObject; + this.Text = responseText; + } + + public T Object { get; } + + public string Text { get; } + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStringAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStringAsync(cancellationToken); + #else + return content.ReadAsStringAsync(); + #endif + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStreamAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStreamAsync(cancellationToken); + #else + return content.ReadAsStreamAsync(); + #endif + } + + public bool ReadResponseAsString { get; set; } + + protected virtual async System.Threading.Tasks.Task> ReadObjectResponseAsync(System.Net.Http.HttpResponseMessage response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Threading.CancellationToken cancellationToken) + { + if (response == null || response.Content == null) + { + return new ObjectResponseResult(default(T), string.Empty); + } + + if (ReadResponseAsString) + { + var responseText = await ReadAsStringAsync(response.Content, cancellationToken).ConfigureAwait(false); + try + { + var typedBody = System.Text.Json.JsonSerializer.Deserialize(responseText, JsonSerializerSettings); + return new ObjectResponseResult(typedBody, responseText); + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body string as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, responseText, headers, exception); + } + } + else + { + try + { + using (var responseStream = await ReadAsStreamAsync(response.Content, cancellationToken).ConfigureAwait(false)) + { + var typedBody = await System.Text.Json.JsonSerializer.DeserializeAsync(responseStream, JsonSerializerSettings, cancellationToken).ConfigureAwait(false); + return new ObjectResponseResult(typedBody, string.Empty); + } + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body stream as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, string.Empty, headers, exception); + } + } + } + + private string ConvertToString(object value, System.Globalization.CultureInfo cultureInfo) + { + if (value == null) + { + return ""; + } + + if (value is System.Enum) + { + var name = System.Enum.GetName(value.GetType(), value); + if (name != null) + { + var field_ = System.Reflection.IntrospectionExtensions.GetTypeInfo(value.GetType()).GetDeclaredField(name); + if (field_ != null) + { + var attribute = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(field_, typeof(System.Runtime.Serialization.EnumMemberAttribute)) + as System.Runtime.Serialization.EnumMemberAttribute; + if (attribute != null) + { + return attribute.Value != null ? attribute.Value : name; + } + } + + var converted = System.Convert.ToString(System.Convert.ChangeType(value, System.Enum.GetUnderlyingType(value.GetType()), cultureInfo)); + return converted == null ? string.Empty : converted; + } + } + else if (value is bool) + { + return System.Convert.ToString((bool)value, cultureInfo).ToLowerInvariant(); + } + else if (value is byte[]) + { + return System.Convert.ToBase64String((byte[]) value); + } + else if (value is string[]) + { + return string.Join(",", (string[])value); + } + else if (value.GetType().IsArray) + { + var valueArray = (System.Array)value; + var valueTextArray = new string[valueArray.Length]; + for (var i = 0; i < valueArray.Length; i++) + { + valueTextArray[i] = ConvertToString(valueArray.GetValue(i), cultureInfo); + } + return string.Join(",", valueTextArray); + } + + var result = System.Convert.ToString(value, cultureInfo); + return result == null ? "" : result; + } + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial interface IThemeClient + { + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Reset tenant theme to defaults + /// + /// + /// Reset the theme settings for the current tenant to the default values. + /// + /// No Content + /// A server side error occurred. + System.Threading.Tasks.Task ResetAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class ThemeClient : IThemeClient + { + private System.Net.Http.HttpClient _httpClient; + private static System.Lazy _settings = new System.Lazy(CreateSerializerSettings, true); + private System.Text.Json.JsonSerializerOptions _instanceSettings; + + #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public ThemeClient(System.Net.Http.HttpClient httpClient) + #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + { + _httpClient = httpClient; + Initialize(); + } + + private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() + { + var settings = new System.Text.Json.JsonSerializerOptions(); + UpdateJsonSerializerSettings(settings); + return settings; + } + + protected System.Text.Json.JsonSerializerOptions JsonSerializerSettings { get { return _instanceSettings ?? _settings.Value; } } + + static partial void UpdateJsonSerializerSettings(System.Text.Json.JsonSerializerOptions settings); + + partial void Initialize(); + + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, string url); + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, System.Text.StringBuilder urlBuilder); + partial void ProcessResponse(System.Net.Http.HttpClient client, System.Net.Http.HttpResponseMessage response); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Reset tenant theme to defaults + /// + /// + /// Reset the theme settings for the current tenant to the default values. + /// + /// No Content + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task ResetAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); + request_.Method = new System.Net.Http.HttpMethod("POST"); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/tenants/theme/reset" + urlBuilder_.Append("api/v1/tenants/theme/reset"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 204) + { + return; + } + else + if (status_ == 401) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Unauthorized", status_, responseText_, headers_, null); + } + else + if (status_ == 403) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Forbidden", status_, responseText_, headers_, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + protected struct ObjectResponseResult + { + public ObjectResponseResult(T responseObject, string responseText) + { + this.Object = responseObject; + this.Text = responseText; + } + + public T Object { get; } + + public string Text { get; } + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStringAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStringAsync(cancellationToken); + #else + return content.ReadAsStringAsync(); + #endif + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStreamAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStreamAsync(cancellationToken); + #else + return content.ReadAsStreamAsync(); + #endif + } + + public bool ReadResponseAsString { get; set; } + + protected virtual async System.Threading.Tasks.Task> ReadObjectResponseAsync(System.Net.Http.HttpResponseMessage response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Threading.CancellationToken cancellationToken) + { + if (response == null || response.Content == null) + { + return new ObjectResponseResult(default(T), string.Empty); + } + + if (ReadResponseAsString) + { + var responseText = await ReadAsStringAsync(response.Content, cancellationToken).ConfigureAwait(false); + try + { + var typedBody = System.Text.Json.JsonSerializer.Deserialize(responseText, JsonSerializerSettings); + return new ObjectResponseResult(typedBody, responseText); + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body string as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, responseText, headers, exception); + } + } + else + { + try + { + using (var responseStream = await ReadAsStreamAsync(response.Content, cancellationToken).ConfigureAwait(false)) + { + var typedBody = await System.Text.Json.JsonSerializer.DeserializeAsync(responseStream, JsonSerializerSettings, cancellationToken).ConfigureAwait(false); + return new ObjectResponseResult(typedBody, string.Empty); + } + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body stream as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, string.Empty, headers, exception); + } + } + } + + private string ConvertToString(object value, System.Globalization.CultureInfo cultureInfo) + { + if (value == null) + { + return ""; + } + + if (value is System.Enum) + { + var name = System.Enum.GetName(value.GetType(), value); + if (name != null) + { + var field_ = System.Reflection.IntrospectionExtensions.GetTypeInfo(value.GetType()).GetDeclaredField(name); + if (field_ != null) + { + var attribute = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(field_, typeof(System.Runtime.Serialization.EnumMemberAttribute)) + as System.Runtime.Serialization.EnumMemberAttribute; + if (attribute != null) + { + return attribute.Value != null ? attribute.Value : name; + } + } + + var converted = System.Convert.ToString(System.Convert.ChangeType(value, System.Enum.GetUnderlyingType(value.GetType()), cultureInfo)); + return converted == null ? string.Empty : converted; + } + } + else if (value is bool) + { + return System.Convert.ToString((bool)value, cultureInfo).ToLowerInvariant(); + } + else if (value is byte[]) + { + return System.Convert.ToBase64String((byte[]) value); + } + else if (value is string[]) + { + return string.Join(",", (string[])value); + } + else if (value.GetType().IsArray) + { + var valueArray = (System.Array)value; + var valueTextArray = new string[valueArray.Length]; + for (var i = 0; i < valueArray.Length; i++) + { + valueTextArray[i] = ConvertToString(valueArray.GetValue(i), cultureInfo); + } + return string.Join(",", valueTextArray); + } + + var result = System.Convert.ToString(value, cultureInfo); + return result == null ? "" : result; + } + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial interface IAuditsClient + { + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get audit events by correlation id + /// + /// + /// Retrieve audit events associated with a given correlation id. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task> ByCorrelationAsync(string correlationId, System.DateTimeOffset? fromUtc = null, System.DateTimeOffset? toUtc = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get audit events by trace id + /// + /// + /// Retrieve audit events associated with a given trace id. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task> ByTraceAsync(string traceId, System.DateTimeOffset? fromUtc = null, System.DateTimeOffset? toUtc = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get security-related audit events + /// + /// + /// Retrieve security audit events such as login, logout, and permission denials. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task> SecurityAsync(int? action = null, string userId = null, string tenantId = null, System.DateTimeOffset? fromUtc = null, System.DateTimeOffset? toUtc = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get exception audit events + /// + /// + /// Retrieve audit events related to exceptions. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task> ExceptionsAsync(int? area = null, int? severity = null, string exceptionType = null, string routeOrLocation = null, System.DateTimeOffset? fromUtc = null, System.DateTimeOffset? toUtc = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get audit summary + /// + /// + /// Retrieve aggregate counts of audit events by type, severity, source, and tenant. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task SummaryAsync(System.DateTimeOffset? fromUtc = null, System.DateTimeOffset? toUtc = null, string tenantId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class AuditsClient : IAuditsClient + { + private System.Net.Http.HttpClient _httpClient; + private static System.Lazy _settings = new System.Lazy(CreateSerializerSettings, true); + private System.Text.Json.JsonSerializerOptions _instanceSettings; + + #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public AuditsClient(System.Net.Http.HttpClient httpClient) + #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + { + _httpClient = httpClient; + Initialize(); + } + + private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() + { + var settings = new System.Text.Json.JsonSerializerOptions(); + UpdateJsonSerializerSettings(settings); + return settings; + } + + protected System.Text.Json.JsonSerializerOptions JsonSerializerSettings { get { return _instanceSettings ?? _settings.Value; } } + + static partial void UpdateJsonSerializerSettings(System.Text.Json.JsonSerializerOptions settings); + + partial void Initialize(); + + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, string url); + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, System.Text.StringBuilder urlBuilder); + partial void ProcessResponse(System.Net.Http.HttpClient client, System.Net.Http.HttpResponseMessage response); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get audit events by correlation id + /// + /// + /// Retrieve audit events associated with a given correlation id. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task> ByCorrelationAsync(string correlationId, System.DateTimeOffset? fromUtc = null, System.DateTimeOffset? toUtc = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (correlationId == null) + throw new System.ArgumentNullException("correlationId"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/audits/by-correlation/{correlationId}" + urlBuilder_.Append("api/v1/audits/by-correlation/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(correlationId, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append('?'); + if (fromUtc != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("fromUtc")).Append('=').Append(System.Uri.EscapeDataString(fromUtc.Value.ToString("o", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (toUtc != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("toUtc")).Append('=').Append(System.Uri.EscapeDataString(toUtc.Value.ToString("o", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get audit events by trace id + /// + /// + /// Retrieve audit events associated with a given trace id. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task> ByTraceAsync(string traceId, System.DateTimeOffset? fromUtc = null, System.DateTimeOffset? toUtc = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (traceId == null) + throw new System.ArgumentNullException("traceId"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/audits/by-trace/{traceId}" + urlBuilder_.Append("api/v1/audits/by-trace/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(traceId, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append('?'); + if (fromUtc != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("fromUtc")).Append('=').Append(System.Uri.EscapeDataString(fromUtc.Value.ToString("o", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (toUtc != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("toUtc")).Append('=').Append(System.Uri.EscapeDataString(toUtc.Value.ToString("o", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get security-related audit events + /// + /// + /// Retrieve security audit events such as login, logout, and permission denials. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task> SecurityAsync(int? action = null, string userId = null, string tenantId = null, System.DateTimeOffset? fromUtc = null, System.DateTimeOffset? toUtc = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/audits/security" + urlBuilder_.Append("api/v1/audits/security"); + urlBuilder_.Append('?'); + if (action != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Action")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(action, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (userId != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("UserId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(userId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (tenantId != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("TenantId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(tenantId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (fromUtc != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("FromUtc")).Append('=').Append(System.Uri.EscapeDataString(fromUtc.Value.ToString("o", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (toUtc != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("ToUtc")).Append('=').Append(System.Uri.EscapeDataString(toUtc.Value.ToString("o", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get exception audit events + /// + /// + /// Retrieve audit events related to exceptions. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task> ExceptionsAsync(int? area = null, int? severity = null, string exceptionType = null, string routeOrLocation = null, System.DateTimeOffset? fromUtc = null, System.DateTimeOffset? toUtc = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/audits/exceptions" + urlBuilder_.Append("api/v1/audits/exceptions"); + urlBuilder_.Append('?'); + if (area != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Area")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(area, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (severity != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Severity")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(severity, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (exceptionType != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("ExceptionType")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(exceptionType, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (routeOrLocation != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("RouteOrLocation")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(routeOrLocation, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (fromUtc != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("FromUtc")).Append('=').Append(System.Uri.EscapeDataString(fromUtc.Value.ToString("o", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (toUtc != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("ToUtc")).Append('=').Append(System.Uri.EscapeDataString(toUtc.Value.ToString("o", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get audit summary + /// + /// + /// Retrieve aggregate counts of audit events by type, severity, source, and tenant. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task SummaryAsync(System.DateTimeOffset? fromUtc = null, System.DateTimeOffset? toUtc = null, string tenantId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/v1/audits/summary" + urlBuilder_.Append("api/v1/audits/summary"); + urlBuilder_.Append('?'); + if (fromUtc != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("FromUtc")).Append('=').Append(System.Uri.EscapeDataString(fromUtc.Value.ToString("o", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (toUtc != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("ToUtc")).Append('=').Append(System.Uri.EscapeDataString(toUtc.Value.ToString("o", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (tenantId != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("TenantId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(tenantId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + protected struct ObjectResponseResult + { + public ObjectResponseResult(T responseObject, string responseText) + { + this.Object = responseObject; + this.Text = responseText; + } + + public T Object { get; } + + public string Text { get; } + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStringAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStringAsync(cancellationToken); + #else + return content.ReadAsStringAsync(); + #endif + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStreamAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStreamAsync(cancellationToken); + #else + return content.ReadAsStreamAsync(); + #endif + } + + public bool ReadResponseAsString { get; set; } + + protected virtual async System.Threading.Tasks.Task> ReadObjectResponseAsync(System.Net.Http.HttpResponseMessage response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Threading.CancellationToken cancellationToken) + { + if (response == null || response.Content == null) + { + return new ObjectResponseResult(default(T), string.Empty); + } + + if (ReadResponseAsString) + { + var responseText = await ReadAsStringAsync(response.Content, cancellationToken).ConfigureAwait(false); + try + { + var typedBody = System.Text.Json.JsonSerializer.Deserialize(responseText, JsonSerializerSettings); + return new ObjectResponseResult(typedBody, responseText); + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body string as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, responseText, headers, exception); + } + } + else + { + try + { + using (var responseStream = await ReadAsStreamAsync(response.Content, cancellationToken).ConfigureAwait(false)) + { + var typedBody = await System.Text.Json.JsonSerializer.DeserializeAsync(responseStream, JsonSerializerSettings, cancellationToken).ConfigureAwait(false); + return new ObjectResponseResult(typedBody, string.Empty); + } + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body stream as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, string.Empty, headers, exception); + } + } + } + + private string ConvertToString(object value, System.Globalization.CultureInfo cultureInfo) + { + if (value == null) + { + return ""; + } + + if (value is System.Enum) + { + var name = System.Enum.GetName(value.GetType(), value); + if (name != null) + { + var field_ = System.Reflection.IntrospectionExtensions.GetTypeInfo(value.GetType()).GetDeclaredField(name); + if (field_ != null) + { + var attribute = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(field_, typeof(System.Runtime.Serialization.EnumMemberAttribute)) + as System.Runtime.Serialization.EnumMemberAttribute; + if (attribute != null) + { + return attribute.Value != null ? attribute.Value : name; + } + } + + var converted = System.Convert.ToString(System.Convert.ChangeType(value, System.Enum.GetUnderlyingType(value.GetType()), cultureInfo)); + return converted == null ? string.Empty : converted; + } + } + else if (value is bool) + { + return System.Convert.ToString((bool)value, cultureInfo).ToLowerInvariant(); + } + else if (value is byte[]) + { + return System.Convert.ToBase64String((byte[]) value); + } + else if (value is string[]) + { + return string.Join(",", (string[])value); + } + else if (value.GetType().IsArray) + { + var valueArray = (System.Array)value; + var valueTextArray = new string[valueArray.Length]; + for (var i = 0; i < valueArray.Length; i++) + { + valueTextArray[i] = ConvertToString(valueArray.GetValue(i), cultureInfo); + } + return string.Join(",", valueTextArray); + } + + var result = System.Convert.ToString(value, cultureInfo); + return result == null ? "" : result; + } + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial interface IClient + { + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task IndexAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class Client : IClient + { + private System.Net.Http.HttpClient _httpClient; + private static System.Lazy _settings = new System.Lazy(CreateSerializerSettings, true); + private System.Text.Json.JsonSerializerOptions _instanceSettings; + + #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public Client(System.Net.Http.HttpClient httpClient) + #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + { + _httpClient = httpClient; + Initialize(); + } + + private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() + { + var settings = new System.Text.Json.JsonSerializerOptions(); + UpdateJsonSerializerSettings(settings); + return settings; + } + + protected System.Text.Json.JsonSerializerOptions JsonSerializerSettings { get { return _instanceSettings ?? _settings.Value; } } + + static partial void UpdateJsonSerializerSettings(System.Text.Json.JsonSerializerOptions settings); + + partial void Initialize(); + + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, string url); + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, System.Text.StringBuilder urlBuilder); + partial void ProcessResponse(System.Net.Http.HttpClient client, System.Net.Http.HttpResponseMessage response); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task IndexAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "" + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + return; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + protected struct ObjectResponseResult + { + public ObjectResponseResult(T responseObject, string responseText) + { + this.Object = responseObject; + this.Text = responseText; + } + + public T Object { get; } + + public string Text { get; } + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStringAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStringAsync(cancellationToken); + #else + return content.ReadAsStringAsync(); + #endif + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStreamAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStreamAsync(cancellationToken); + #else + return content.ReadAsStreamAsync(); + #endif + } + + public bool ReadResponseAsString { get; set; } + + protected virtual async System.Threading.Tasks.Task> ReadObjectResponseAsync(System.Net.Http.HttpResponseMessage response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Threading.CancellationToken cancellationToken) + { + if (response == null || response.Content == null) + { + return new ObjectResponseResult(default(T), string.Empty); + } + + if (ReadResponseAsString) + { + var responseText = await ReadAsStringAsync(response.Content, cancellationToken).ConfigureAwait(false); + try + { + var typedBody = System.Text.Json.JsonSerializer.Deserialize(responseText, JsonSerializerSettings); + return new ObjectResponseResult(typedBody, responseText); + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body string as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, responseText, headers, exception); + } + } + else + { + try + { + using (var responseStream = await ReadAsStreamAsync(response.Content, cancellationToken).ConfigureAwait(false)) + { + var typedBody = await System.Text.Json.JsonSerializer.DeserializeAsync(responseStream, JsonSerializerSettings, cancellationToken).ConfigureAwait(false); + return new ObjectResponseResult(typedBody, string.Empty); + } + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body stream as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, string.Empty, headers, exception); + } + } + } + + private string ConvertToString(object value, System.Globalization.CultureInfo cultureInfo) + { + if (value == null) + { + return ""; + } + + if (value is System.Enum) + { + var name = System.Enum.GetName(value.GetType(), value); + if (name != null) + { + var field_ = System.Reflection.IntrospectionExtensions.GetTypeInfo(value.GetType()).GetDeclaredField(name); + if (field_ != null) + { + var attribute = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(field_, typeof(System.Runtime.Serialization.EnumMemberAttribute)) + as System.Runtime.Serialization.EnumMemberAttribute; + if (attribute != null) + { + return attribute.Value != null ? attribute.Value : name; + } + } + + var converted = System.Convert.ToString(System.Convert.ChangeType(value, System.Enum.GetUnderlyingType(value.GetType()), cultureInfo)); + return converted == null ? string.Empty : converted; + } + } + else if (value is bool) + { + return System.Convert.ToString((bool)value, cultureInfo).ToLowerInvariant(); + } + else if (value is byte[]) + { + return System.Convert.ToBase64String((byte[]) value); + } + else if (value is string[]) + { + return string.Join(",", (string[])value); + } + else if (value.GetType().IsArray) + { + var valueArray = (System.Array)value; + var valueTextArray = new string[valueArray.Length]; + for (var i = 0; i < valueArray.Length; i++) + { + valueTextArray[i] = ConvertToString(valueArray.GetValue(i), cultureInfo); + } + return string.Join(",", valueTextArray); + } + + var result = System.Convert.ToString(value, cultureInfo); + return result == null ? "" : result; + } + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial interface IHealthClient + { + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Quick process liveness probe. + /// + /// + /// Reports if the API process is alive. Does not check dependencies. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task LiveAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Readiness probe with database check. + /// + /// + /// Returns 200 if all dependencies are healthy, otherwise 503. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task ReadyAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class HealthClient : IHealthClient + { + private System.Net.Http.HttpClient _httpClient; + private static System.Lazy _settings = new System.Lazy(CreateSerializerSettings, true); + private System.Text.Json.JsonSerializerOptions _instanceSettings; + + #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public HealthClient(System.Net.Http.HttpClient httpClient) + #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + { + _httpClient = httpClient; + Initialize(); + } + + private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() + { + var settings = new System.Text.Json.JsonSerializerOptions(); + UpdateJsonSerializerSettings(settings); + return settings; + } + + protected System.Text.Json.JsonSerializerOptions JsonSerializerSettings { get { return _instanceSettings ?? _settings.Value; } } + + static partial void UpdateJsonSerializerSettings(System.Text.Json.JsonSerializerOptions settings); + + partial void Initialize(); + + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, string url); + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, System.Text.StringBuilder urlBuilder); + partial void ProcessResponse(System.Net.Http.HttpClient client, System.Net.Http.HttpResponseMessage response); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Quick process liveness probe. + /// + /// + /// Reports if the API process is alive. Does not check dependencies. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task LiveAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "health/live" + urlBuilder_.Append("health/live"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Readiness probe with database check. + /// + /// + /// Returns 200 if all dependencies are healthy, otherwise 503. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task ReadyAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "health/ready" + urlBuilder_.Append("health/ready"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 503) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Service Unavailable", status_, responseText_, headers_, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + protected struct ObjectResponseResult + { + public ObjectResponseResult(T responseObject, string responseText) + { + this.Object = responseObject; + this.Text = responseText; + } + + public T Object { get; } + + public string Text { get; } + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStringAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStringAsync(cancellationToken); + #else + return content.ReadAsStringAsync(); + #endif + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStreamAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStreamAsync(cancellationToken); + #else + return content.ReadAsStreamAsync(); + #endif + } + + public bool ReadResponseAsString { get; set; } + + protected virtual async System.Threading.Tasks.Task> ReadObjectResponseAsync(System.Net.Http.HttpResponseMessage response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Threading.CancellationToken cancellationToken) + { + if (response == null || response.Content == null) + { + return new ObjectResponseResult(default(T), string.Empty); + } + + if (ReadResponseAsString) + { + var responseText = await ReadAsStringAsync(response.Content, cancellationToken).ConfigureAwait(false); + try + { + var typedBody = System.Text.Json.JsonSerializer.Deserialize(responseText, JsonSerializerSettings); + return new ObjectResponseResult(typedBody, responseText); + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body string as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, responseText, headers, exception); + } + } + else + { + try + { + using (var responseStream = await ReadAsStreamAsync(response.Content, cancellationToken).ConfigureAwait(false)) + { + var typedBody = await System.Text.Json.JsonSerializer.DeserializeAsync(responseStream, JsonSerializerSettings, cancellationToken).ConfigureAwait(false); + return new ObjectResponseResult(typedBody, string.Empty); + } + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body stream as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, string.Empty, headers, exception); + } + } + } + + private string ConvertToString(object value, System.Globalization.CultureInfo cultureInfo) + { + if (value == null) + { + return ""; + } + + if (value is System.Enum) + { + var name = System.Enum.GetName(value.GetType(), value); + if (name != null) + { + var field_ = System.Reflection.IntrospectionExtensions.GetTypeInfo(value.GetType()).GetDeclaredField(name); + if (field_ != null) + { + var attribute = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(field_, typeof(System.Runtime.Serialization.EnumMemberAttribute)) + as System.Runtime.Serialization.EnumMemberAttribute; + if (attribute != null) + { + return attribute.Value != null ? attribute.Value : name; + } + } + + var converted = System.Convert.ToString(System.Convert.ChangeType(value, System.Enum.GetUnderlyingType(value.GetType()), cultureInfo)); + return converted == null ? string.Empty : converted; + } + } + else if (value is bool) + { + return System.Convert.ToString((bool)value, cultureInfo).ToLowerInvariant(); + } + else if (value is byte[]) + { + return System.Convert.ToBase64String((byte[]) value); + } + else if (value is string[]) + { + return string.Join(",", (string[])value); + } + else if (value.GetType().IsArray) + { + var valueArray = (System.Array)value; + var valueTextArray = new string[valueArray.Length]; + for (var i = 0; i < valueArray.Length; i++) + { + valueTextArray[i] = ConvertToString(valueArray.GetValue(i), cultureInfo); + } + return string.Join(",", valueTextArray); + } + + var result = System.Convert.ToString(value, cultureInfo); + return result == null ? "" : result; + } + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class AddUsersRequest + { + + [System.Text.Json.Serialization.JsonPropertyName("userIds")] + [System.ComponentModel.DataAnnotations.Required] + public System.Collections.Generic.ICollection UserIds { get; set; } = new System.Collections.ObjectModel.Collection(); + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class AddUsersToGroupResponse + { + + [System.Text.Json.Serialization.JsonPropertyName("addedCount")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + [System.ComponentModel.DataAnnotations.RegularExpression(@"^-?(?:0|[1-9]\d*)$")] + public int AddedCount { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("alreadyMemberUserIds")] + [System.ComponentModel.DataAnnotations.Required] + public System.Collections.Generic.ICollection AlreadyMemberUserIds { get; set; } = new System.Collections.ObjectModel.Collection(); + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class AdminRevokeAllSessionsCommand + { + + [System.Text.Json.Serialization.JsonPropertyName("userId")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public System.Guid UserId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("reason")] + public string Reason { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class AnonymousTypeOfint + { + + [System.Text.Json.Serialization.JsonPropertyName("revokedCount")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + [System.ComponentModel.DataAnnotations.RegularExpression(@"^-?(?:0|[1-9]\d*)$")] + public int RevokedCount { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class AssignUserRolesCommand + { + + [System.Text.Json.Serialization.JsonPropertyName("userId")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string UserId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("userRoles")] + public System.Collections.Generic.ICollection UserRoles { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class AuditDetailDto + { + + [System.Text.Json.Serialization.JsonPropertyName("id")] + public System.Guid Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("occurredAtUtc")] + public System.DateTimeOffset OccurredAtUtc { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("receivedAtUtc")] + public System.DateTimeOffset ReceivedAtUtc { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("eventType")] + public int EventType { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("severity")] + public int Severity { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("tenantId")] + public string TenantId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("userId")] + public string UserId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("userName")] + public string UserName { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("traceId")] + public string TraceId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("spanId")] + public string SpanId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("correlationId")] + public string CorrelationId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("requestId")] + public string RequestId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("source")] + public string Source { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("tags")] + public int Tags { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("payload")] + public JsonElement Payload { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class AuditSummaryAggregateDto + { + + [System.Text.Json.Serialization.JsonPropertyName("eventsByType")] + public System.Collections.Generic.IDictionary EventsByType { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("eventsBySeverity")] + public System.Collections.Generic.IDictionary EventsBySeverity { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("eventsBySource")] + public System.Collections.Generic.IDictionary EventsBySource { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("eventsByTenant")] + public System.Collections.Generic.IDictionary EventsByTenant { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class AuditSummaryDto + { + + [System.Text.Json.Serialization.JsonPropertyName("id")] + public System.Guid Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("occurredAtUtc")] + public System.DateTimeOffset OccurredAtUtc { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("eventType")] + public int EventType { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("severity")] + public int Severity { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("tenantId")] + public string TenantId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("userId")] + public string UserId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("userName")] + public string UserName { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("traceId")] + public string TraceId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("correlationId")] + public string CorrelationId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("requestId")] + public string RequestId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("source")] + public string Source { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("tags")] + public int Tags { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class BrandAssetsDto + { + + [System.Text.Json.Serialization.JsonPropertyName("logoUrl")] + public string LogoUrl { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("logoDarkUrl")] + public string LogoDarkUrl { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("faviconUrl")] + public string FaviconUrl { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("logo")] + public FileUploadRequest Logo { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("logoDark")] + public FileUploadRequest LogoDark { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("favicon")] + public FileUploadRequest Favicon { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("deleteLogo")] + public bool DeleteLogo { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("deleteLogoDark")] + public bool DeleteLogoDark { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("deleteFavicon")] + public bool DeleteFavicon { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class ChangePasswordCommand + { + + [System.Text.Json.Serialization.JsonPropertyName("password")] + public string Password { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("newPassword")] + public string NewPassword { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("confirmNewPassword")] + public string ConfirmNewPassword { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class ChangeTenantActivationCommand + { + + [System.Text.Json.Serialization.JsonPropertyName("tenantId")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string TenantId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("isActive")] + public bool IsActive { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class CreateGroupCommand + { + + [System.Text.Json.Serialization.JsonPropertyName("name")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Name { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("description")] + public string Description { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("isDefault")] + public bool IsDefault { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("roleIds")] + public System.Collections.Generic.ICollection RoleIds { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class CreateTenantCommand + { + + [System.Text.Json.Serialization.JsonPropertyName("id")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("name")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Name { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("connectionString")] + public string ConnectionString { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("adminEmail")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string AdminEmail { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("issuer")] + public string Issuer { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class CreateTenantCommandResponse + { + + [System.Text.Json.Serialization.JsonPropertyName("id")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("provisioningCorrelationId")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string ProvisioningCorrelationId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("status")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Status { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class FileUploadRequest + { + + [System.Text.Json.Serialization.JsonPropertyName("fileName")] + public string FileName { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("contentType")] + public string ContentType { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("data")] + public System.Collections.Generic.ICollection Data { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class GenerateTokenCommand + { + + [System.Text.Json.Serialization.JsonPropertyName("email")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Email { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("password")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Password { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class GroupDto + { + + [System.Text.Json.Serialization.JsonPropertyName("id")] + public System.Guid Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("name")] + public string Name { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("description")] + public string Description { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("isDefault")] + public bool IsDefault { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("isSystemGroup")] + public bool IsSystemGroup { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("memberCount")] + [System.ComponentModel.DataAnnotations.RegularExpression(@"^-?(?:0|[1-9]\d*)$")] + public int MemberCount { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("roleIds")] + public System.Collections.Generic.ICollection RoleIds { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("roleNames")] + public System.Collections.Generic.ICollection RoleNames { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("createdAt")] + public System.DateTimeOffset CreatedAt { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class GroupMemberDto + { + + [System.Text.Json.Serialization.JsonPropertyName("userId")] + public string UserId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("userName")] + public string UserName { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("email")] + public string Email { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("firstName")] + public string FirstName { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("lastName")] + public string LastName { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("addedAt")] + public System.DateTimeOffset AddedAt { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("addedBy")] + public string AddedBy { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class HealthEntry + { + + [System.Text.Json.Serialization.JsonPropertyName("name")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Name { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("status")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Status { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("description")] + public string Description { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("durationMs")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + [System.ComponentModel.DataAnnotations.RegularExpression(@"^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$")] + public double DurationMs { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("details")] + public object Details { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class HealthResult + { + + [System.Text.Json.Serialization.JsonPropertyName("status")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Status { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("results")] + [System.ComponentModel.DataAnnotations.Required] + public System.Collections.Generic.ICollection Results { get; set; } = new System.Collections.ObjectModel.Collection(); + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class JsonElement + { + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class LayoutDto + { + + [System.Text.Json.Serialization.JsonPropertyName("borderRadius")] + public string BorderRadius { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("defaultElevation")] + [System.ComponentModel.DataAnnotations.RegularExpression(@"^-?(?:0|[1-9]\d*)$")] + public int DefaultElevation { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class PagedResponseOfAuditSummaryDto + { + + [System.Text.Json.Serialization.JsonPropertyName("items")] + public System.Collections.Generic.ICollection Items { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("pageNumber")] + [System.ComponentModel.DataAnnotations.RegularExpression(@"^-?(?:0|[1-9]\d*)$")] + public int PageNumber { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("pageSize")] + [System.ComponentModel.DataAnnotations.RegularExpression(@"^-?(?:0|[1-9]\d*)$")] + public int PageSize { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("totalCount")] + [System.ComponentModel.DataAnnotations.RegularExpression(@"^-?(?:0|[1-9]\d*)$")] + public long TotalCount { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("totalPages")] + [System.ComponentModel.DataAnnotations.RegularExpression(@"^-?(?:0|[1-9]\d*)$")] + public int TotalPages { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("hasNext")] + public bool HasNext { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("hasPrevious")] + public bool HasPrevious { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class PagedResponseOfTenantDto + { + + [System.Text.Json.Serialization.JsonPropertyName("items")] + public System.Collections.Generic.ICollection Items { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("pageNumber")] + [System.ComponentModel.DataAnnotations.RegularExpression(@"^-?(?:0|[1-9]\d*)$")] + public int PageNumber { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("pageSize")] + [System.ComponentModel.DataAnnotations.RegularExpression(@"^-?(?:0|[1-9]\d*)$")] + public int PageSize { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("totalCount")] + [System.ComponentModel.DataAnnotations.RegularExpression(@"^-?(?:0|[1-9]\d*)$")] + public long TotalCount { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("totalPages")] + [System.ComponentModel.DataAnnotations.RegularExpression(@"^-?(?:0|[1-9]\d*)$")] + public int TotalPages { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("hasNext")] + public bool HasNext { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("hasPrevious")] + public bool HasPrevious { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class PagedResponseOfUserDto + { + + [System.Text.Json.Serialization.JsonPropertyName("items")] + public System.Collections.Generic.ICollection Items { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("pageNumber")] + [System.ComponentModel.DataAnnotations.RegularExpression(@"^-?(?:0|[1-9]\d*)$")] + public int PageNumber { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("pageSize")] + [System.ComponentModel.DataAnnotations.RegularExpression(@"^-?(?:0|[1-9]\d*)$")] + public int PageSize { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("totalCount")] + [System.ComponentModel.DataAnnotations.RegularExpression(@"^-?(?:0|[1-9]\d*)$")] + public long TotalCount { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("totalPages")] + [System.ComponentModel.DataAnnotations.RegularExpression(@"^-?(?:0|[1-9]\d*)$")] + public int TotalPages { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("hasNext")] + public bool HasNext { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("hasPrevious")] + public bool HasPrevious { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class PaletteDto + { + + [System.Text.Json.Serialization.JsonPropertyName("primary")] + public string Primary { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("secondary")] + public string Secondary { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("tertiary")] + public string Tertiary { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("background")] + public string Background { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("surface")] + public string Surface { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("error")] + public string Error { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("warning")] + public string Warning { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("success")] + public string Success { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("info")] + public string Info { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class RefreshTokenCommand + { + + [System.Text.Json.Serialization.JsonPropertyName("token")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Token { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("refreshToken")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string RefreshToken { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class RefreshTokenCommandResponse + { + + [System.Text.Json.Serialization.JsonPropertyName("token")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Token { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("refreshToken")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string RefreshToken { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("refreshTokenExpiryTime")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public System.DateTimeOffset RefreshTokenExpiryTime { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class RegisterUserCommand + { + + [System.Text.Json.Serialization.JsonPropertyName("firstName")] + public string FirstName { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("lastName")] + public string LastName { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("email")] + public string Email { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("userName")] + public string UserName { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("password")] + public string Password { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("confirmPassword")] + public string ConfirmPassword { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("phoneNumber")] + public string PhoneNumber { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class RegisterUserResponse + { + + [System.Text.Json.Serialization.JsonPropertyName("userId")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string UserId { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class ResetPasswordCommand + { + + [System.Text.Json.Serialization.JsonPropertyName("email")] + public string Email { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("password")] + public string Password { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("token")] + public string Token { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class RevokeAllSessionsCommand + { + + [System.Text.Json.Serialization.JsonPropertyName("exceptSessionId")] + public System.Guid? ExceptSessionId { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class RoleDto + { + + [System.Text.Json.Serialization.JsonPropertyName("id")] + public string Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("name")] + public string Name { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("description")] + public string Description { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("permissions")] + public System.Collections.Generic.ICollection Permissions { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class TenantDto + { + + [System.Text.Json.Serialization.JsonPropertyName("id")] + public string Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("name")] + public string Name { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("connectionString")] + public string ConnectionString { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("adminEmail")] + public string AdminEmail { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("isActive")] + public bool IsActive { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("validUpto")] + public System.DateTimeOffset ValidUpto { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("issuer")] + public string Issuer { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class TenantLifecycleResultDto + { + + [System.Text.Json.Serialization.JsonPropertyName("tenantId")] + public string TenantId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("isActive")] + public bool IsActive { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("validUpto")] + public System.DateTimeOffset? ValidUpto { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("message")] + public string Message { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class TenantProvisioningStatusDto + { + + [System.Text.Json.Serialization.JsonPropertyName("tenantId")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string TenantId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("status")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Status { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("correlationId")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string CorrelationId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("currentStep")] + public string CurrentStep { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("error")] + public string Error { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("createdUtc")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public System.DateTimeOffset CreatedUtc { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("startedUtc")] + public System.DateTimeOffset? StartedUtc { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("completedUtc")] + public System.DateTimeOffset? CompletedUtc { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("steps")] + [System.ComponentModel.DataAnnotations.Required] + public System.Collections.Generic.ICollection Steps { get; set; } = new System.Collections.ObjectModel.Collection(); + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class TenantProvisioningStepDto + { + + [System.Text.Json.Serialization.JsonPropertyName("step")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Step { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("status")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Status { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("startedUtc")] + public System.DateTimeOffset? StartedUtc { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("completedUtc")] + public System.DateTimeOffset? CompletedUtc { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("error")] + public string Error { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class TenantStatusDto + { + + [System.Text.Json.Serialization.JsonPropertyName("id")] + public string Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("name")] + public string Name { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("isActive")] + public bool IsActive { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("validUpto")] + public System.DateTimeOffset ValidUpto { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("hasConnectionString")] + public bool HasConnectionString { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("adminEmail")] + public string AdminEmail { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("issuer")] + public string Issuer { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class TenantThemeDto + { + + [System.Text.Json.Serialization.JsonPropertyName("lightPalette")] + public PaletteDto LightPalette { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("darkPalette")] + public PaletteDto DarkPalette { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("brandAssets")] + public BrandAssetsDto BrandAssets { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("typography")] + public TypographyDto Typography { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("layout")] + public LayoutDto Layout { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("isDefault")] + public bool IsDefault { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class ToggleUserStatusCommand + { + + [System.Text.Json.Serialization.JsonPropertyName("activateUser")] + public bool ActivateUser { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("userId")] + public string UserId { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class TokenResponse + { + + [System.Text.Json.Serialization.JsonPropertyName("accessToken")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string AccessToken { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("refreshToken")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string RefreshToken { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("refreshTokenExpiresAt")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public System.DateTimeOffset RefreshTokenExpiresAt { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("accessTokenExpiresAt")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public System.DateTimeOffset AccessTokenExpiresAt { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class TypographyDto + { + + [System.Text.Json.Serialization.JsonPropertyName("fontFamily")] + public string FontFamily { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("headingFontFamily")] + public string HeadingFontFamily { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("fontSizeBase")] + [System.ComponentModel.DataAnnotations.RegularExpression(@"^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$")] + public double FontSizeBase { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("lineHeightBase")] + [System.ComponentModel.DataAnnotations.RegularExpression(@"^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$")] + public double LineHeightBase { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class Unit + { + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class UpdateGroupRequest + { + + [System.Text.Json.Serialization.JsonPropertyName("name")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Name { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("description")] + public string Description { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("isDefault")] + public bool IsDefault { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("roleIds")] + public System.Collections.Generic.ICollection RoleIds { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class UpdatePermissionsCommand + { + + [System.Text.Json.Serialization.JsonPropertyName("roleId")] + public string RoleId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("permissions")] + public System.Collections.Generic.ICollection Permissions { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class UpdateUserCommand + { + + [System.Text.Json.Serialization.JsonPropertyName("id")] + public string Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("firstName")] + public string FirstName { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("lastName")] + public string LastName { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("phoneNumber")] + public string PhoneNumber { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("email")] + public string Email { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("image")] + public FileUploadRequest Image { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("deleteCurrentImage")] + public bool DeleteCurrentImage { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class UpgradeTenantCommand + { + + [System.Text.Json.Serialization.JsonPropertyName("tenant")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Tenant { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("extendedExpiryDate")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public System.DateTimeOffset ExtendedExpiryDate { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class UpsertRoleCommand + { + + [System.Text.Json.Serialization.JsonPropertyName("id")] + public string Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("name")] + public string Name { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("description")] + public string Description { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class UserDto + { + + [System.Text.Json.Serialization.JsonPropertyName("id")] + public string Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("userName")] + public string UserName { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("firstName")] + public string FirstName { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("lastName")] + public string LastName { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("email")] + public string Email { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("isActive")] + public bool IsActive { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("emailConfirmed")] + public bool EmailConfirmed { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("phoneNumber")] + public string PhoneNumber { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("imageUrl")] + public string ImageUrl { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class UserRoleDto + { + + [System.Text.Json.Serialization.JsonPropertyName("roleId")] + public string RoleId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("roleName")] + public string RoleName { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("description")] + public string Description { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("enabled")] + public bool Enabled { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class UserSessionDto + { + + [System.Text.Json.Serialization.JsonPropertyName("id")] + public System.Guid Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("userId")] + public string UserId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("userName")] + public string UserName { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("userEmail")] + public string UserEmail { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("ipAddress")] + public string IpAddress { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("deviceType")] + public string DeviceType { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("browser")] + public string Browser { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("browserVersion")] + public string BrowserVersion { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("operatingSystem")] + public string OperatingSystem { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("osVersion")] + public string OsVersion { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("createdAt")] + public System.DateTimeOffset CreatedAt { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("lastActivityAt")] + public System.DateTimeOffset LastActivityAt { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("expiresAt")] + public System.DateTimeOffset ExpiresAt { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("isActive")] + public bool IsActive { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("isCurrentSession")] + public bool IsCurrentSession { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class ApiException : System.Exception + { + public int StatusCode { get; private set; } + + public string Response { get; private set; } + + public System.Collections.Generic.IReadOnlyDictionary> Headers { get; private set; } + + public ApiException(string message, int statusCode, string response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Exception innerException) + : base(message + "\n\nStatus: " + statusCode + "\nResponse: \n" + ((response == null) ? "(null)" : response.Substring(0, response.Length >= 512 ? 512 : response.Length)), innerException) + { + StatusCode = statusCode; + Response = response; + Headers = headers; + } + + public override string ToString() + { + return string.Format("HTTP Response: \n\n{0}\n\n{1}", Response, base.ToString()); + } + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class ApiException : ApiException + { + public TResult Result { get; private set; } + + public ApiException(string message, int statusCode, string response, System.Collections.Generic.IReadOnlyDictionary> headers, TResult result, System.Exception innerException) + : base(message, statusCode, response, headers, innerException) + { + Result = result; + } + } + +} + +#pragma warning restore 108 +#pragma warning restore 114 +#pragma warning restore 472 +#pragma warning restore 612 +#pragma warning restore 649 +#pragma warning restore 1573 +#pragma warning restore 1591 +#pragma warning restore 8073 +#pragma warning restore 3016 +#pragma warning restore 8600 +#pragma warning restore 8602 +#pragma warning restore 8603 +#pragma warning restore 8604 +#pragma warning restore 8625 +#pragma warning restore 8765 \ No newline at end of file diff --git a/src/Playground/Playground.Blazor/Components/App.razor b/src/Playground/Playground.Blazor/Components/App.razor new file mode 100644 index 0000000000..25593fbe46 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/App.razor @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Playground/Playground.Blazor/Components/Layout/EmptyLayout.razor b/src/Playground/Playground.Blazor/Components/Layout/EmptyLayout.razor new file mode 100644 index 0000000000..f467d00816 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Layout/EmptyLayout.razor @@ -0,0 +1,8 @@ +@inherits LayoutComponentBase + + + + + + +@Body diff --git a/src/Playground/Playground.Blazor/Components/Layout/NavMenu.razor b/src/Playground/Playground.Blazor/Components/Layout/NavMenu.razor new file mode 100644 index 0000000000..30d0fbcd21 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Layout/NavMenu.razor @@ -0,0 +1,119 @@ +@using FSH.Framework.Shared.Constants +@using FSH.Framework.Shared.Multitenancy +@using Microsoft.AspNetCore.Components.Authorization +@inject NavigationManager Navigation +@inject AuthenticationStateProvider AuthenticationStateProvider + + + +@code { + private bool _isRootTenantAdmin; + + protected override async Task OnInitializedAsync() + { + await CheckRootTenantAdmin(); + } + + private async Task CheckRootTenantAdmin() + { + try + { + var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + + if (user.Identity?.IsAuthenticated != true) + { + _isRootTenantAdmin = false; + return; + } + + var tenant = user.FindFirst(CustomClaims.Tenant)?.Value; + var isRootTenant = string.Equals(tenant, MultitenancyConstants.Root.Id, StringComparison.OrdinalIgnoreCase); + + var roles = user.FindAll(System.Security.Claims.ClaimTypes.Role).Select(c => c.Value).ToList(); + var isAdmin = roles.Any(r => string.Equals(r, RoleConstants.Admin, StringComparison.OrdinalIgnoreCase)); + + _isRootTenantAdmin = isRootTenant && isAdmin; + } + catch + { + _isRootTenantAdmin = false; + } + } +} diff --git a/src/Playground/Playground.Blazor/Components/Layout/NavMenu.razor.css b/src/Playground/Playground.Blazor/Components/Layout/NavMenu.razor.css new file mode 100644 index 0000000000..cfc56b8a03 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Layout/NavMenu.razor.css @@ -0,0 +1,131 @@ +.fsh-nav { + display: flex; + flex-direction: column; + padding: 0 8px; +} + +.fsh-nav-section-title { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--mud-palette-text-secondary); + padding: 16px 12px 6px 12px; + opacity: 0.6; +} + +::deep .fsh-nav-menu { + padding: 0; +} + +/* Main nav items */ +::deep .fsh-nav-item { + border-radius: 8px !important; + margin: 2px 0 !important; + padding: 8px 12px !important; + font-weight: 500 !important; + font-size: 13px !important; + min-height: 38px !important; + transition: all 0.15s ease !important; +} + +::deep .fsh-nav-item:hover { + background-color: rgba(var(--mud-palette-primary-rgb), 0.08) !important; +} + +::deep .fsh-nav-item.active { + background-color: rgba(var(--mud-palette-primary-rgb), 0.12) !important; + color: var(--mud-palette-primary) !important; +} + +::deep .fsh-nav-item .mud-nav-link-icon { + margin-right: 12px !important; + font-size: 20px !important; + opacity: 0.75; +} + +::deep .fsh-nav-item:hover .mud-nav-link-icon, +::deep .fsh-nav-item.active .mud-nav-link-icon { + opacity: 1; + color: var(--mud-palette-primary); +} + +/* Nav groups (expandable) */ +::deep .fsh-nav-group { + margin: 2px 0 !important; +} + +::deep .fsh-nav-group > .mud-nav-link { + border-radius: 8px !important; + padding: 8px 12px !important; + font-weight: 500 !important; + font-size: 13px !important; + min-height: 38px !important; + transition: all 0.15s ease !important; +} + +::deep .fsh-nav-group > .mud-nav-link:hover { + background-color: rgba(var(--mud-palette-primary-rgb), 0.08) !important; +} + +::deep .fsh-nav-group > .mud-nav-link .mud-nav-link-icon { + margin-right: 12px !important; + font-size: 20px !important; + opacity: 0.75; +} + +::deep .fsh-nav-group.mud-expanded > .mud-nav-link { + background-color: rgba(var(--mud-palette-primary-rgb), 0.06) !important; +} + +::deep .fsh-nav-group.mud-expanded > .mud-nav-link .mud-nav-link-icon { + color: var(--mud-palette-primary); + opacity: 1; +} + +/* Sub-items inside nav groups */ +::deep .fsh-nav-subitem { + border-radius: 8px !important; + margin: 2px 0 2px 20px !important; + padding: 8px 12px !important; + font-weight: 400 !important; + font-size: 13px !important; + min-height: 38px !important; + transition: all 0.15s ease !important; +} + +::deep .fsh-nav-subitem .mud-nav-link-icon { + margin-right: 12px !important; + font-size: 18px !important; + opacity: 0.7; +} + +::deep .fsh-nav-subitem:hover { + background-color: rgba(var(--mud-palette-primary-rgb), 0.08) !important; +} + +::deep .fsh-nav-subitem:hover .mud-nav-link-icon { + opacity: 1; + color: var(--mud-palette-primary); +} + +::deep .fsh-nav-subitem.active { + background-color: rgba(var(--mud-palette-primary-rgb), 0.12) !important; + color: var(--mud-palette-primary) !important; + font-weight: 500 !important; +} + +::deep .fsh-nav-subitem.active .mud-nav-link-icon { + opacity: 1; + color: var(--mud-palette-primary); +} + +/* Collapse panel styling */ +::deep .mud-navgroup-collapse { + padding: 2px 0 !important; +} + +/* Expand arrow */ +::deep .fsh-nav-group .mud-expand-panel-header .mud-collapse-icon { + font-size: 18px !important; +} diff --git a/src/Playground/Playground.Blazor/Components/Layout/PlaygroundLayout.razor b/src/Playground/Playground.Blazor/Components/Layout/PlaygroundLayout.razor new file mode 100644 index 0000000000..392f490ed7 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Layout/PlaygroundLayout.razor @@ -0,0 +1,264 @@ +@inherits LayoutComponentBase +@implements IDisposable +@using FSH.Framework.Blazor.UI.Components.Button +@using FSH.Framework.Blazor.UI.Components.Layouts +@using FSH.Framework.Blazor.UI.Components.User +@using FSH.Framework.Blazor.UI.Components.Dialogs +@using FSH.Framework.Blazor.UI.Theme +@using FSH.Playground.Blazor.Components.Pages +@using Microsoft.AspNetCore.WebUtilities +@using FSH.Playground.Blazor.Services +@using Microsoft.AspNetCore.Components.Authorization +@using System.Security.Claims +@inject IHttpContextAccessor HttpContextAccessor +@inject AuthenticationStateProvider AuthenticationStateProvider +@inject IHttpClientFactory HttpClientFactory +@inject NavigationManager Navigation +@inject ISnackbar Snackbar +@inject MudTheme FshTheme +@inject ITenantThemeState TenantThemeState +@inject IThemeStateFactory ThemeStateFactory +@inject FSH.Playground.Blazor.ApiClient.IIdentityClient IdentityClient +@inject IUserProfileState UserProfileState +@inject IDialogService DialogService + + + + + + +@if (!_authStatusLoaded) +{ + + + +} +else if (!_isAuthenticated) +{ + +} +else +{ + + + + + + + + + + + + + + + + @Body + + + +} + +
+ An unhandled error has occurred. + Reload + ?? +
+ +@code { + private bool _drawerOpen = true; + private bool _isDarkMode = false; + private MudTheme? _theme = null; + private bool _authStatusLoaded; + private bool _isAuthenticated; + private string _userName = "User"; + private string? _userEmail; + private string? _userRole; + private string? _avatarUrl; + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + // SSR-friendly authentication check via HttpContext + var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); + _isAuthenticated = authState.User?.Identity?.IsAuthenticated ?? false; + + if (_isAuthenticated) + { + // Extract user info from claims (available in SSR) + var user = authState.User!; + _userName = user.FindFirst(ClaimTypes.Name)?.Value ?? user.FindFirst(ClaimTypes.Email)?.Value ?? "User"; + _userEmail = user.FindFirst(ClaimTypes.Email)?.Value; + _userRole = user.FindFirst(ClaimTypes.Role)?.Value; + + // Load theme from cache (fast, SSR-compatible) + var httpContext = HttpContextAccessor.HttpContext; + if (httpContext is not null) + { + var tenantId = httpContext.Request.Cookies["fsh_tenant"] ?? "root"; + var themeSettings = await ThemeStateFactory.GetThemeAsync(tenantId); + _theme = themeSettings.ToMudTheme(); + // Dark mode preference is user-specific, default to false for SSR + _isDarkMode = false; + } + } + else + { + // Use default theme for non-authenticated users + _theme = TenantThemeState.Theme; + _isDarkMode = false; + } + + // Subscribe to theme changes (for Interactive mode) + TenantThemeState.OnThemeChanged += HandleThemeChanged; + + // Subscribe to profile changes (for syncing across components) + UserProfileState.OnProfileChanged += HandleProfileChanged; + + _authStatusLoaded = true; + } + + private void HandleThemeChanged() + { + _theme = TenantThemeState.Theme; + _isDarkMode = TenantThemeState.IsDarkMode; + InvokeAsync(StateHasChanged); + } + + private void HandleProfileChanged() + { + _userName = UserProfileState.UserName; + _userEmail = UserProfileState.UserEmail; + _userRole = UserProfileState.UserRole; + _avatarUrl = UserProfileState.AvatarUrl; + InvokeAsync(StateHasChanged); + } + + public void Dispose() + { + TenantThemeState.OnThemeChanged -= HandleThemeChanged; + UserProfileState.OnProfileChanged -= HandleProfileChanged; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (firstRender && _isAuthenticated) + { + // Load full profile in Interactive mode + await LoadUserProfileAsync(); + + // Handle toast notifications + var currentUri = new Uri(Navigation.Uri); + var query = QueryHelpers.ParseQuery(currentUri.Query); + if (query.TryGetValue("toast", out var toastValues)) + { + var toast = toastValues.ToString(); + if (toast == "login_success") + { + Snackbar.Add("Signed in.", Severity.Success); + } + else if (toast == "logout_success") + { + Snackbar.Add("Signed out.", Severity.Success); + } + else if (toast == "session_expired") + { + Snackbar.Add("Your session has expired. Please sign in again.", Severity.Warning); + } + + var cleanUri = currentUri.GetLeftPart(UriPartial.Path); + Navigation.NavigateTo(cleanUri, false); + } + + StateHasChanged(); + } + } + + private async Task ConfirmAndLogoutAsync() + { + var confirmed = await DialogService.ShowSignOutConfirmAsync(); + if (confirmed) + { + await LogoutAsync(); + } + } + + private Task LogoutAsync() + { + // Use browser navigation to ensure the cookie is cleared properly + // The server will clear the auth cookie and redirect to login page + Navigation.NavigateTo("/auth/logout", forceLoad: true); + return Task.CompletedTask; + } + + private void DrawerToggle() + { + _drawerOpen = !_drawerOpen; + } + + private void DarkModeToggle() + { + TenantThemeState.ToggleDarkMode(); + } + + public string DarkLightModeButtonIcon => _isDarkMode switch + { + true => Icons.Material.Rounded.AutoMode, + false => Icons.Material.Outlined.DarkMode, + }; + + private async Task LoadUserProfileAsync() + { + try + { + var profile = await IdentityClient.ProfileGetAsync(); + if (profile is not null) + { + _userName = $"{profile.FirstName} {profile.LastName}".Trim(); + if (string.IsNullOrWhiteSpace(_userName)) + { + _userName = profile.Email ?? "User"; + } + _userEmail = profile.Email; + _avatarUrl = profile.ImageUrl; + + // Update shared profile state so other components can access it + UserProfileState.UpdateProfile(_userName, _userEmail, _userRole, _avatarUrl); + + StateHasChanged(); + } + } + catch + { + // Use defaults if profile loading fails + _userName = "User"; + } + } + + private void NavigateToProfile() + { + Navigation.NavigateTo("/profile"); + } + + private void NavigateToSettings() + { + Navigation.NavigateTo("/settings/profile"); + } + + private void NavigateToAuditing() + { + Navigation.NavigateTo("/audits"); + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Audits.razor b/src/Playground/Playground.Blazor/Components/Pages/Audits.razor new file mode 100644 index 0000000000..978221e5a9 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/Audits.razor @@ -0,0 +1,1217 @@ +@page "/audits" +@using System.Linq +@using System.Text +@using System.Text.Json +@using MudBlazor + + + + + + + + Export as CSV + + + + + + Export as JSON + + + + + + + + +@if (_showSummary && _summary != null) +{ + + + + + + + + Total + + @GetTotalEvents() + Total Events + + + + + + + + + + + Errors + + @GetEventsBySeverity("Error") + Error Events + + + + + + + + + + + Security + + @GetEventsByType("Security") + Security Events + + + + + + + + + + + Sources + + @GetSourcesCount() + Unique Sources + + + + + +} + + + + + Quick Filters: + Last Hour + Last 24 Hours + Last 7 Days + + Errors Only + Security Events + Exceptions + + Clear All + + + + + + + + + + Advanced Filters + @if (HasActiveFilters()) + { + @GetActiveFiltersCount() Active + } + + + + + + + + + + + + + + + + + + + + + All Types + @foreach (var value in _eventTypes) + { + + @value + + } + + + + + All Severities + @foreach (var value in _severities) + { + + @value + + } + + + + + + + + + + + + + + + + + + + Apply Filters + + + Reset All + + + + + + + + + + Timestamp + Event Type + Severity + User + Tenant + Source + Tracking + Actions + + + + + + @context.OccurredAtUtc.ToLocalTime().ToString("MMM dd, yyyy") + + + @context.OccurredAtUtc.ToLocalTime().ToString("HH:mm:ss") + + + + + + @FormatEventType(context.EventType) + + + + + @FormatSeverity(context.Severity) + + + + + @(string.IsNullOrEmpty(context.UserName) ? "System" : context.UserName) + @if (!string.IsNullOrEmpty(context.UserId) && context.UserId != context.UserName) + { + @context.UserId + } + + + + + @(string.IsNullOrEmpty(context.TenantId) ? "-" : context.TenantId) + + + + + + @context.Source + + + + + + @if (!string.IsNullOrEmpty(context.CorrelationId)) + { + + + + } + @if (!string.IsNullOrEmpty(context.TraceId)) + { + + + + } + + + + + @(IsExpanded(context.Id) ? "Hide" : "Details") + + + + + @if (IsExpanded(context.Id)) + { + + @if (TryGetDetail(context.Id, out var detail) && detail is not null) + { + + + + + + + + + Event Information + + + + + + Event Type: + + @FormatEventType(detail.EventType) + + + + Severity: + + @FormatSeverity(detail.Severity) + + + + + Occurred At: + @detail.OccurredAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.fff") + + + Received At: + @detail.ReceivedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.fff") + + + Latency: + @((detail.ReceivedAtUtc - detail.OccurredAtUtc).TotalMilliseconds.ToString("F2")) ms + + + + + + + + + + Context & Identity + + + + + + User: + @(string.IsNullOrEmpty(detail.UserName) ? "System" : detail.UserName) + + + User ID: + @(string.IsNullOrEmpty(detail.UserId) ? "-" : detail.UserId) + + + Tenant: + @(string.IsNullOrEmpty(detail.TenantId) ? "-" : detail.TenantId) + + + Source: + @detail.Source + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + View Correlated Events + + + View Trace Timeline + + + + + + + + + + + + +
@FormatPayload(detail.Payload)
+
+
+ + + Copy Payload + + +
+
+
+
+ } + else + { + + + Loading details... + + } +
+ } +
+ + + + No audit events found + + Try adjusting your filters or date range to see more results + + + Clear All Filters + + + + + + +
+
+ + + + + + @(_relatedType == "correlation" ? "Correlated Events" : "Trace Timeline") + @_relatedEvents.Count events + + + + @if (_loadingRelated) + { + + + Loading related events... + + } + else if (_relatedEvents.Any()) + { + + @foreach (var evt in _relatedEvents.OrderBy(e => e.OccurredAtUtc)) + { + + + + @evt.OccurredAtUtc.ToLocalTime().ToString("HH:mm:ss.fff") + + + + + + + + @FormatEventType(evt.EventType) + + + @FormatSeverity(evt.Severity) + + @evt.UserName + + Source: @evt.Source + + + + + } + + } + else + { + + + No related events found + + } + + + Close + + + + + +@code { + private const int ApiPageSizeLimit = 100; + private static readonly int[] PageSizeOptions = new[] { 10, 25, 50, ApiPageSizeLimit }; + private readonly AuditEventTypeOption[] _eventTypes = Enum.GetValues(typeof(AuditEventTypeOption)).Cast().Where(v => v != AuditEventTypeOption.None).ToArray(); + private readonly AuditSeverityOption[] _severities = Enum.GetValues(typeof(AuditSeverityOption)).Cast().Where(v => v != AuditSeverityOption.None).ToArray(); + + private FilterDto _filter = new(); + private MudTable? _table; + private IReadOnlyList _currentPage = Array.Empty(); + private TableState _lastState = new() { SortLabel = "OccurredAtUtc", SortDirection = SortDirection.Descending, PageSize = 25 }; + private int _pageSize = 25; + private bool _loading; + private int _totalCount; + private readonly Dictionary _detailCache = new(); + private readonly HashSet _expanded = new(); + + // Summary dashboard + private bool _showSummary = true; + private FSH.Playground.Blazor.ApiClient.AuditSummaryAggregateDto? _summary; + + // UI state + private bool _filtersExpanded = false; + private string? _quickFilter = null; + + // Related events dialog + private bool _showRelatedDialog = false; + private bool _loadingRelated = false; + private string _relatedType = "correlation"; + private List _relatedEvents = new(); + + [Inject] private FSH.Playground.Blazor.ApiClient.IV1Client V1Client { get; set; } = default!; + [Inject] private FSH.Playground.Blazor.ApiClient.IAuditsClient AuditsClient { get; set; } = default!; + [Inject] private ISnackbar Snackbar { get; set; } = default!; + [Inject] private NavigationManager NavigationManager { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + await LoadSummaryAsync(); + } + + private async Task LoadSummaryAsync() + { + try + { + _summary = await AuditsClient.SummaryAsync( + fromUtc: _filter.FromUtc, + toUtc: _filter.ToUtc, + tenantId: string.IsNullOrWhiteSpace(_filter.TenantId) ? null : _filter.TenantId); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to load summary: {ex.Message}", Severity.Warning); + _showSummary = false; + } + } + + private async Task> LoadAudits(TableState state, CancellationToken cancellationToken) + { + _loading = true; + try + { + _lastState = new TableState + { + Page = state.Page, + PageSize = Math.Min(state.PageSize, ApiPageSizeLimit), + SortLabel = state.SortLabel, + SortDirection = state.SortDirection + }; + + var sort = BuildSort(state); + var result = await V1Client.AuditsGetAsync( + pageNumber: state.Page + 1, + pageSize: Math.Min(state.PageSize, ApiPageSizeLimit), + sort: sort, + fromUtc: _filter.FromUtc, + toUtc: _filter.ToUtc, + tenantId: string.IsNullOrWhiteSpace(_filter.TenantId) ? null : _filter.TenantId, + userId: _filter.Actor, + eventType: _filter.EventType.HasValue ? (int)_filter.EventType.Value : null, + severity: _filter.Severity.HasValue ? (int)_filter.Severity.Value : null, + source: string.IsNullOrWhiteSpace(_filter.Resource) ? null : _filter.Resource, + correlationId: string.IsNullOrWhiteSpace(_filter.CorrelationId) ? null : _filter.CorrelationId, + traceId: string.IsNullOrWhiteSpace(_filter.TraceId) ? null : _filter.TraceId, + search: string.IsNullOrWhiteSpace(_filter.Search) ? null : _filter.Search, + cancellationToken: cancellationToken); + + _currentPage = result.Items?.ToList() ?? new List(); + _totalCount = (int)result.TotalCount; + _expanded.Clear(); + _detailCache.Clear(); + + // Reload summary when filters change + await LoadSummaryAsync(); + + return new TableData + { + Items = _currentPage, + TotalItems = _totalCount + }; + } + catch (Exception ex) + { + Snackbar.Add($"Failed to load audits: {ex.Message}", Severity.Error); + return new TableData + { + Items = Array.Empty(), + TotalItems = 0 + }; + } + finally + { + _loading = false; + StateHasChanged(); + } + } + + private Task ReloadTable() + { + _table?.ReloadServerData(); + return Task.CompletedTask; + } + + private void ResetFilters() + { + _filter = new FilterDto(); + _quickFilter = null; + _table?.ReloadServerData(); + } + + private void ApplyQuickRange(TimeSpan span, string filterKey) + { + // Use DateTimeOffset directly to avoid DateTime Kind issues + var nowUtc = DateTimeOffset.UtcNow; + var startUtc = nowUtc.Add(-span); + + // Store as DateTime for DateRange, but we'll convert back to DateTimeOffset with UTC info + _filter.Range = new DateRange(startUtc.UtcDateTime, nowUtc.UtcDateTime); + _quickFilter = filterKey; + _table?.ReloadServerData(); + } + + private void ApplyErrorsFilter() + { + _filter.Severity = AuditSeverityOption.Error; + _quickFilter = "errors"; + _table?.ReloadServerData(); + } + + private void ApplySecurityFilter() + { + _filter.EventType = AuditEventTypeOption.Security; + _quickFilter = "security"; + _table?.ReloadServerData(); + } + + private void ApplyExceptionsFilter() + { + _filter.EventType = AuditEventTypeOption.Exception; + _quickFilter = "exceptions"; + _table?.ReloadServerData(); + } + + private async Task ToggleDetail(FSH.Playground.Blazor.ApiClient.AuditSummaryDto audit) + { + if (_expanded.Contains(audit.Id)) + { + _expanded.Remove(audit.Id); + StateHasChanged(); + return; + } + + _expanded.Add(audit.Id); + + if (_detailCache.ContainsKey(audit.Id)) + { + StateHasChanged(); + return; + } + + try + { + var detail = await V1Client.AuditsGetAsync(audit.Id); + _detailCache[audit.Id] = detail; + StateHasChanged(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to load audit detail: {ex.Message}", Severity.Error); + _expanded.Remove(audit.Id); + } + } + + private async Task ViewByCorrelation(string correlationId) + { + if (string.IsNullOrEmpty(correlationId)) return; + + _loadingRelated = true; + _showRelatedDialog = true; + _relatedType = "correlation"; + _relatedEvents.Clear(); + StateHasChanged(); + + try + { + var events = await AuditsClient.ByCorrelationAsync( + correlationId: correlationId, + fromUtc: _filter.FromUtc, + toUtc: _filter.ToUtc); + + _relatedEvents = events?.ToList() ?? new List(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to load correlated events: {ex.Message}", Severity.Error); + } + finally + { + _loadingRelated = false; + StateHasChanged(); + } + } + + private async Task ViewByTrace(string traceId) + { + if (string.IsNullOrEmpty(traceId)) return; + + _loadingRelated = true; + _showRelatedDialog = true; + _relatedType = "trace"; + _relatedEvents.Clear(); + StateHasChanged(); + + try + { + var events = await AuditsClient.ByTraceAsync( + traceId: traceId, + fromUtc: _filter.FromUtc, + toUtc: _filter.ToUtc); + + _relatedEvents = events?.ToList() ?? new List(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to load trace events: {ex.Message}", Severity.Error); + } + finally + { + _loadingRelated = false; + StateHasChanged(); + } + } + + private Task CopyToClipboard(string? text) + { + if (string.IsNullOrEmpty(text)) return Task.CompletedTask; + + try + { + NavigationManager.NavigateTo($"javascript:navigator.clipboard.writeText('{text}')"); + Snackbar.Add("Copied to clipboard", Severity.Success); + } + catch + { + Snackbar.Add("Failed to copy to clipboard", Severity.Warning); + } + + return Task.CompletedTask; + } + + private bool IsExpanded(Guid id) => _expanded.Contains(id); + + private bool TryGetDetail(Guid id, out FSH.Playground.Blazor.ApiClient.AuditDetailDto? detail) => + _detailCache.TryGetValue(id, out detail); + + private static string FormatPayload(FSH.Playground.Blazor.ApiClient.JsonElement payload) => + JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); + + private Task OnRowsPerPageChanged(int size) + { + _pageSize = Math.Min(size, ApiPageSizeLimit); + _table?.SetRowsPerPage(_pageSize); + return ReloadTable(); + } + + private static Color SeverityColor(int severity) => + severity switch + { + 6 => Color.Error, // Critical + 5 => Color.Error, // Error + 4 => Color.Warning, // Warning + 3 => Color.Info, // Information + 2 => Color.Default, // Debug + 1 => Color.Default, // Trace + _ => Color.Default + }; + + private static Color EventTypeColor(int eventType) => + eventType switch + { + 1 => Color.Info, // EntityChange + 2 => Color.Warning, // Security + 3 => Color.Success, // Activity + 4 => Color.Error, // Exception + _ => Color.Default + }; + + private static string GetEventTypeIcon(int eventType) => + eventType switch + { + 1 => Icons.Material.Filled.Edit, + 2 => Icons.Material.Filled.Security, + 3 => Icons.Material.Filled.DirectionsRun, + 4 => Icons.Material.Filled.BugReport, + _ => Icons.Material.Filled.Event + }; + + private static string GetSeverityIcon(int severity) => + severity switch + { + 6 => Icons.Material.Filled.ErrorOutline, + 5 => Icons.Material.Filled.Error, + 4 => Icons.Material.Filled.Warning, + 3 => Icons.Material.Filled.Info, + 2 => Icons.Material.Filled.Code, + 1 => Icons.Material.Filled.BugReport, + _ => Icons.Material.Filled.Circle + }; + + private static string FormatEventType(int value) => + Enum.GetName(typeof(AuditEventTypeOption), value) ?? value.ToString(); + + private static string FormatSeverity(int value) => + Enum.GetName(typeof(AuditSeverityOption), value) ?? value.ToString(); + + private async Task ExportAsync(string format) + { + try + { + var items = new List(); + var remaining = Math.Min(_totalCount, ApiPageSizeLimit); + var page = 1; + + while (remaining > 0) + { + var pageSize = Math.Min(remaining, ApiPageSizeLimit); + var result = await V1Client.AuditsGetAsync( + pageNumber: page, + pageSize: pageSize, + sort: BuildSort(_lastState), + fromUtc: _filter.FromUtc, + toUtc: _filter.ToUtc, + tenantId: string.IsNullOrWhiteSpace(_filter.TenantId) ? null : _filter.TenantId, + userId: _filter.Actor, + eventType: _filter.EventType.HasValue ? (int)_filter.EventType.Value : null, + severity: _filter.Severity.HasValue ? (int)_filter.Severity.Value : null, + source: string.IsNullOrWhiteSpace(_filter.Resource) ? null : _filter.Resource, + correlationId: string.IsNullOrWhiteSpace(_filter.CorrelationId) ? null : _filter.CorrelationId, + traceId: string.IsNullOrWhiteSpace(_filter.TraceId) ? null : _filter.TraceId, + search: string.IsNullOrWhiteSpace(_filter.Search) ? null : _filter.Search); + + var pageItems = result.Items?.ToList() ?? new List(); + if (pageItems.Count == 0) + { + break; + } + + items.AddRange(pageItems); + remaining -= pageItems.Count; + page++; + } + + if (items.Count == 0) + { + Snackbar.Add("No audits to export for the current filters.", Severity.Info); + return; + } + + var bytes = format.Equals("json", StringComparison.OrdinalIgnoreCase) + ? Encoding.UTF8.GetBytes(JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true })) + : BuildCsv(items); + + var contentType = format.Equals("json", StringComparison.OrdinalIgnoreCase) ? "application/json" : "text/csv"; + var dataUrl = $"data:{contentType};base64,{Convert.ToBase64String(bytes)}"; + + Snackbar.Add($"Exported {items.Count} audit events", Severity.Success); + NavigationManager.NavigateTo(dataUrl, true); + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + } + + private static byte[] BuildCsv(IEnumerable items) + { + var sb = new StringBuilder(); + sb.AppendLine("OccurredAtUtc,EventType,Severity,UserName,UserId,TenantId,Source,CorrelationId,TraceId"); + foreach (var item in items) + { + sb.AppendLine(string.Join(",", + Quote(item.OccurredAtUtc.ToString("o")), + Quote(FormatEventType(item.EventType)), + Quote(FormatSeverity(item.Severity)), + Quote(item.UserName), + Quote(item.UserId), + Quote(item.TenantId), + Quote(item.Source), + Quote(item.CorrelationId), + Quote(item.TraceId))); + } + + return Encoding.UTF8.GetBytes(sb.ToString()); + } + + private static string Quote(string? value) + { + if (string.IsNullOrEmpty(value)) + { + return "\"\""; + } + + return $"\"{value.Replace("\"", "\"\"")}\""; + } + + private static string? BuildSort(TableState state) + { + if (string.IsNullOrWhiteSpace(state.SortLabel)) + { + return "OccurredAtUtc desc"; + } + + var direction = state.SortDirection == SortDirection.Descending ? "desc" : "asc"; + return $"{state.SortLabel} {direction}"; + } + + private bool HasActiveFilters() => + _filter.Range != null || + !string.IsNullOrWhiteSpace(_filter.Actor) || + _filter.EventType.HasValue || + _filter.Severity.HasValue || + !string.IsNullOrWhiteSpace(_filter.Resource) || + !string.IsNullOrWhiteSpace(_filter.TenantId) || + !string.IsNullOrWhiteSpace(_filter.CorrelationId) || + !string.IsNullOrWhiteSpace(_filter.TraceId) || + !string.IsNullOrWhiteSpace(_filter.Search); + + private int GetActiveFiltersCount() + { + var count = 0; + if (_filter.Range != null) count++; + if (!string.IsNullOrWhiteSpace(_filter.Actor)) count++; + if (_filter.EventType.HasValue) count++; + if (_filter.Severity.HasValue) count++; + if (!string.IsNullOrWhiteSpace(_filter.Resource)) count++; + if (!string.IsNullOrWhiteSpace(_filter.TenantId)) count++; + if (!string.IsNullOrWhiteSpace(_filter.CorrelationId)) count++; + if (!string.IsNullOrWhiteSpace(_filter.TraceId)) count++; + if (!string.IsNullOrWhiteSpace(_filter.Search)) count++; + return count; + } + + private long GetTotalEvents() => + _summary?.EventsByType?.Values.Sum() ?? 0; + + private long GetEventsBySeverity(string severity) => + _summary?.EventsBySeverity?.TryGetValue(severity, out var count) == true ? count : 0; + + private long GetEventsByType(string type) => + _summary?.EventsByType?.TryGetValue(type, out var count) == true ? count : 0; + + private int GetSourcesCount() => + _summary?.EventsBySource?.Count ?? 0; + + private sealed class FilterDto + { + public DateRange? Range { get; set; } + public string? Actor { get; set; } + public AuditEventTypeOption? EventType { get; set; } + public AuditSeverityOption? Severity { get; set; } + public string? Resource { get; set; } + public string? TenantId { get; set; } + public string? CorrelationId { get; set; } + public string? TraceId { get; set; } + public string? Search { get; set; } + + public DateTimeOffset? FromUtc + { + get + { + if (Range?.Start is null) + return null; + + var dateTime = Range.Start.Value; + + // Convert to UTC based on Kind + return dateTime.Kind switch + { + DateTimeKind.Utc => new DateTimeOffset(dateTime, TimeSpan.Zero), + DateTimeKind.Local => new DateTimeOffset(dateTime.ToUniversalTime(), TimeSpan.Zero), + _ => new DateTimeOffset(DateTime.SpecifyKind(dateTime, DateTimeKind.Utc), TimeSpan.Zero) + }; + } + } + + public DateTimeOffset? ToUtc + { + get + { + if (Range?.End is null) + return null; + + var dateTime = Range.End.Value; + + // Convert to UTC based on Kind + return dateTime.Kind switch + { + DateTimeKind.Utc => new DateTimeOffset(dateTime, TimeSpan.Zero), + DateTimeKind.Local => new DateTimeOffset(dateTime.ToUniversalTime(), TimeSpan.Zero), + _ => new DateTimeOffset(DateTime.SpecifyKind(dateTime, DateTimeKind.Utc), TimeSpan.Zero) + }; + } + } + } + + private enum AuditEventTypeOption + { + None = 0, + EntityChange = 1, + Security = 2, + Activity = 3, + Exception = 4 + } + + private enum AuditSeverityOption + { + None = 0, + Trace = 1, + Debug = 2, + Information = 3, + Warning = 4, + Error = 5, + Critical = 6 + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Counter.razor b/src/Playground/Playground.Blazor/Components/Pages/Counter.razor new file mode 100644 index 0000000000..db95bf3872 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/Counter.razor @@ -0,0 +1,18 @@ +@page "/counter" + +Counter + +Counter + +Current count: @currentCount + +Click me + +@code { + private int currentCount = 0; + + private void IncrementCount() + { + currentCount++; + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Dashboard/DashboardPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Dashboard/DashboardPage.razor new file mode 100644 index 0000000000..1874ed7b5d --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/Dashboard/DashboardPage.razor @@ -0,0 +1,117 @@ +@page "/dashboard" +@page "/" +@attribute [StreamRendering(true)] +@using System.Linq +@inherits ComponentBase +@inject FSH.Playground.Blazor.ApiClient.IV1Client V1Client +@inject FSH.Playground.Blazor.ApiClient.IIdentityClient IdentityClient +@inject ISnackbar Snackbar + + + +
+ + + + Users + @_summary.Users + + + + + Roles + @_summary.Roles + + + + + Tenants + @_summary.Tenants + + + + + Recent Audits + @_recentAudits.Count + + + + + + Recent Audits + + + Timestamp + Event Type + User + Correlation + + + @context.OccurredAtUtc.ToLocalTime() + @context.EventType + @context.UserName + @context.CorrelationId + + + +
+ +@code { + private SummaryDto _summary = new(); + private List _recentAudits = new(); + + protected override async Task OnInitializedAsync() + { + await LoadRecentAudits(); + await LoadSummary(); + } + + private async Task LoadRecentAudits() + { + try + { + var result = await V1Client.AuditsGetAsync(pageNumber: 1, pageSize: 5); + if (result is null) + { + _recentAudits = new List(); + _summary.RecentAudits = 0; + return; + } + + _recentAudits = result.Items?.ToList() ?? new List(); + _summary.RecentAudits = (int)result.TotalCount; + } + catch (Exception ex) + { + Snackbar.Add($"Failed to load audits: {ex.Message}", Severity.Error); + } + } + + private async Task LoadSummary() + { + try + { + var users = await IdentityClient.UsersGetAsync(); + _summary.Users = users?.Count ?? 0; + + var roles = await IdentityClient.RolesGetAsync(); + _summary.Roles = roles?.Count ?? 0; + + var tenants = await V1Client.TenantsGetAsync(pageNumber: 1, pageSize: 1); + _summary.Tenants = (int)tenants.TotalCount; + } + catch (Exception ex) + { + Snackbar.Add($"Failed to load summary: {ex.Message}", Severity.Error); + } + } + + private sealed class SummaryDto + { + public int Users { get; set; } + public int Roles { get; set; } + public int Tenants { get; set; } + public int RecentAudits { get; set; } + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Error.razor b/src/Playground/Playground.Blazor/Components/Pages/Error.razor new file mode 100644 index 0000000000..576cc2d2f4 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Groups/AddMembersDialog.razor b/src/Playground/Playground.Blazor/Components/Pages/Groups/AddMembersDialog.razor new file mode 100644 index 0000000000..4a839497f0 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/Groups/AddMembersDialog.razor @@ -0,0 +1,250 @@ +@using FSH.Playground.Blazor.ApiClient +@inject IIdentityClient IdentityClient +@inject IGroupsClient GroupsClient +@inject ISnackbar Snackbar + + + + + Add Members to Group + + Select users to add to this group + + + + + + @* Search *@ + + + @* User Selection *@ + @if (_loading) + { + + } + else if (_filteredUsers.Any()) + { + + + @foreach (var user in _filteredUsers) + { + var isSelected = _selectedUserIds.Contains(user.Id!); + var isExisting = ExistingMemberIds.Contains(user.Id!); + + + + + + + @GetInitials(user) + + + + @($"{user.FirstName} {user.LastName}".Trim()) + + @user.Email + + + @if (isExisting) + { + + Already member + + } + + + } + + + + @if (_selectedUserIds.Any()) + { + + @_selectedUserIds.Count user(s) selected + + } + } + else + { + + No users found matching your search. + + } + + + + + Cancel + + + @if (_busy) + { + + Adding... + } + else + { + Add @_selectedUserIds.Count Member(s) + } + + + + + + +@code { + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public Guid GroupId { get; set; } + + [Parameter] + public List ExistingMemberIds { get; set; } = new(); + + private List _users = new(); + private List _filteredUsers = new(); + private HashSet _selectedUserIds = new(); + private string _searchTerm = string.Empty; + private bool _loading = true; + private bool _busy; + + protected override async Task OnInitializedAsync() + { + await LoadUsers(); + } + + private async Task LoadUsers() + { + _loading = true; + try + { + var result = await IdentityClient.UsersGetAsync(); + _users = result?.Where(u => u.IsActive).ToList() ?? new List(); + FilterUsers(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to load users: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + } + } + + private void FilterUsers() + { + if (string.IsNullOrWhiteSpace(_searchTerm)) + { + _filteredUsers = _users; + } + else + { + var term = _searchTerm.ToLowerInvariant(); + _filteredUsers = _users.Where(u => + (u.FirstName?.ToLowerInvariant().Contains(term) ?? false) || + (u.LastName?.ToLowerInvariant().Contains(term) ?? false) || + (u.Email?.ToLowerInvariant().Contains(term) ?? false) || + (u.UserName?.ToLowerInvariant().Contains(term) ?? false) + ).ToList(); + } + StateHasChanged(); + } + + private void ToggleUser(string userId, bool isExisting) + { + if (isExisting) return; + + if (_selectedUserIds.Contains(userId)) + { + _selectedUserIds.Remove(userId); + } + else + { + _selectedUserIds.Add(userId); + } + StateHasChanged(); + } + + private static string GetInitials(UserDto user) + { + var first = user.FirstName?.FirstOrDefault() ?? ' '; + var last = user.LastName?.FirstOrDefault() ?? ' '; + return $"{first}{last}".Trim().ToUpperInvariant(); + } + + private void Cancel() + { + MudDialog.Cancel(); + } + + private async Task Submit() + { + if (!_selectedUserIds.Any()) + { + Snackbar.Add("Please select at least one user.", Severity.Warning); + return; + } + + _busy = true; + try + { + var request = new AddUsersRequest + { + UserIds = _selectedUserIds.ToList() + }; + + var response = await GroupsClient.MembersPostAsync(GroupId, request); + + if (response.AddedCount > 0) + { + Snackbar.Add($"Added {response.AddedCount} member(s) to the group.", Severity.Success); + } + + if (response.AlreadyMemberUserIds?.Any() == true) + { + Snackbar.Add($"{response.AlreadyMemberUserIds.Count} user(s) were already members.", Severity.Info); + } + + MudDialog.Close(DialogResult.Ok(true)); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to add members: {ex.Message}", Severity.Error); + } + finally + { + _busy = false; + } + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Groups/CreateGroupDialog.razor b/src/Playground/Playground.Blazor/Components/Pages/Groups/CreateGroupDialog.razor new file mode 100644 index 0000000000..deb389c646 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/Groups/CreateGroupDialog.razor @@ -0,0 +1,282 @@ +@using FSH.Playground.Blazor.ApiClient +@inject IIdentityClient IdentityClient +@inject ISnackbar Snackbar + + + + + @(IsEditMode ? "Edit Group" : "Create New Group") + + @(IsEditMode ? "Modify group details and role assignments" : "Define a new group for organizing users") + + + + + + + @* Group Name *@ + + + @* Group Description *@ + + + @* Is Default Group *@ + + + New users will be automatically added to default groups + + + @* Role Assignments *@ + Role Assignments + + Members of this group will inherit these roles + + + @if (_loadingRoles) + { + + } + else if (_availableRoles.Any()) + { + + + @foreach (var role in _availableRoles) + { + + + + @role.Name + + @if (!string.IsNullOrEmpty(role.Description)) + { + @role.Description + } + + + } + + + } + else + { + + No roles available for assignment. + + } + + @* Info Alert *@ + + + + + @if (IsEditMode) + { + After saving, you can manage group members from the group details page. + } + else + { + After creating the group, you can add members from the group details page. + } + + + + + + + + + Cancel + + + @if (_busy) + { + + @(IsEditMode ? "Saving..." : "Creating...") + } + else + { + @(IsEditMode ? "Save Changes" : "Create Group") + } + + + + +@code { + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public GroupDto? ExistingGroup { get; set; } + + private MudForm? _form; + private string _name = string.Empty; + private string _description = string.Empty; + private bool _isDefault; + private HashSet _selectedRoleIds = new(); + private List _availableRoles = new(); + private bool _loadingRoles = true; + private bool _busy; + + private bool IsEditMode => ExistingGroup is not null; + + protected override async Task OnInitializedAsync() + { + await LoadRoles(); + + if (ExistingGroup is not null) + { + _name = ExistingGroup.Name ?? string.Empty; + _description = ExistingGroup.Description ?? string.Empty; + _isDefault = ExistingGroup.IsDefault; + + // Get role IDs from role names + if (ExistingGroup.RoleNames?.Any() == true) + { + foreach (var roleName in ExistingGroup.RoleNames) + { + var role = _availableRoles.FirstOrDefault(r => + string.Equals(r.Name, roleName, StringComparison.OrdinalIgnoreCase)); + if (role?.Id is not null) + { + _selectedRoleIds.Add(role.Id); + } + } + } + } + } + + private async Task LoadRoles() + { + _loadingRoles = true; + try + { + var roles = await IdentityClient.RolesGetAsync(); + _availableRoles = roles?.ToList() ?? new List(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to load roles: {ex.Message}", Severity.Warning); + } + finally + { + _loadingRoles = false; + } + } + + private void ToggleRole(string roleId, bool selected) + { + if (selected) + { + _selectedRoleIds.Add(roleId); + } + else + { + _selectedRoleIds.Remove(roleId); + } + } + + private static Color GetRoleColor(string? roleName) => roleName?.ToLowerInvariant() switch + { + "admin" => Color.Error, + "administrator" => Color.Error, + "basic" => Color.Info, + _ => Color.Default + }; + + private string? GetSubmitIcon() + { + if (_busy) return null; + return IsEditMode ? Icons.Material.Filled.Save : Icons.Material.Filled.Add; + } + + private void Cancel() + { + MudDialog.Cancel(); + } + + private async Task Submit() + { + if (_form is not null) + { + await _form.Validate(); + if (!_form.IsValid) + { + return; + } + } + + if (string.IsNullOrWhiteSpace(_name)) + { + Snackbar.Add("Please enter a group name.", Severity.Warning); + return; + } + + _busy = true; + try + { + if (IsEditMode && ExistingGroup is not null) + { + var request = new UpdateGroupRequest + { + Name = _name, + Description = _description, + IsDefault = _isDefault, + RoleIds = _selectedRoleIds.ToList() + }; + await IdentityClient.GroupsPutAsync(ExistingGroup.Id, request); + } + else + { + var command = new CreateGroupCommand + { + Name = _name, + Description = _description, + IsDefault = _isDefault, + RoleIds = _selectedRoleIds.ToList() + }; + await IdentityClient.GroupsPostAsync(command); + } + + Snackbar.Add($"Group '{_name}' {(IsEditMode ? "updated" : "created")} successfully!", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to {(IsEditMode ? "update" : "create")} group: {ex.Message}", Severity.Error); + } + finally + { + _busy = false; + } + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Groups/GroupMembersPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Groups/GroupMembersPage.razor new file mode 100644 index 0000000000..a11cab99f4 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/Groups/GroupMembersPage.razor @@ -0,0 +1,318 @@ +@page "/groups/{GroupId:guid}/members" +@using FSH.Playground.Blazor.ApiClient +@using FSH.Framework.Blazor.UI.Components.Dialogs +@inherits ComponentBase +@inject IIdentityClient IdentityClient +@inject IGroupsClient GroupsClient +@inject NavigationManager Navigation +@inject ISnackbar Snackbar +@inject IDialogService DialogService + + + + + Back to Groups + + + Add Members + + + + +@* Group Info Card *@ +@if (_group is not null) +{ + + + + + + + @_group.Name + @if (_group.IsSystemGroup) + { + + System + + } + @if (_group.IsDefault) + { + + Default + + } + + @if (!string.IsNullOrEmpty(_group.Description)) + { + @_group.Description + } + + + @if (_group.RoleNames?.Any() == true) + { + @foreach (var role in _group.RoleNames) + { + + @role + + } + } + + + + +} + +@* Members Grid *@ + + + + + + + @_filteredMembers.Count member(s) + + + + + + + + @GetInitials(context.Item) + + + + @($"{context.Item.FirstName} {context.Item.LastName}".Trim()) + + @@@context.Item.UserName + + + + + + + + + @context.Item.AddedAt.LocalDateTime.ToString("MMM d, yyyy") + by @(context.Item.AddedBy ?? "System") + + + + + + + + + + + + + + + + + + No members in this group + Add members to get started. + + Add Members + + + + + + + + +@code { + [Parameter] + public Guid GroupId { get; set; } + + private MudDataGrid? _dataGrid; + private GroupDto? _group; + private List _members = new(); + private List _filteredMembers = new(); + private bool _loading = true; + private string? _busyUserId; + private string _searchTerm = string.Empty; + + private readonly int _pageSize = 10; + private readonly int[] _pageSizeOptions = { 5, 10, 25, 50 }; + + protected override async Task OnInitializedAsync() + { + await LoadGroup(); + await LoadMembers(); + } + + private async Task LoadGroup() + { + try + { + _group = await IdentityClient.GroupsGetAsync(GroupId); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to load group: {ex.Message}", Severity.Error); + } + } + + private async Task LoadMembers() + { + _loading = true; + try + { + var result = await GroupsClient.MembersGetAsync(GroupId); + _members = result?.ToList() ?? new List(); + FilterMembers(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to load members: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + } + } + + private void FilterMembers() + { + if (string.IsNullOrWhiteSpace(_searchTerm)) + { + _filteredMembers = _members; + } + else + { + var term = _searchTerm.ToLowerInvariant(); + _filteredMembers = _members.Where(m => + (m.FirstName?.ToLowerInvariant().Contains(term) ?? false) || + (m.LastName?.ToLowerInvariant().Contains(term) ?? false) || + (m.Email?.ToLowerInvariant().Contains(term) ?? false) || + (m.UserName?.ToLowerInvariant().Contains(term) ?? false) + ).ToList(); + } + StateHasChanged(); + } + + private static string GetInitials(GroupMemberDto member) + { + var first = member.FirstName?.FirstOrDefault() ?? ' '; + var last = member.LastName?.FirstOrDefault() ?? ' '; + return $"{first}{last}".Trim().ToUpperInvariant(); + } + + private static Color GetRoleColor(string? roleName) => roleName?.ToLowerInvariant() switch + { + "admin" => Color.Error, + "administrator" => Color.Error, + "basic" => Color.Info, + _ => Color.Default + }; + + private async Task ShowAddMembers() + { + var parameters = new DialogParameters + { + { x => x.GroupId, GroupId }, + { x => x.ExistingMemberIds, _members.Select(m => m.UserId).Where(id => id is not null).ToList()! } + }; + + var options = new DialogOptions + { + MaxWidth = MaxWidth.Medium, + FullWidth = true, + CloseOnEscapeKey = true, + CloseButton = true + }; + + var dialog = await DialogService.ShowAsync("Add Members", parameters, options); + var result = await dialog.Result; + + if (result is not null && !result.Canceled) + { + await LoadMembers(); + } + } + + private async Task RemoveMember(GroupMemberDto member) + { + if (string.IsNullOrWhiteSpace(member.UserId)) return; + + var confirmed = await DialogService.ShowConfirmAsync( + "Remove Member", + $"Remove {member.FirstName} {member.LastName} from this group?", + "Remove", + "Cancel", + Color.Error); + + if (!confirmed) return; + + _busyUserId = member.UserId; + try + { + await GroupsClient.MembersDeleteAsync(GroupId, member.UserId); + Snackbar.Add($"{member.FirstName} {member.LastName} removed from group.", Severity.Success); + await LoadMembers(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to remove member: {ex.Message}", Severity.Error); + } + finally + { + _busyUserId = null; + } + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Groups/GroupsPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Groups/GroupsPage.razor new file mode 100644 index 0000000000..64ffe14cb5 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/Groups/GroupsPage.razor @@ -0,0 +1,511 @@ +@page "/groups" +@using FSH.Playground.Blazor.ApiClient +@using FSH.Framework.Blazor.UI.Components.Dialogs +@inherits ComponentBase +@inject IIdentityClient IdentityClient +@inject NavigationManager Navigation +@inject ISnackbar Snackbar +@inject IDialogService DialogService + + + + @if (_selectedGroups.Any()) + { + + + Delete (@_selectedGroups.Count) + + + Clear + + + } + + New Group + + + + +@* Stats Cards *@ + + + + + + + + Total + + @_stats.Total + Total Groups + + + + + + + + + + + System + + @_stats.SystemGroups + System Groups + + + + + + + + + + + Default + + @_stats.DefaultGroups + Default Groups + + + + + + + + + + + Custom + + @_stats.CustomGroups + Custom Groups + + + + + + +@* Filter Panel *@ + + + + + + + + + Clear Filters + + + Refresh + + + + + + +@* Groups Grid *@ + + + + + + + + + + @context.Item.Name + + @if (context.Item.IsDefault) + { + + + + } + + + @(string.IsNullOrEmpty(context.Item.Description) ? "No description" : context.Item.Description) + + + + + + + @if (context.Item.IsSystemGroup) + { + + + System + + } + else + { + + + Custom + + } + + + + + @if (context.Item.RoleNames?.Any() == true) + { + + @foreach (var role in context.Item.RoleNames.Take(2)) + { + + @role + + } + @if (context.Item.RoleNames.Count > 2) + { + + +@(context.Item.RoleNames.Count - 2) + + } + + } + else + { + No roles assigned + } + + + + + + + @context.Item.MemberCount + + + + + + + + + + + + + + + + + + + + + + + + + + No groups found + Try adjusting your filters or create a new group. + + + + + + + +@code { + private MudDataGrid? _dataGrid; + private List _groups = new(); + private List _filtered = new(); + private HashSet _selectedGroups = new(); + private bool _loading = true; + private Guid? _busyGroupId; + private bool _bulkBusy; + + private readonly int _pageSize = 10; + private readonly int[] _pageSizeOptions = { 5, 10, 25, 50 }; + + private GroupStats _stats = new(); + private FilterModel _filter = new(); + + private class GroupStats + { + public int Total { get; set; } + public int SystemGroups { get; set; } + public int CustomGroups { get; set; } + public int DefaultGroups { get; set; } + } + + private class FilterModel + { + public string? Search { get; set; } + } + + protected override async Task OnInitializedAsync() + { + await LoadData(); + } + + private async Task LoadData() + { + _loading = true; + try + { + var result = await IdentityClient.GroupsGetAsync(_filter.Search); + _groups = result?.ToList() ?? new List(); + CalculateStats(); + ApplyFilters(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to load groups: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + } + } + + private void CalculateStats() + { + _stats = new GroupStats + { + Total = _groups.Count, + SystemGroups = _groups.Count(g => g.IsSystemGroup), + CustomGroups = _groups.Count(g => !g.IsSystemGroup), + DefaultGroups = _groups.Count(g => g.IsDefault) + }; + } + + private void ApplyFilters() + { + var query = _groups.AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(_filter.Search)) + { + var term = _filter.Search.ToLowerInvariant(); + query = query.Where(g => + (g.Name?.ToLowerInvariant().Contains(term) ?? false) || + (g.Description?.ToLowerInvariant().Contains(term) ?? false)); + } + + _filtered = query.OrderBy(g => g.Name).ToList(); + } + + private void RefreshData() + { + ApplyFilters(); + StateHasChanged(); + } + + private void ClearFilters() + { + _filter = new FilterModel(); + ApplyFilters(); + } + + private static Color GetRoleColor(string? roleName) => roleName?.ToLowerInvariant() switch + { + "admin" => Color.Error, + "administrator" => Color.Error, + "basic" => Color.Info, + _ => Color.Default + }; + + private void GoToMembers(Guid id) + { + Navigation.NavigateTo($"/groups/{id}/members"); + } + + private void OnSelectionChanged(HashSet items) + { + _selectedGroups = items; + } + + private void ClearSelection() + { + _selectedGroups.Clear(); + StateHasChanged(); + } + + private async Task ShowCreate() + { + var options = new DialogOptions + { + MaxWidth = MaxWidth.Small, + FullWidth = true, + CloseOnEscapeKey = true, + CloseButton = true + }; + + var dialog = await DialogService.ShowAsync("Create Group", options); + var result = await dialog.Result; + + if (result is not null && !result.Canceled) + { + await LoadData(); + } + } + + private async Task EditGroup(GroupDto group) + { + if (group.IsSystemGroup) + { + Snackbar.Add("System groups cannot be edited.", Severity.Warning); + return; + } + + var parameters = new DialogParameters + { + { x => x.ExistingGroup, group } + }; + + var options = new DialogOptions + { + MaxWidth = MaxWidth.Small, + FullWidth = true, + CloseOnEscapeKey = true, + CloseButton = true + }; + + var dialog = await DialogService.ShowAsync("Edit Group", parameters, options); + var result = await dialog.Result; + + if (result is not null && !result.Canceled) + { + await LoadData(); + } + } + + private async Task DeleteGroup(GroupDto group) + { + if (group.IsSystemGroup) + { + Snackbar.Add("System groups cannot be deleted.", Severity.Warning); + return; + } + + var confirmed = await DialogService.ShowDeleteConfirmAsync(group.Name ?? "this group"); + if (!confirmed) return; + + _busyGroupId = group.Id; + try + { + await IdentityClient.GroupsDeleteAsync(group.Id); + Snackbar.Add("Group deleted successfully.", Severity.Success); + await LoadData(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to delete group: {ex.Message}", Severity.Error); + } + finally + { + _busyGroupId = null; + } + } + + private async Task BulkDelete() + { + var groups = _selectedGroups.Where(g => !g.IsSystemGroup).ToList(); + if (!groups.Any()) + { + Snackbar.Add("No deletable groups selected. System groups cannot be deleted.", Severity.Info); + return; + } + + var confirmed = await DialogService.ShowConfirmAsync( + "Bulk Delete", + $"Delete {groups.Count} group(s)? This action cannot be undone.", + "Delete All", + "Cancel", + Color.Error, + Icons.Material.Outlined.DeleteForever, + Color.Error); + + if (!confirmed) return; + + _bulkBusy = true; + var success = 0; + foreach (var group in groups) + { + try + { + await IdentityClient.GroupsDeleteAsync(group.Id); + success++; + } + catch + { + // Continue with remaining groups + } + } + _bulkBusy = false; + Snackbar.Add($"Deleted {success} of {groups.Count} groups.", Severity.Success); + ClearSelection(); + await LoadData(); + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Health/HealthPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Health/HealthPage.razor new file mode 100644 index 0000000000..a59fc14bad --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/Health/HealthPage.razor @@ -0,0 +1,389 @@ +@page "/health" +@using FSH.Playground.Blazor.ApiClient +@using System.Timers +@implements IDisposable +@inject IHealthClient HealthClient +@inject ISnackbar Snackbar + + + + + Last updated: @_lastUpdated.ToString("HH:mm:ss") + + + + + + +@* Stats Cards *@ + + + + + + + + Healthy + + @(_readyResult?.Results?.Count(r => r.Status == "Healthy") ?? 0) + Healthy Services + + + + + + + + + + + Degraded + + @(_readyResult?.Results?.Count(r => r.Status == "Degraded") ?? 0) + Degraded Services + + + + + + + + + + + Unhealthy + + @(_readyResult?.Results?.Count(r => r.Status == "Unhealthy") ?? 0) + Unhealthy Services + + + + + + + + + + + Response + + @GetAverageDuration()ms + Avg Response Time + + + + + + +@* Services Grid *@ +Service Status + + @if (_readyResult?.Results != null) + { + @foreach (var entry in _readyResult.Results.OrderBy(GetStatusSortOrder)) + { + + + + + + + @entry.Status + + @entry.DurationMs.ToString("F1")ms + @FormatServiceName(entry.Name) + + + + + } + } + else if (_loading) + { + @for (int i = 0; i < 6; i++) + { + + + + + + + + } + } + + +@* Endpoints Section *@ + + + + + + Liveness Probe + + @(_liveResult?.Status ?? "Unknown") + + + + Basic process check - confirms the application is running. + + + + /health/live + + + @(_liveResult?.Results?.FirstOrDefault()?.DurationMs.ToString("F2") ?? "0")ms + + + + + + + + + Readiness Probe + + @(_readyResult?.Status ?? "Unknown") + + + + Full dependency check - validates all services and databases. + + + + /health/ready + + + @GetTotalDuration()ms total + + + + + + +@* History Timeline *@ +@if (_healthHistory.Any()) +{ + + + + Recent Checks + Last @_healthHistory.Count checks + + + @foreach (var record in _healthHistory.TakeLast(20)) + { + +
+
+ } +
+
+} + + + +@code { + private HealthResult? _liveResult; + private HealthResult? _readyResult; + private bool _loading = true; + private bool _autoRefresh = true; + private DateTime _lastUpdated = DateTime.Now; + private Timer? _refreshTimer; + private List _healthHistory = new(); + private int _uptimePercent = 100; + + protected override async Task OnInitializedAsync() + { + await RefreshHealth(); + StartAutoRefresh(); + } + + private void StartAutoRefresh() + { + _refreshTimer = new Timer(10000); // 10 seconds + _refreshTimer.Elapsed += async (sender, e) => + { + if (_autoRefresh) + { + await InvokeAsync(async () => + { + await RefreshHealth(); + StateHasChanged(); + }); + } + }; + _refreshTimer.Start(); + } + + private async Task RefreshHealth() + { + _loading = true; + StateHasChanged(); + + try + { + var liveTask = HealthClient.LiveAsync(); + var readyTask = HealthClient.ReadyAsync(); + + await Task.WhenAll(liveTask, readyTask); + + _liveResult = await liveTask; + _readyResult = await readyTask; + + _lastUpdated = DateTime.Now; + + // Add to history + _healthHistory.Add(new HealthRecord + { + Time = _lastUpdated, + Status = _readyResult?.Status ?? "Unknown" + }); + + // Keep only last 50 records + if (_healthHistory.Count > 50) + _healthHistory.RemoveAt(0); + + // Calculate uptime + var healthyCount = _healthHistory.Count(h => h.Status == "Healthy"); + _uptimePercent = _healthHistory.Count > 0 + ? (int)Math.Round((double)healthyCount / _healthHistory.Count * 100) + : 100; + } + catch (Exception ex) + { + Snackbar.Add($"Failed to fetch health status: {ex.Message}", Severity.Error); + _healthHistory.Add(new HealthRecord + { + Time = DateTime.Now, + Status = "Unhealthy" + }); + } + finally + { + _loading = false; + } + } + + private static string GetStatusText(string? status) => status switch + { + "Healthy" => "All Systems Operational", + "Degraded" => "Degraded Performance", + "Unhealthy" => "System Issues Detected", + _ => "Checking Status..." + }; + + private static string GetStatusIcon(string? status) => status switch + { + "Healthy" => Icons.Material.Filled.CheckCircle, + "Degraded" => Icons.Material.Filled.Warning, + "Unhealthy" => Icons.Material.Filled.Error, + _ => Icons.Material.Filled.HelpOutline + }; + + private static Color GetStatusColor(string? status) => status switch + { + "Healthy" => Color.Success, + "Degraded" => Color.Warning, + "Unhealthy" => Color.Error, + _ => Color.Default + }; + + private static int GetStatusSortOrder(HealthEntry entry) + { + // Show unhealthy first (0), then degraded (1), then healthy (2) + return entry.Status switch + { + "Healthy" => 2, + "Degraded" => 1, + _ => 0 + }; + } + + private static string GetServiceIcon(string? name) => name?.ToLower() switch + { + var n when n?.Contains("identity") == true => Icons.Material.Outlined.Person, + var n when n?.Contains("audit") == true => Icons.Material.Outlined.History, + var n when n?.Contains("tenant") == true => Icons.Material.Outlined.Business, + var n when n?.Contains("migration") == true => Icons.Material.Outlined.Storage, + var n when n?.Contains("db") == true => Icons.Material.Outlined.Storage, + "self" => Icons.Material.Outlined.Memory, + _ => Icons.Material.Outlined.Dns + }; + + private static string FormatServiceName(string? name) + { + if (string.IsNullOrEmpty(name)) return "Unknown"; + + return name switch + { + "self" => "Application Core", + "db:identity" => "Identity Database", + "db:auditing" => "Audit Database", + "db:multitenancy" => "Multitenancy Database", + "db:tenants-migrations" => "Tenant Migrations", + _ => name.Replace(":", " - ").Replace("-", " ") + .Split(' ') + .Select(w => char.ToUpper(w[0]) + w[1..].ToLower()) + .Aggregate((a, b) => $"{a} {b}") + }; + } + + private string GetTotalDuration() + { + if (_readyResult?.Results == null) return "0"; + return _readyResult.Results.Sum(r => r.DurationMs).ToString("F0"); + } + + private string GetAverageDuration() + { + if (_readyResult?.Results == null || !_readyResult.Results.Any()) return "0"; + return _readyResult.Results.Average(r => r.DurationMs).ToString("F1"); + } + + private static string GetTimelineBarStyle(string status) + { + var color = status switch + { + "Healthy" => "var(--mud-palette-success)", + "Degraded" => "var(--mud-palette-warning)", + "Unhealthy" => "var(--mud-palette-error)", + _ => "var(--mud-palette-gray-default)" + }; + return $"width: 12px; height: 100%; background: {color}; border-radius: 4px 4px 0 0; cursor: pointer;"; + } + + public void Dispose() + { + _refreshTimer?.Stop(); + _refreshTimer?.Dispose(); + } + + private class HealthRecord + { + public DateTime Time { get; set; } + public string Status { get; set; } = ""; + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Home.razor b/src/Playground/Playground.Blazor/Components/Pages/Home.razor new file mode 100644 index 0000000000..313b80233d --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/Home.razor @@ -0,0 +1,5 @@ +@page "/welcome" +@inherits ComponentBase + + diff --git a/src/Playground/Playground.Blazor/Components/Pages/ProfileSettings.razor b/src/Playground/Playground.Blazor/Components/Pages/ProfileSettings.razor new file mode 100644 index 0000000000..c4bf753c63 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/ProfileSettings.razor @@ -0,0 +1,641 @@ +@page "/settings/profile" +@using System.Security.Claims +@using Microsoft.AspNetCore.Components.Authorization +@inject FSH.Playground.Blazor.ApiClient.IIdentityClient IdentityClient +@inject ISnackbar Snackbar +@inject NavigationManager Navigation +@inject AuthenticationStateProvider AuthenticationStateProvider +@inject FSH.Playground.Blazor.Services.IUserProfileState UserProfileState + + + + +@if (_loading) +{ + + + Loading profile... + +} +else +{ + + + + + + + + + Profile Photo + + + + + + +
+ @if (!string.IsNullOrEmpty(_avatarPreviewUrl)) + { + + + + } + else + { + + @GetInitials() + + } + @if (_hasImageChanges) + { + Pending + } +
+ + + Upload a profile photo. Max 2MB, images only. + + + + + + + + Upload Photo + + + + + @if (!string.IsNullOrEmpty(_avatarPreviewUrl)) + { + + Remove + + } + +
+
+
+ + + + + + + + Account Status + + + + + + + Account Status + + @(_profile?.IsActive == true ? "Active" : "Inactive") + + + + + Email Verified + + @(_profile?.EmailConfirmed == true ? "Verified" : "Pending") + + + + + Username + @(_profile?.UserName ?? "-") + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Reset + + + @if (_saving) + { + + Saving... + } + else + { + Save Changes + } + + + + + + + + + + + + Change Password + + Update your password to keep your account secure + + + + + + + + + + + + + + + + + + + + + + + Clear + + + @if (_changingPassword) + { + + Updating... + } + else + { + Update Password + } + + + + + + +
+} + + + +@code { + private bool _loading = true; + private bool _saving; + private bool _changingPassword; + private FSH.Playground.Blazor.ApiClient.UserDto? _profile; + private string? _avatarPreviewUrl; + private bool _hasImageChanges; + private bool _deleteCurrentImage; + private byte[]? _pendingImageData; + private string? _pendingImageFileName; + private string? _pendingImageContentType; + + // Form references + private MudForm? _profileForm; + private MudForm? _passwordForm; + + // Profile model + private ProfileModel _profileModel = new(); + + // Password model + private PasswordModel _passwordModel = new(); + + // Password visibility toggles + private bool _showCurrentPassword; + private bool _showNewPassword; + private bool _showConfirmPassword; + + protected override async Task OnInitializedAsync() + { + await LoadProfileAsync(); + } + + private async Task LoadProfileAsync() + { + _loading = true; + try + { + _profile = await IdentityClient.ProfileGetAsync(); + if (_profile is not null) + { + _profileModel = new ProfileModel + { + FirstName = _profile.FirstName, + LastName = _profile.LastName, + Email = _profile.Email, + PhoneNumber = _profile.PhoneNumber + }; + _avatarPreviewUrl = _profile.ImageUrl; + } + } + catch (Exception ex) + { + Snackbar.Add($"Failed to load profile: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + } + } + + private string GetInitials() + { + var first = _profileModel.FirstName?.FirstOrDefault() ?? _profile?.FirstName?.FirstOrDefault() ?? ' '; + var last = _profileModel.LastName?.FirstOrDefault() ?? _profile?.LastName?.FirstOrDefault() ?? ' '; + return $"{char.ToUpperInvariant(first)}{char.ToUpperInvariant(last)}".Trim(); + } + + private async Task OnAvatarSelected(InputFileChangeEventArgs e) + { + var file = e.File; + if (file is null) return; + + try + { + // Validate file size (2MB max) + if (file.Size > 2 * 1024 * 1024) + { + Snackbar.Add("File too large. Maximum 2MB allowed.", Severity.Error); + return; + } + + // Validate file type + if (!file.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + Snackbar.Add("Only image files are allowed.", Severity.Error); + return; + } + + // Read file data + using var stream = file.OpenReadStream(2 * 1024 * 1024); + using var memoryStream = new MemoryStream(); + await stream.CopyToAsync(memoryStream); + var bytes = memoryStream.ToArray(); + + // Store pending upload data + _pendingImageData = bytes; + _pendingImageFileName = file.Name; + _pendingImageContentType = file.ContentType; + _deleteCurrentImage = false; + _hasImageChanges = true; + + // Set preview URL + _avatarPreviewUrl = $"data:{file.ContentType};base64,{Convert.ToBase64String(bytes)}"; + + Snackbar.Add("Image ready. Click Save Changes to upload.", Severity.Info); + StateHasChanged(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to process image: {ex.Message}", Severity.Error); + } + } + + private void DeleteAvatar() + { + _avatarPreviewUrl = null; + _pendingImageData = null; + _pendingImageFileName = null; + _pendingImageContentType = null; + _deleteCurrentImage = true; + _hasImageChanges = true; + Snackbar.Add("Photo will be removed when you save changes.", Severity.Info); + StateHasChanged(); + } + + private void ResetProfile() + { + if (_profile is not null) + { + _profileModel = new ProfileModel + { + FirstName = _profile.FirstName, + LastName = _profile.LastName, + Email = _profile.Email, + PhoneNumber = _profile.PhoneNumber + }; + _avatarPreviewUrl = _profile.ImageUrl; + _pendingImageData = null; + _pendingImageFileName = null; + _pendingImageContentType = null; + _deleteCurrentImage = false; + _hasImageChanges = false; + } + StateHasChanged(); + } + + private async Task SaveProfileAsync() + { + if (_profileForm is not null) + { + await _profileForm.Validate(); + if (!_profileForm.IsValid) + { + Snackbar.Add("Please fix the validation errors.", Severity.Warning); + return; + } + } + + _saving = true; + try + { + var command = new FSH.Playground.Blazor.ApiClient.UpdateUserCommand + { + Id = _profile?.Id ?? string.Empty, + FirstName = _profileModel.FirstName, + LastName = _profileModel.LastName, + Email = _profileModel.Email, + PhoneNumber = _profileModel.PhoneNumber, + DeleteCurrentImage = _deleteCurrentImage + }; + + // Add image upload if pending + if (_pendingImageData is not null && _pendingImageFileName is not null && _pendingImageContentType is not null) + { + command.Image = new FSH.Playground.Blazor.ApiClient.FileUploadRequest + { + FileName = _pendingImageFileName, + ContentType = _pendingImageContentType, + Data = _pendingImageData.Select(b => (int)b).ToList() + }; + } + + await IdentityClient.ProfilePutAsync(command); + + Snackbar.Add("Profile updated successfully!", Severity.Success); + + // Reload profile to get updated data + await LoadProfileAsync(); + _hasImageChanges = false; + + // Notify other components (e.g., header) about the profile change + NotifyProfileChanged(); + } + catch (FSH.Playground.Blazor.ApiClient.ApiException ex) + { + Snackbar.Add($"Failed to update profile: {ex.Message}", Severity.Error); + } + catch (Exception ex) + { + Snackbar.Add($"An error occurred: {ex.Message}", Severity.Error); + } + finally + { + _saving = false; + } + } + + private void ResetPasswordForm() + { + _passwordModel = new PasswordModel(); + _showCurrentPassword = false; + _showNewPassword = false; + _showConfirmPassword = false; + StateHasChanged(); + } + + private string? ValidateConfirmPassword(string confirmPassword) + { + if (string.IsNullOrEmpty(confirmPassword)) + return null; // Required validation handles empty + + if (confirmPassword != _passwordModel.NewPassword) + return "Passwords do not match"; + + return null; + } + + private async Task ChangePasswordAsync() + { + if (_passwordForm is not null) + { + await _passwordForm.Validate(); + if (!_passwordForm.IsValid) + { + Snackbar.Add("Please fix the validation errors.", Severity.Warning); + return; + } + } + + if (_passwordModel.NewPassword != _passwordModel.ConfirmPassword) + { + Snackbar.Add("Passwords do not match.", Severity.Warning); + return; + } + + _changingPassword = true; + try + { + var command = new FSH.Playground.Blazor.ApiClient.ChangePasswordCommand + { + Password = _passwordModel.CurrentPassword, + NewPassword = _passwordModel.NewPassword, + ConfirmNewPassword = _passwordModel.ConfirmPassword + }; + + await IdentityClient.ChangePasswordAsync(command); + + Snackbar.Add("Password changed successfully!", Severity.Success); + ResetPasswordForm(); + } + catch (FSH.Playground.Blazor.ApiClient.ApiException ex) + { + Snackbar.Add($"Failed to change password: {ex.Message}", Severity.Error); + } + catch (Exception ex) + { + Snackbar.Add($"An error occurred: {ex.Message}", Severity.Error); + } + finally + { + _changingPassword = false; + } + } + + private void NotifyProfileChanged() + { + if (_profile is null) return; + + var userName = $"{_profile.FirstName} {_profile.LastName}".Trim(); + if (string.IsNullOrWhiteSpace(userName)) + { + userName = _profile.Email ?? "User"; + } + + UserProfileState.UpdateProfile( + userName, + _profile.Email, + null, // role is not changed here + _profile.ImageUrl); + } + + private sealed class ProfileModel + { + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string? Email { get; set; } + public string? PhoneNumber { get; set; } + } + + private sealed class PasswordModel + { + public string? CurrentPassword { get; set; } + public string? NewPassword { get; set; } + public string? ConfirmPassword { get; set; } + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Roles/CreateRoleDialog.razor b/src/Playground/Playground.Blazor/Components/Pages/Roles/CreateRoleDialog.razor new file mode 100644 index 0000000000..44c456552f --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/Roles/CreateRoleDialog.razor @@ -0,0 +1,162 @@ +@using FSH.Playground.Blazor.ApiClient +@inject IIdentityClient IdentityClient +@inject ISnackbar Snackbar + + + + + @(IsEditMode ? "Edit Role" : "Create New Role") + + @(IsEditMode ? "Modify role details" : "Define a new role for your organization") + + + + + + + @* Role Name *@ + + + @* Role Description *@ + + + @* Info Alert *@ + + + + + @if (IsEditMode) + { + After saving, you can manage permissions for this role from the Roles page. + } + else + { + After creating the role, you can assign permissions to define what users with this role can do. + } + + + + + + + + + Cancel + + + @if (_busy) + { + + @(IsEditMode ? "Saving..." : "Creating...") + } + else + { + @(IsEditMode ? "Save Changes" : "Create Role") + } + + + + +@code { + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public RoleDto? ExistingRole { get; set; } + + private MudForm? _form; + private UpsertRoleCommand _model = new(); + private bool _busy; + + private bool IsEditMode => ExistingRole is not null; + + private static readonly HashSet SystemRoles = new(StringComparer.OrdinalIgnoreCase) + { + "Admin", "Administrator", "Basic" + }; + + protected override void OnInitialized() + { + if (ExistingRole is not null) + { + _model = new UpsertRoleCommand + { + Id = ExistingRole.Id, + Name = ExistingRole.Name, + Description = ExistingRole.Description + }; + } + } + + private static bool IsSystemRole(string? roleName) => + !string.IsNullOrEmpty(roleName) && SystemRoles.Contains(roleName); + + private string? GetSubmitIcon() + { + if (_busy) return null; + return IsEditMode ? Icons.Material.Filled.Save : Icons.Material.Filled.Add; + } + + private void Cancel() + { + MudDialog.Cancel(); + } + + private async Task Submit() + { + if (_form is not null) + { + await _form.Validate(); + if (!_form.IsValid) + { + return; + } + } + + if (string.IsNullOrWhiteSpace(_model.Name)) + { + Snackbar.Add("Please enter a role name.", Severity.Warning); + return; + } + + _busy = true; + try + { + await IdentityClient.RolesPostAsync(_model); + Snackbar.Add($"Role '{_model.Name}' {(IsEditMode ? "updated" : "created")} successfully!", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to {(IsEditMode ? "update" : "create")} role: {ex.Message}", Severity.Error); + } + finally + { + _busy = false; + } + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Roles/RolePermissionsPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Roles/RolePermissionsPage.razor new file mode 100644 index 0000000000..81d19a66ee --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/Roles/RolePermissionsPage.razor @@ -0,0 +1,667 @@ +@page "/roles/{Id:guid}/permissions" +@using FSH.Playground.Blazor.ApiClient +@using FSH.Framework.Blazor.UI.Components.Dialogs +@inherits ComponentBase +@inject IIdentityClient IdentityClient +@inject NavigationManager Navigation +@inject ISnackbar Snackbar +@inject IDialogService DialogService + + + + + Back to Roles + + + + +@if (_loading) +{ + + + + Loading role information... + + +} +else if (_role is null) +{ + + + + Role Not Found + The requested role could not be found. + + Back to Roles + + + +} +else +{ + @* Role Info Header *@ + + + + @_role.Name + + @(string.IsNullOrEmpty(_role.Description) ? "No description provided" : _role.Description) + + + @if (IsAdminRole(_role.Name)) + { + + + Admin (Full Access) + + } + else if (IsSystemRole(_role.Name)) + { + + + System Role + + } + else + { + + + Custom Role + + } + + @_currentPermissions.Count permissions + + + + @if (!IsAdminRole(_role.Name)) + { + + @if (HasChanges) + { + + Reset + + } + + @if (_saving) + { + + } + Save Changes + + + } + + + + @if (IsAdminRole(_role.Name)) + { + + + Admin role has full access to all permissions. These permissions cannot be modified. + + + } + + @* Quick Stats *@ + + + + + + + + @_currentPermissions.Count + Assigned + + + + + + + + + + + + @(_allPermissions.Count - _currentPermissions.Count) + Available + + + + + + + + + + + + @_groupedPermissions.Count + Categories + + + + + + + + + + + + @GetChangedCount() + Changes + + + + + + + + @* Search and Bulk Actions *@ + + + + + @if (!_isReadOnly) + { + + Select All + + + Deselect All + + } + + + Expand + + + Collapse + + + + + + + @* Permissions by Category *@ + + @foreach (var group in FilteredGroups) + { + var categoryKey = group.Key; + var groupPermissions = group.ToList(); + var totalCount = groupPermissions.Count; + + + + + + + + @FormatCategoryName(categoryKey) + @totalCount permissions available + + + + + @GetCategoryAssignedCount(categoryKey) / @totalCount + + + + + + + + @foreach (var permission in groupPermissions.OrderBy(p => p)) + { + // Capture loop variable to avoid closure issue + var perm = permission; + var permissionName = GetPermissionDisplayName(perm); + + + + + + + + @permissionName + + @if (HasPermissionChanged(perm)) + { + + @(IsPermissionAssigned(perm) ? "Will be added" : "Will be removed") + + } + + @if (IsPermissionAssigned(perm)) + { + + } + + + + } + + + + } + + + @if (!FilteredGroups.Any()) + { + + + + No permissions found + + @if (!string.IsNullOrEmpty(_searchTerm)) + { + No permissions match your search. Try a different term. + } + else + { + No permissions available in the system. + } + + + + } +} + + + +@code { + [Parameter] public Guid Id { get; set; } + + private bool _loading = true; + private bool _saving; + private string _searchTerm = ""; + private bool _isReadOnly; + + private RoleDto? _role; + private List _allPermissions = new(); + private HashSet _originalPermissions = new(); + private HashSet _currentPermissions = new(); + + private List> _groupedPermissions = new(); + private HashSet _expandedCategories = new(); + + private bool HasChanges => !_isReadOnly && !_originalPermissions.SetEquals(_currentPermissions); + + private IEnumerable> FilteredGroups + { + get + { + if (string.IsNullOrWhiteSpace(_searchTerm)) + return _groupedPermissions; + + var term = _searchTerm.ToLowerInvariant(); + return _groupedPermissions + .Select(g => new + { + Key = g.Key, + Permissions = g.Where(p => + p.ToLowerInvariant().Contains(term) || + GetPermissionResource(p).ToLowerInvariant().Contains(term) || + GetPermissionAction(p).ToLowerInvariant().Contains(term) + ).ToList() + }) + .Where(g => g.Permissions.Any()) + .Select(g => g.Permissions.GroupBy(_ => g.Key).First()); + } + } + + // Permission format: Permissions.{Resource}.{Action} + private static string GetPermissionResource(string permission) + { + var parts = permission.Split('.'); + return parts.Length >= 2 ? parts[1] : "Other"; + } + + private static string GetPermissionAction(string permission) + { + var parts = permission.Split('.'); + return parts.Length >= 3 ? parts[2] : permission; + } + + private static readonly HashSet SystemRoles = new(StringComparer.OrdinalIgnoreCase) + { + "Admin", "Administrator", "Basic" + }; + + private static readonly HashSet AdminRoles = new(StringComparer.OrdinalIgnoreCase) + { + "Admin", "Administrator" + }; + + private static bool IsSystemRole(string? roleName) => + !string.IsNullOrEmpty(roleName) && SystemRoles.Contains(roleName); + + private static bool IsAdminRole(string? roleName) => + !string.IsNullOrEmpty(roleName) && AdminRoles.Contains(roleName); + + protected override async Task OnInitializedAsync() + { + await LoadData(); + } + + private async Task LoadData() + { + _loading = true; + try + { + // Load role with its current permissions + _role = await IdentityClient.PermissionsGetAsync(Id); + + // Load all available permissions first + var allPerms = await IdentityClient.PermissionsGetAsync(); + _allPermissions = allPerms?.ToList() ?? new List(); + + if (_role is not null) + { + _isReadOnly = IsAdminRole(_role.Name); + + if (_isReadOnly) + { + // Admin role always has all permissions + _originalPermissions = new HashSet(_allPermissions); + _currentPermissions = new HashSet(_allPermissions); + } + else + { + _originalPermissions = new HashSet(_role.Permissions ?? new List()); + _currentPermissions = new HashSet(_originalPermissions); + } + } + + // Group permissions by Resource (second part: Permissions.{Resource}.{Action}) + _groupedPermissions = _allPermissions + .GroupBy(p => GetPermissionResource(p)) + .OrderBy(g => g.Key) + .ToList(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to load data: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + } + } + + private bool IsPermissionAssigned(string permission) => + _currentPermissions.Contains(permission); + + private bool HasPermissionChanged(string permission) => + _currentPermissions.Contains(permission) != _originalPermissions.Contains(permission); + + private string GetPermissionItemClass(string permission) + { + var isAssigned = IsPermissionAssigned(permission); + var hasChanged = HasPermissionChanged(permission); + var classes = "permission-item pa-3"; + if (hasChanged) classes += " permission-changed"; + if (isAssigned) classes += " permission-assigned"; + return classes; + } + + private int GetChangedCount() + { + var added = _currentPermissions.Except(_originalPermissions).Count(); + var removed = _originalPermissions.Except(_currentPermissions).Count(); + return added + removed; + } + + private int GetCategoryAssignedCount(string category) + { + return _allPermissions + .Where(p => GetPermissionResource(p) == category) + .Count(p => _currentPermissions.Contains(p)); + } + + private bool IsCategoryAllSelected(string category) + { + var categoryPermissions = _allPermissions.Where(p => GetPermissionResource(p) == category).ToList(); + return categoryPermissions.Count > 0 && categoryPermissions.All(p => _currentPermissions.Contains(p)); + } + + private bool IsCategorySomeSelected(string category) + { + var categoryPermissions = _allPermissions.Where(p => GetPermissionResource(p) == category).ToList(); + var assignedCount = categoryPermissions.Count(p => _currentPermissions.Contains(p)); + return assignedCount > 0 && assignedCount < categoryPermissions.Count; + } + + private Color GetCategoryChipColor(string category) + { + if (IsCategoryAllSelected(category)) return Color.Success; + if (IsCategorySomeSelected(category)) return Color.Warning; + return Color.Default; + } + + private void TogglePermission(string permission, bool enabled) + { + if (enabled) + _currentPermissions.Add(permission); + else + _currentPermissions.Remove(permission); + StateHasChanged(); + } + + private void ToggleCategory(string category, bool enabled) + { + var categoryPermissions = _allPermissions + .Where(p => GetPermissionResource(p) == category) + .ToList(); + + foreach (var permission in categoryPermissions) + { + if (enabled) + _currentPermissions.Add(permission); + else + _currentPermissions.Remove(permission); + } + StateHasChanged(); + } + + private void SelectAll() + { + foreach (var permission in _allPermissions) + { + _currentPermissions.Add(permission); + } + StateHasChanged(); + } + + private void DeselectAll() + { + _currentPermissions.Clear(); + StateHasChanged(); + } + + private void ExpandAll() + { + foreach (var group in _groupedPermissions) + { + _expandedCategories.Add(group.Key); + } + StateHasChanged(); + } + + private void CollapseAll() + { + _expandedCategories.Clear(); + StateHasChanged(); + } + + private void OnCategoryExpandedChanged(string categoryKey, bool expanded) + { + if (expanded) + _expandedCategories.Add(categoryKey); + else + _expandedCategories.Remove(categoryKey); + } + + private void ResetChanges() + { + _currentPermissions = new HashSet(_originalPermissions); + StateHasChanged(); + } + + private async Task SavePermissions() + { + _saving = true; + try + { + var command = new UpdatePermissionsCommand + { + RoleId = Id.ToString(), + Permissions = _currentPermissions.ToList() + }; + + await IdentityClient.PermissionsPutAsync(Id.ToString(), command); + + _originalPermissions = new HashSet(_currentPermissions); + + Snackbar.Add("Permissions updated successfully.", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to save permissions: {ex.Message}", Severity.Error); + } + finally + { + _saving = false; + } + } + + private void GoBack() + { + Navigation.NavigateTo("/roles"); + } + + private static string FormatCategoryName(string category) + { + if (string.IsNullOrEmpty(category)) return "Other"; + return char.ToUpper(category[0]) + category.Substring(1); + } + + private static string GetPermissionDisplayName(string permission) + { + // Permission format: Permissions.{Resource}.{Action} + // Show the action with nice formatting + var action = GetPermissionAction(permission); + + // Add spaces before capital letters for readability (e.g., "UpgradeSubscription" -> "Upgrade Subscription") + var formatted = string.Concat(action.Select((c, i) => + i > 0 && char.IsUpper(c) ? " " + c : c.ToString())); + + return formatted; + } + + private static string GetCategoryDescription(string category) => category switch + { + "Tenants" => "Manage multi-tenant operations", + "Users" => "Manage user accounts", + "Roles" => "Manage security roles", + "UserRoles" => "Assign roles to users", + "RoleClaims" => "Manage role permissions", + "AuditTrails" => "View audit logs", + "Hangfire" => "Access job dashboard", + "Dashboard" => "Access main dashboard", + _ => $"Manage {category.ToLowerInvariant()}" + }; +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Roles/RolesPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Roles/RolesPage.razor new file mode 100644 index 0000000000..38094be988 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/Roles/RolesPage.razor @@ -0,0 +1,483 @@ +@page "/roles" +@using FSH.Playground.Blazor.ApiClient +@using FSH.Framework.Blazor.UI.Components.Dialogs +@inherits ComponentBase +@inject IIdentityClient IdentityClient +@inject NavigationManager Navigation +@inject ISnackbar Snackbar +@inject IDialogService DialogService + + + + @if (_selectedRoles.Any()) + { + + + Delete (@_selectedRoles.Count) + + + Clear + + + } + + New Role + + + + +@* Stats Cards *@ + + + + + + + + Total + + @_stats.Total + Total Roles + + + + + + + + + + + System + + @_stats.SystemRolesCount + System Roles + + + + + + + + + + + Custom + + @_stats.CustomRolesCount + Custom Roles + + + + + + + + + + + Protected + + @_stats.ProtectedRolesCount + Protected Roles + + + + + + +@* Filter Panel *@ + + + + + + + + + Clear Filters + + + Refresh + + + + + + +@* Roles Grid *@ + + + + + + + + + @context.Item.Name + + + @(string.IsNullOrEmpty(context.Item.Description) ? "No description" : context.Item.Description) + + + + + + + @if (IsSystemRole(context.Item.Name)) + { + + + System + + } + else + { + + + Custom + + } + + + + + + + + + + + + + + + + + + + + + + + + + No roles found + Try adjusting your filters or create a new role. + + + + + + + +@code { + private MudDataGrid? _dataGrid; + private List _roles = new(); + private List _filtered = new(); + private HashSet _selectedRoles = new(); + private bool _loading = true; + private string? _busyRoleId; + private bool _bulkBusy; + + private readonly int _pageSize = 10; + private readonly int[] _pageSizeOptions = { 5, 10, 25, 50 }; + + private RoleStats _stats = new(); + private FilterModel _filter = new(); + + // Only Admin roles are truly protected - Basic can be edited by tenant admins + private static readonly HashSet ProtectedRoles = new(StringComparer.OrdinalIgnoreCase) + { + "Admin", "Administrator" + }; + + // For display purposes, these are considered "system" roles + private static readonly HashSet SystemRoles = new(StringComparer.OrdinalIgnoreCase) + { + "Admin", "Administrator", "Basic" + }; + + private class RoleStats + { + public int Total { get; set; } + public int SystemRolesCount { get; set; } + public int CustomRolesCount { get; set; } + public int ProtectedRolesCount { get; set; } + } + + private class FilterModel + { + public string? Search { get; set; } + } + + protected override async Task OnInitializedAsync() + { + await LoadData(); + } + + private async Task LoadData() + { + _loading = true; + try + { + var result = await IdentityClient.RolesGetAsync(); + _roles = result?.ToList() ?? new List(); + CalculateStats(); + ApplyFilters(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to load roles: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + } + } + + private void CalculateStats() + { + _stats = new RoleStats + { + Total = _roles.Count, + SystemRolesCount = _roles.Count(r => IsSystemRole(r.Name)), + CustomRolesCount = _roles.Count(r => !IsSystemRole(r.Name)), + ProtectedRolesCount = _roles.Count(r => IsProtectedRole(r.Name)) + }; + } + + private void ApplyFilters() + { + var query = _roles.AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(_filter.Search)) + { + var term = _filter.Search.ToLowerInvariant(); + query = query.Where(r => + (r.Name?.ToLowerInvariant().Contains(term) ?? false) || + (r.Description?.ToLowerInvariant().Contains(term) ?? false)); + } + + _filtered = query.OrderBy(r => r.Name).ToList(); + } + + private void RefreshData() + { + ApplyFilters(); + StateHasChanged(); + } + + private void ClearFilters() + { + _filter = new FilterModel(); + ApplyFilters(); + } + + private static bool IsSystemRole(string? roleName) => + !string.IsNullOrEmpty(roleName) && SystemRoles.Contains(roleName); + + private static bool IsProtectedRole(string? roleName) => + !string.IsNullOrEmpty(roleName) && ProtectedRoles.Contains(roleName); + + private void GoToPermissions(string? id) + { + if (Guid.TryParse(id, out var guid)) + Navigation.NavigateTo($"/roles/{guid}/permissions"); + } + + private void OnSelectionChanged(HashSet items) + { + _selectedRoles = items; + } + + private void ClearSelection() + { + _selectedRoles.Clear(); + StateHasChanged(); + } + + private async Task ShowCreate() + { + var options = new DialogOptions + { + MaxWidth = MaxWidth.Small, + FullWidth = true, + CloseOnEscapeKey = true, + CloseButton = true + }; + + var dialog = await DialogService.ShowAsync("Create Role", options); + var result = await dialog.Result; + + if (result is not null && !result.Canceled) + { + await LoadData(); + } + } + + private async Task EditRole(RoleDto role) + { + if (IsProtectedRole(role.Name)) + { + Snackbar.Add("Protected roles cannot be edited.", Severity.Warning); + return; + } + + var parameters = new DialogParameters + { + { x => x.ExistingRole, role } + }; + + var options = new DialogOptions + { + MaxWidth = MaxWidth.Small, + FullWidth = true, + CloseOnEscapeKey = true, + CloseButton = true + }; + + var dialog = await DialogService.ShowAsync("Edit Role", parameters, options); + var result = await dialog.Result; + + if (result is not null && !result.Canceled) + { + await LoadData(); + } + } + + private async Task DeleteRole(RoleDto role) + { + if (string.IsNullOrWhiteSpace(role.Id)) return; + + if (IsProtectedRole(role.Name)) + { + Snackbar.Add("Protected roles cannot be deleted.", Severity.Warning); + return; + } + + var confirmed = await DialogService.ShowDeleteConfirmAsync(role.Name ?? "this role"); + if (!confirmed) return; + + _busyRoleId = role.Id; + try + { + await IdentityClient.RolesDeleteAsync(Guid.Parse(role.Id)); + Snackbar.Add("Role deleted successfully.", Severity.Success); + await LoadData(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to delete role: {ex.Message}", Severity.Error); + } + finally + { + _busyRoleId = null; + } + } + + private async Task BulkDelete() + { + var roles = _selectedRoles.Where(r => !IsProtectedRole(r.Name)).ToList(); + if (!roles.Any()) + { + Snackbar.Add("No deletable roles selected. Protected roles cannot be deleted.", Severity.Info); + return; + } + + var confirmed = await DialogService.ShowConfirmAsync( + "Bulk Delete", + $"Delete {roles.Count} role(s)? This action cannot be undone.", + "Delete All", + "Cancel", + Color.Error, + Icons.Material.Outlined.DeleteForever, + Color.Error); + + if (!confirmed) return; + + _bulkBusy = true; + var success = 0; + foreach (var role in roles) + { + try + { + await IdentityClient.RolesDeleteAsync(Guid.Parse(role.Id!)); + success++; + } + catch + { + // Continue with remaining roles - failures are reflected in the success count + } + } + _bulkBusy = false; + Snackbar.Add($"Deleted {success} of {roles.Count} roles.", Severity.Success); + ClearSelection(); + await LoadData(); + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Sessions/SessionsPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Sessions/SessionsPage.razor new file mode 100644 index 0000000000..578109a1a2 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/Sessions/SessionsPage.razor @@ -0,0 +1,383 @@ +@page "/sessions" +@using FSH.Playground.Blazor.ApiClient +@using FSH.Framework.Blazor.UI.Components.Dialogs +@inherits ComponentBase +@using System.Security.Claims +@using Microsoft.AspNetCore.Components.Authorization +@inject ISessionsClient SessionsClient +@inject IIdentityClient IdentityClient +@inject NavigationManager Navigation +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject AuthenticationStateProvider AuthenticationStateProvider + + + + + Logout All Other Devices + + + + +@* Stats Cards *@ + + + + + + + + Total + + @_stats.Total + Active Sessions + + + + + + + + + + + Desktop + + @_stats.Desktop + Desktop Sessions + + + + + + + + + + + Mobile + + @_stats.Mobile + Mobile Sessions + + + + + + + + + + + Tablet + + @_stats.Tablet + Tablet Sessions + + + + + + +@* Sessions List *@ + + @if (_loading) + { + + } + + + + + + + + + + + + + @(context.Item.Browser ?? "Unknown Browser") + @if (!string.IsNullOrEmpty(context.Item.BrowserVersion)) + { + @context.Item.BrowserVersion + } + + @if (context.Item.IsCurrentSession) + { + + Current + + } + + + @(context.Item.OperatingSystem ?? "Unknown OS") + @if (!string.IsNullOrEmpty(context.Item.OsVersion)) + { + @context.Item.OsVersion + } + + + + + + + + @context.Item.IpAddress + + + + + + @FormatRelativeTime(context.Item.LastActivityAt) + @context.Item.LastActivityAt.ToString("MMM dd, yyyy HH:mm") + + + + + + + @FormatRelativeTime(context.Item.CreatedAt) + @context.Item.CreatedAt.ToString("MMM dd, yyyy HH:mm") + + + + + + @if (context.Item.IsActive) + { + Active + } + else + { + Expired + } + + + + + @if (!context.Item.IsCurrentSession) + { + + + + } + else + { + + + + } + + + + + + + + + + No active sessions + You don't have any active sessions at the moment. + + + + + + + +@code { + private List _sessions = new(); + private bool _loading = true; + private Guid? _busySessionId; + + private readonly int _pageSize = 10; + private readonly int[] _pageSizeOptions = { 5, 10, 25, 50 }; + + private SessionStats _stats = new(); + + private class SessionStats + { + public int Total { get; set; } + public int Desktop { get; set; } + public int Mobile { get; set; } + public int Tablet { get; set; } + } + + protected override async Task OnInitializedAsync() + { + await LoadSessions(); + } + + private async Task LoadSessions() + { + _loading = true; + try + { + var result = await SessionsClient.MeAsync(); + _sessions = result?.ToList() ?? new List(); + CalculateStats(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to load sessions: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + } + } + + private void CalculateStats() + { + _stats = new SessionStats + { + Total = _sessions.Count, + Desktop = _sessions.Count(s => s.DeviceType == "Desktop"), + Mobile = _sessions.Count(s => s.DeviceType == "Mobile"), + Tablet = _sessions.Count(s => s.DeviceType == "Tablet") + }; + } + + private async Task RevokeSession(UserSessionDto session) + { + var confirmed = await DialogService.ShowConfirmAsync( + "Revoke Session", + $"Are you sure you want to log out from this device?\n\n{session.Browser} on {session.OperatingSystem}", + "Revoke", + "Cancel", + Color.Error); + + if (!confirmed) return; + + _busySessionId = session.Id; + try + { + await IdentityClient.SessionsAsync(session.Id); + Snackbar.Add("Session revoked successfully.", Severity.Success); + await LoadSessions(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to revoke session: {ex.Message}", Severity.Error); + } + finally + { + _busySessionId = null; + } + } + + private async Task RevokeAllSessions() + { + var otherSessions = _sessions.Count(s => !s.IsCurrentSession); + if (otherSessions == 0) + { + Snackbar.Add("No other sessions to revoke.", Severity.Info); + return; + } + + var confirmed = await DialogService.ShowConfirmAsync( + "Logout All Other Devices", + $"This will log you out from {otherSessions} other device(s). Your current session will remain active.", + "Logout All", + "Cancel", + Color.Warning, + Icons.Material.Outlined.Warning, + Color.Warning); + + if (!confirmed) return; + + _loading = true; + try + { + await SessionsClient.RevokeAllPostAsync(null); + Snackbar.Add($"Successfully logged out from {otherSessions} device(s).", Severity.Success); + await LoadSessions(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to revoke sessions: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + } + } + + private static string GetDeviceIcon(string? deviceType) => deviceType switch + { + "Mobile" => Icons.Material.Filled.PhoneAndroid, + "Tablet" => Icons.Material.Filled.Tablet, + _ => Icons.Material.Filled.Computer + }; + + private static Color GetDeviceColor(string? deviceType) => deviceType switch + { + "Mobile" => Color.Success, + "Tablet" => Color.Warning, + _ => Color.Info + }; + + private static string FormatRelativeTime(DateTimeOffset dateTime) + { + var timeSpan = DateTimeOffset.UtcNow - dateTime; + + if (timeSpan.TotalMinutes < 1) + return "Just now"; + if (timeSpan.TotalMinutes < 60) + return $"{(int)timeSpan.TotalMinutes} min ago"; + if (timeSpan.TotalHours < 24) + return $"{(int)timeSpan.TotalHours} hour(s) ago"; + if (timeSpan.TotalDays < 7) + return $"{(int)timeSpan.TotalDays} day(s) ago"; + if (timeSpan.TotalDays < 30) + return $"{(int)(timeSpan.TotalDays / 7)} week(s) ago"; + + return $"{(int)(timeSpan.TotalDays / 30)} month(s) ago"; + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/SimpleLogin.razor b/src/Playground/Playground.Blazor/Components/Pages/SimpleLogin.razor new file mode 100644 index 0000000000..aa93bf73f5 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/SimpleLogin.razor @@ -0,0 +1,129 @@ +@page "/login" +@layout EmptyLayout +@using FSH.Framework.Shared.Multitenancy +@using FSH.Playground.Blazor.Services +@using Microsoft.AspNetCore.Components.Authorization +@inject NavigationManager Navigation +@inject IHttpClientFactory HttpClientFactory +@inject AuthenticationStateProvider AuthenticationStateProvider + +Login + + +@code { + private string _tenant = "root"; + private string _email = MultitenancyConstants.Root.EmailAddress; + private string _password = MultitenancyConstants.DefaultPassword; + private bool _showPassword = false; + + protected override async Task OnInitializedAsync() + { + var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); + if (authState.User.Identity?.IsAuthenticated == true) + { + Navigation.NavigateTo("/", replace: true); + } + } + + private void TogglePassword() + { + _showPassword = !_showPassword; + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/SimpleLogin.razor.css b/src/Playground/Playground.Blazor/Components/Pages/SimpleLogin.razor.css new file mode 100644 index 0000000000..864b87e4aa --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/SimpleLogin.razor.css @@ -0,0 +1,295 @@ +/* ===== Login Page - Centered Card Design ===== */ + +.login-page { + min-height: 100vh; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + background-color: #f5f5f5; + padding: 2rem 1rem; +} + +.login-wrapper { + display: flex; + flex-direction: column; + align-items: center; + gap: 1.5rem; + width: 100%; + max-width: 400px; +} + +/* ===== Brand Section ===== */ +.brand-section { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.brand-icon { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background-color: #1a1a1a; + border-radius: 6px; + color: white; +} + +.brand-icon ::deep .mud-icon-root { + font-size: 16px; +} + +.brand-name { + font-size: 1.125rem; + font-weight: 600; + color: #1a1a1a; +} + +/* ===== Login Card ===== */ +.login-card { + width: 100%; + background-color: #ffffff; + border-radius: 12px; + padding: 2rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + border: 1px solid #e5e5e5; +} + +/* ===== Header ===== */ +.login-header { + text-align: center; + margin-bottom: 1.5rem; +} + +.login-title { + font-size: 1.5rem; + font-weight: 600; + color: #1a1a1a; + margin: 0 0 0.5rem 0; +} + +.login-subtitle { + font-size: 0.875rem; + color: #6b7280; + margin: 0; +} + +/* ===== Social Buttons ===== */ +.social-buttons { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.social-button { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + width: 100%; + padding: 0.75rem 1rem; + font-size: 0.875rem; + font-weight: 500; + color: #1a1a1a; + background-color: #ffffff; + border: 1px solid #e5e5e5; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s ease; +} + +.social-button:hover:not(:disabled) { + background-color: #f9fafb; + border-color: #d1d5db; +} + +.social-button:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.social-button svg { + flex-shrink: 0; +} + +/* ===== Divider ===== */ +.divider { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.divider::before, +.divider::after { + content: ""; + flex: 1; + height: 1px; + background-color: #e5e5e5; +} + +.divider span { + font-size: 0.75rem; + color: #9ca3af; + white-space: nowrap; +} + +/* ===== Form ===== */ +.login-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.label-row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.form-label { + font-size: 0.875rem; + font-weight: 500; + color: #1a1a1a; +} + +.forgot-link { + font-size: 0.8rem; + color: #1a1a1a; + text-decoration: underline; + text-underline-offset: 2px; +} + +.forgot-link:hover { + color: #4b5563; +} + +.form-input { + width: 100%; + padding: 0.625rem 0.875rem; + font-size: 0.875rem; + border: 1px solid #e5e5e5; + border-radius: 8px; + background-color: #ffffff; + color: #1a1a1a; + outline: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.form-input::placeholder { + color: #9ca3af; +} + +.form-input:hover { + border-color: #d1d5db; +} + +.form-input:focus { + border-color: #1a1a1a; + box-shadow: 0 0 0 1px #1a1a1a; +} + +/* ===== Login Button ===== */ +.login-button { + width: 100%; + padding: 0.75rem 1rem; + font-size: 0.875rem; + font-weight: 500; + color: #ffffff; + background-color: #1a1a1a; + border: none; + border-radius: 8px; + cursor: pointer; + transition: background-color 0.15s ease; + margin-top: 0.5rem; +} + +.login-button:hover { + background-color: #333333; +} + +.login-button:active { + background-color: #0a0a0a; +} + +/* ===== Sign Up Text ===== */ +.signup-text { + text-align: center; + font-size: 0.875rem; + color: #6b7280; + margin: 0.5rem 0 0 0; +} + +.signup-link { + color: #1a1a1a; + text-decoration: underline; + text-underline-offset: 2px; +} + +.signup-link:hover { + color: #4b5563; +} + +/* ===== Footer ===== */ +.login-footer { + text-align: center; + font-size: 0.75rem; + color: #9ca3af; + max-width: 300px; + line-height: 1.5; +} + +.footer-link { + color: #1a1a1a; + text-decoration: underline; + text-underline-offset: 2px; +} + +.footer-link:hover { + color: #4b5563; +} + +/* ===== Responsive Design ===== */ +@media (max-width: 480px) { + .login-page { + padding: 1rem; + } + + .login-card { + padding: 1.5rem; + } + + .login-title { + font-size: 1.25rem; + } +} + +/* ===== Focus States for Accessibility ===== */ +.login-button:focus-visible, +.social-button:focus-visible, +.forgot-link:focus-visible, +.signup-link:focus-visible, +.footer-link:focus-visible { + outline: 2px solid #1a1a1a; + outline-offset: 2px; +} + +.form-input:focus-visible { + outline: none; +} + +/* Remove focus outline from card/form containers */ +.login-page *:focus:not(.form-input):not(.login-button):not(.social-button), +.login-page *:focus-visible:not(.form-input):not(.login-button):not(.social-button) { + outline: none !important; + box-shadow: none !important; +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Tenants/CreateTenantDialog.razor b/src/Playground/Playground.Blazor/Components/Pages/Tenants/CreateTenantDialog.razor new file mode 100644 index 0000000000..527c725504 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/Tenants/CreateTenantDialog.razor @@ -0,0 +1,204 @@ +@using FSH.Playground.Blazor.ApiClient +@inject IV1Client V1Client +@inject ISnackbar Snackbar + + + + + + + + + Create New Tenant + Add a new organization to the platform + + + + + + + @* Basic Information Section *@ + + + + Basic Information + + + + + + + + + + + @* Admin Account Section *@ + + + + Administrator Account + + + + + + + + @* Advanced Settings Section *@ + + + + + + + + + + + + + @* Info Alert *@ + + + + + + A new tenant will be provisioned with default settings. The admin will receive credentials to access the tenant. + + + + + + + + + + Cancel + + + @if (_busy) + { + + Creating... + } + else + { + Create Tenant + } + + + + + + +@code { + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + private MudForm? _form; + private CreateTenantCommand _model = new(); + private bool _busy; + + private void Cancel() + { + MudDialog.Cancel(); + } + + private async Task Submit() + { + if (_form is not null) + { + await _form.Validate(); + if (!_form.IsValid) + { + return; + } + } + + if (string.IsNullOrWhiteSpace(_model.Id) || + string.IsNullOrWhiteSpace(_model.Name) || + string.IsNullOrWhiteSpace(_model.AdminEmail)) + { + Snackbar.Add("Please fill in all required fields.", Severity.Warning); + return; + } + + // Validate tenant ID format (lowercase, no spaces) + var tenantId = _model.Id.Trim().ToLowerInvariant(); + if (tenantId.Contains(' ') || tenantId.Contains('\t')) + { + Snackbar.Add("Tenant ID cannot contain spaces.", Severity.Error); + return; + } + + _model.Id = tenantId; + + _busy = true; + try + { + await V1Client.TenantsPostAsync(_model); + Snackbar.Add($"Tenant '{_model.Name}' created successfully!", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to create tenant: {ex.Message}", Severity.Error); + } + finally + { + _busy = false; + } + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Tenants/TenantDetailPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Tenants/TenantDetailPage.razor new file mode 100644 index 0000000000..71ec1deaba --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/Tenants/TenantDetailPage.razor @@ -0,0 +1,524 @@ +@page "/tenants/{Id}" +@using FSH.Playground.Blazor.ApiClient +@using FSH.Framework.Blazor.UI.Components.Dialogs +@using FSH.Framework.Shared.Constants +@using FSH.Framework.Shared.Multitenancy +@using Microsoft.AspNetCore.Components.Authorization +@inherits ComponentBase +@inject ITenantsClient TenantsClient +@inject NavigationManager Navigation +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject AuthenticationStateProvider AuthenticationStateProvider + +@if (!_isRootTenantAdmin) +{ + + You do not have permission to access this page. Only root tenant administrators can manage tenants. + +} +else if (_loading) +{ + + + + Loading tenant details... + +} +else if (_tenant is null) +{ + + + Tenant not found. + +} +else +{ + + + + Back to Tenants + + + Upgrade + + @if (!IsRootTenant) + { + + @(_tenant.IsActive ? "Deactivate" : "Activate") + + } + + + + + + + @GetInitials(_tenant) + + + @_tenant.Name + @_tenant.Id + + @if (_tenant.IsActive) + { + Active + } + else + { + Inactive + } + + + + + @* Tenant Information *@ + + + + + Tenant Information + + + + + Tenant ID + @_tenant.Id + + + Name + @_tenant.Name + + + Admin Email + @_tenant.AdminEmail + + + Issuer + @(string.IsNullOrEmpty(_tenant.Issuer) ? "Default" : _tenant.Issuer) + + + + + + @* Subscription Details *@ + + + + + Subscription Details + + + + + Status + @if (_tenant.IsActive) + { + Active + } + else + { + Inactive + } + + + Valid Until + @_tenant.ValidUpto.ToString("MMMM dd, yyyy") + + + Time Remaining + + @GetExpiryText(_tenant.ValidUpto) + + + + + @if (IsExpiringSoon(_tenant.ValidUpto)) + { + + This tenant's subscription is expiring soon. Consider upgrading. + + } + else if (IsExpired(_tenant.ValidUpto)) + { + + This tenant's subscription has expired. + + } + + + + @* Database Configuration *@ + + + + + Database Configuration + + + + + Database Type + @if (_tenant.HasConnectionString) + { + + + Dedicated + + } + else + { + + + Shared + + } + + @if (_tenant.HasConnectionString) + { + + Connection String + + + + + } + + + + + @* Provisioning Status *@ + + + + + + Provisioning Status + + + + + + @if (_provisioningLoading) + { + + + + } + else if (_provisioningStatus is null) + { + + No provisioning information available. + + } + else + { + + + Status + + @_provisioningStatus.Status + + + @if (!string.IsNullOrEmpty(_provisioningStatus.CurrentStep)) + { + + Current Step + @_provisioningStatus.CurrentStep + + } + @if (_provisioningStatus.StartedUtc.HasValue) + { + + Started + @_provisioningStatus.StartedUtc.Value.LocalDateTime.ToString("g") + + } + @if (_provisioningStatus.CompletedUtc.HasValue) + { + + Completed + @_provisioningStatus.CompletedUtc.Value.LocalDateTime.ToString("g") + + } + @if (!string.IsNullOrEmpty(_provisioningStatus.Error)) + { + + @_provisioningStatus.Error + + } + + + @* Provisioning Steps *@ + @if (_provisioningStatus.Steps?.Any() == true) + { + + Provisioning Steps + + @foreach (var step in _provisioningStatus.Steps) + { + + + @step.Step + @step.Status + @if (!string.IsNullOrEmpty(step.Error)) + { + @step.Error + } + + + } + + } + } + + + +} + + + +@code { + [Parameter] public string Id { get; set; } = string.Empty; + + private TenantStatusDto? _tenant; + private TenantProvisioningStatusDto? _provisioningStatus; + private bool _loading = true; + private bool _provisioningLoading; + private bool _busy; + private bool _isRootTenantAdmin; + + private bool IsRootTenant => string.Equals(Id, MultitenancyConstants.Root.Id, StringComparison.OrdinalIgnoreCase); + + protected override async Task OnParametersSetAsync() + { + await CheckRootTenantAdmin(); + if (_isRootTenantAdmin) + { + await LoadTenant(); + await LoadProvisioningStatus(); + } + } + + private async Task CheckRootTenantAdmin() + { + try + { + var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + + var tenant = user.FindFirst(CustomClaims.Tenant)?.Value; + var isRootTenant = string.Equals(tenant, MultitenancyConstants.Root.Id, StringComparison.OrdinalIgnoreCase); + + var roles = user.FindAll(System.Security.Claims.ClaimTypes.Role).Select(c => c.Value).ToList(); + var isAdmin = roles.Any(r => string.Equals(r, RoleConstants.Admin, StringComparison.OrdinalIgnoreCase)); + + _isRootTenantAdmin = isRootTenant && isAdmin; + } + catch + { + _isRootTenantAdmin = false; + } + } + + private async Task LoadTenant() + { + _loading = true; + try + { + _tenant = await TenantsClient.StatusAsync(Id); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to load tenant: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + } + } + + private async Task LoadProvisioningStatus() + { + _provisioningLoading = true; + try + { + _provisioningStatus = await TenantsClient.ProvisioningAsync(Id); + } + catch + { + _provisioningStatus = null; + } + finally + { + _provisioningLoading = false; + } + } + + private static string GetInitials(TenantStatusDto tenant) + { + if (string.IsNullOrWhiteSpace(tenant.Name)) return "?"; + var words = tenant.Name.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (words.Length >= 2) + return $"{words[0][0]}{words[1][0]}".ToUpperInvariant(); + return tenant.Name.Length >= 2 + ? tenant.Name[..2].ToUpperInvariant() + : tenant.Name.ToUpperInvariant(); + } + + private static bool IsExpired(DateTimeOffset validUpto) => + validUpto <= DateTimeOffset.UtcNow; + + private static bool IsExpiringSoon(DateTimeOffset validUpto) + { + var now = DateTimeOffset.UtcNow; + return validUpto > now && validUpto <= now.AddDays(30); + } + + private static string GetExpiryText(DateTimeOffset validUpto) + { + var now = DateTimeOffset.UtcNow; + if (validUpto <= now) + return "Expired"; + + var diff = validUpto - now; + if (diff.TotalDays < 1) + return "Expires today"; + if (diff.TotalDays < 2) + return "Expires tomorrow"; + if (diff.TotalDays <= 30) + return $"Expires in {(int)diff.TotalDays} days"; + + return $"{(int)diff.TotalDays} days remaining"; + } + + private static Color GetExpiryColor(DateTimeOffset validUpto) + { + if (IsExpired(validUpto)) return Color.Error; + if (IsExpiringSoon(validUpto)) return Color.Warning; + return Color.Default; + } + + private static Color GetProvisioningStatusColor(string status) => status?.ToLowerInvariant() switch + { + "completed" => Color.Success, + "inprogress" or "in_progress" => Color.Info, + "pending" => Color.Warning, + "failed" => Color.Error, + _ => Color.Default + }; + + private static Color GetStepColor(string status) => status?.ToLowerInvariant() switch + { + "completed" => Color.Success, + "inprogress" or "in_progress" => Color.Info, + "pending" => Color.Default, + "failed" => Color.Error, + _ => Color.Default + }; + + private void GoBack() => Navigation.NavigateTo("/tenants"); + + private async Task ToggleStatus() + { + if (_tenant is null) return; + + if (IsRootTenant) + { + Snackbar.Add("Root tenant cannot be deactivated.", Severity.Warning); + return; + } + + var action = _tenant.IsActive ? "deactivate" : "activate"; + var confirmed = await DialogService.ShowConfirmAsync( + $"{(_tenant.IsActive ? "Deactivate" : "Activate")} Tenant", + $"Are you sure you want to {action} '{_tenant.Name}'?", + _tenant.IsActive ? "Deactivate" : "Activate", + "Cancel", + _tenant.IsActive ? Color.Warning : Color.Success); + + if (!confirmed) return; + + _busy = true; + try + { + var command = new ChangeTenantActivationCommand + { + TenantId = _tenant.Id, + IsActive = !_tenant.IsActive + }; + await TenantsClient.ActivationAsync(_tenant.Id, command); + Snackbar.Add($"Tenant {(_tenant.IsActive ? "deactivated" : "activated")} successfully.", Severity.Success); + await LoadTenant(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to update tenant: {ex.Message}", Severity.Error); + } + finally + { + _busy = false; + } + } + + private async Task ShowUpgrade() + { + if (_tenant is null) return; + + var tenantDto = new TenantDto + { + Id = _tenant.Id, + Name = _tenant.Name, + AdminEmail = _tenant.AdminEmail, + IsActive = _tenant.IsActive, + ValidUpto = _tenant.ValidUpto, + Issuer = _tenant.Issuer + }; + + var parameters = new DialogParameters + { + { x => x.Tenant, tenantDto } + }; + + var options = new DialogOptions + { + MaxWidth = MaxWidth.Small, + FullWidth = true, + CloseOnEscapeKey = true, + CloseButton = true + }; + + var dialog = await DialogService.ShowAsync("Upgrade Subscription", parameters, options); + var result = await dialog.Result; + + if (result is not null && !result.Canceled) + { + await LoadTenant(); + } + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Tenants/TenantSettingsPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Tenants/TenantSettingsPage.razor new file mode 100644 index 0000000000..62789fa24b --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/Tenants/TenantSettingsPage.razor @@ -0,0 +1,192 @@ +@page "/tenants/settings" +@using FSH.Playground.Blazor.ApiClient +@using FSH.Framework.Blazor.UI.Components.Page +@using FSH.Framework.Shared.Constants +@using FSH.Framework.Shared.Multitenancy +@using Microsoft.AspNetCore.Components.Authorization +@inherits ComponentBase +@inject NavigationManager Navigation +@inject ISnackbar Snackbar +@inject AuthenticationStateProvider AuthenticationStateProvider + +@if (!_isRootTenantAdmin) +{ + + You do not have permission to access this page. Only root tenant administrators can manage tenant settings. + +} +else +{ + + + + @* Session Management Settings *@ + + + + + Session Management + + + + + Session management settings will be available in a future update. + + + Configure session limits, idle timeouts, and concurrent session policies for all tenants. + + + + Max Sessions per User + Unlimited + + + Session Idle Timeout + None + + + Session Absolute Timeout + 7 days + + + + + + + @* Security Settings *@ + + + + + Security Policies + + + + + Security policy settings will be available in a future update. + + + Configure password policies, lockout settings, and authentication requirements. + + + + Password History + Enabled (5) + + + Account Lockout + Default + + + Two-Factor Authentication + Optional + + + + + + + @* Quota Settings *@ + + + + + Usage Quotas + + + + + Quota settings will be available in a future update. + + + Configure storage limits, API rate limits, and resource quotas per tenant. + + + + Max Users + Unlimited + + + Storage Limit + Unlimited + + + API Rate Limit + None + + + + + + + @* Notification Settings *@ + + + + + Notifications + + + + + Notification settings will be available in a future update. + + + Configure email notifications, alerts, and system messages for tenants. + + + + Email Notifications + Enabled + + + System Alerts + Enabled + + + Subscription Reminders + Enabled + + + + + + +} + +@code { + private bool _isRootTenantAdmin; + + protected override async Task OnInitializedAsync() + { + await CheckRootTenantAdmin(); + } + + private async Task CheckRootTenantAdmin() + { + try + { + var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + + if (user.Identity?.IsAuthenticated != true) + { + _isRootTenantAdmin = false; + return; + } + + var tenant = user.FindFirst(CustomClaims.Tenant)?.Value; + var isRootTenant = string.Equals(tenant, MultitenancyConstants.Root.Id, StringComparison.OrdinalIgnoreCase); + + var roles = user.FindAll(System.Security.Claims.ClaimTypes.Role).Select(c => c.Value).ToList(); + var isAdmin = roles.Any(r => string.Equals(r, RoleConstants.Admin, StringComparison.OrdinalIgnoreCase)); + + _isRootTenantAdmin = isRootTenant && isAdmin; + } + catch + { + _isRootTenantAdmin = false; + } + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Tenants/TenantsPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Tenants/TenantsPage.razor new file mode 100644 index 0000000000..ac8e0960a4 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/Tenants/TenantsPage.razor @@ -0,0 +1,562 @@ +@page "/tenants" +@using FSH.Playground.Blazor.ApiClient +@using FSH.Framework.Blazor.UI.Components.Dialogs +@using FSH.Framework.Shared.Constants +@using FSH.Framework.Shared.Multitenancy +@using Microsoft.AspNetCore.Components.Authorization +@inherits ComponentBase +@inject IV1Client V1Client +@inject ITenantsClient TenantsClient +@inject NavigationManager Navigation +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject AuthenticationStateProvider AuthenticationStateProvider + +@if (!_isRootTenantAdmin) +{ + + You do not have permission to access this page. Only root tenant administrators can manage tenants. + +} +else +{ + + + + New Tenant + + + + + @* Stats Cards *@ + + + + + + + + Total + + @_stats.Total + Total Tenants + + + + + + + + + + + Active + + @_stats.Active + Active Tenants + + + + + + + + + + + Inactive + + @_stats.Inactive + Inactive Tenants + + + + + + + + + + + Expiring + + @_stats.ExpiringSoon + Expiring in 30 days + + + + + + + @* Filter Panel *@ + + + + + + + + + All + Active + Inactive + + + + + Clear Filters + + + Refresh + + + + + + + @* Data Grid *@ + + + + + + + + @GetInitials(context.Item) + + + + @context.Item.Name + + @context.Item.Id + + + + + + + + + @context.Item.ValidUpto.ToString("MMM dd, yyyy") + @if (IsExpiringSoon(context.Item.ValidUpto)) + { + + @GetExpiryText(context.Item.ValidUpto) + + } + else if (IsExpired(context.Item.ValidUpto)) + { + Expired + } + else + { + + @GetExpiryText(context.Item.ValidUpto) + + } + + + + + + @if (!string.IsNullOrWhiteSpace(context.Item.ConnectionString)) + { + + + Dedicated + + } + else + { + + + Shared + + } + + + + + @if (context.Item.IsActive) + { + Active + } + else + { + Inactive + } + + + + + + + + + + + + + + + + + + + + + + + + + No tenants found + Try adjusting your filters or create a new tenant. + + + + +} + + + +@code { + private MudDataGrid? _dataGrid; + private List _tenants = new(); + private List _filtered = new(); + private bool _loading = true; + private string? _busyTenantId; + private bool _isRootTenantAdmin; + + private readonly int _pageSize = 10; + private readonly int[] _pageSizeOptions = { 5, 10, 25, 50 }; + + private TenantStats _stats = new(); + private FilterModel _filter = new(); + + private class TenantStats + { + public int Total { get; set; } + public int Active { get; set; } + public int Inactive { get; set; } + public int ExpiringSoon { get; set; } + } + + private class FilterModel + { + public string? Search { get; set; } + public bool? IsActive { get; set; } + } + + protected override async Task OnInitializedAsync() + { + await CheckRootTenantAdmin(); + if (_isRootTenantAdmin) + { + await LoadData(); + } + } + + private async Task CheckRootTenantAdmin() + { + try + { + var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + + var tenant = user.FindFirst(CustomClaims.Tenant)?.Value; + var isRootTenant = string.Equals(tenant, MultitenancyConstants.Root.Id, StringComparison.OrdinalIgnoreCase); + + var roles = user.FindAll(System.Security.Claims.ClaimTypes.Role).Select(c => c.Value).ToList(); + var isAdmin = roles.Any(r => string.Equals(r, RoleConstants.Admin, StringComparison.OrdinalIgnoreCase)); + + _isRootTenantAdmin = isRootTenant && isAdmin; + } + catch + { + _isRootTenantAdmin = false; + } + } + + private async Task LoadData() + { + _loading = true; + try + { + var result = await V1Client.TenantsGetAsync(pageSize: 100); + _tenants = result?.Items?.ToList() ?? new List(); + CalculateStats(); + ApplyFilters(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to load tenants: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + } + } + + private void CalculateStats() + { + var now = DateTimeOffset.UtcNow; + var thirtyDaysFromNow = now.AddDays(30); + + _stats = new TenantStats + { + Total = _tenants.Count, + Active = _tenants.Count(t => t.IsActive), + Inactive = _tenants.Count(t => !t.IsActive), + ExpiringSoon = _tenants.Count(t => t.IsActive && t.ValidUpto > now && t.ValidUpto <= thirtyDaysFromNow) + }; + } + + private void ApplyFilters() + { + var query = _tenants.AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(_filter.Search)) + { + var term = _filter.Search.ToLowerInvariant(); + query = query.Where(t => + (t.Name?.ToLowerInvariant().Contains(term) ?? false) || + (t.Id?.ToLowerInvariant().Contains(term) ?? false) || + (t.AdminEmail?.ToLowerInvariant().Contains(term) ?? false)); + } + + if (_filter.IsActive.HasValue) + { + query = query.Where(t => t.IsActive == _filter.IsActive.Value); + } + + _filtered = query.OrderBy(t => t.Name).ToList(); + } + + private void RefreshData() + { + ApplyFilters(); + StateHasChanged(); + } + + private async Task OnStatusFilterChanged(bool? value) + { + _filter.IsActive = value; + RefreshData(); + await Task.CompletedTask; + } + + private void ClearFilters() + { + _filter = new FilterModel(); + ApplyFilters(); + } + + private static string GetInitials(TenantDto tenant) + { + if (string.IsNullOrWhiteSpace(tenant.Name)) return "?"; + var words = tenant.Name.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (words.Length >= 2) + return $"{words[0][0]}{words[1][0]}".ToUpperInvariant(); + return tenant.Name.Length >= 2 + ? tenant.Name[..2].ToUpperInvariant() + : tenant.Name.ToUpperInvariant(); + } + + private static bool IsRootTenant(TenantDto tenant) => + string.Equals(tenant.Id, MultitenancyConstants.Root.Id, StringComparison.OrdinalIgnoreCase); + + private static bool IsExpired(DateTimeOffset validUpto) => + validUpto <= DateTimeOffset.UtcNow; + + private static bool IsExpiringSoon(DateTimeOffset validUpto) + { + var now = DateTimeOffset.UtcNow; + return validUpto > now && validUpto <= now.AddDays(30); + } + + private static string GetExpiryText(DateTimeOffset validUpto) + { + var now = DateTimeOffset.UtcNow; + if (validUpto <= now) + return "Expired"; + + var diff = validUpto - now; + if (diff.TotalDays < 1) + return "Expires today"; + if (diff.TotalDays < 2) + return "Expires tomorrow"; + if (diff.TotalDays <= 30) + return $"Expires in {(int)diff.TotalDays} days"; + + return $"{(int)diff.TotalDays} days remaining"; + } + + private bool IsToggleDisabled(TenantDto tenant) => + string.IsNullOrWhiteSpace(tenant.Id) || + _busyTenantId == tenant.Id || + IsRootTenant(tenant); + + private static string GetToggleTooltip(TenantDto tenant) + { + if (IsRootTenant(tenant)) + return "Root tenant cannot be deactivated"; + return tenant.IsActive ? "Deactivate tenant" : "Activate tenant"; + } + + private void GoToDetail(string? id) + { + if (!string.IsNullOrWhiteSpace(id)) + Navigation.NavigateTo($"/tenants/{id}"); + } + + private async Task ToggleStatus(TenantDto tenant) + { + if (string.IsNullOrWhiteSpace(tenant.Id)) return; + + if (IsRootTenant(tenant)) + { + Snackbar.Add("Root tenant cannot be deactivated.", Severity.Warning); + return; + } + + var action = tenant.IsActive ? "deactivate" : "activate"; + var confirmed = await DialogService.ShowConfirmAsync( + $"{(tenant.IsActive ? "Deactivate" : "Activate")} Tenant", + $"Are you sure you want to {action} '{tenant.Name}'?", + tenant.IsActive ? "Deactivate" : "Activate", + "Cancel", + tenant.IsActive ? Color.Warning : Color.Success); + + if (!confirmed) return; + + _busyTenantId = tenant.Id; + try + { + var command = new ChangeTenantActivationCommand + { + TenantId = tenant.Id, + IsActive = !tenant.IsActive + }; + await TenantsClient.ActivationAsync(tenant.Id, command); + Snackbar.Add($"Tenant {(tenant.IsActive ? "deactivated" : "activated")} successfully.", Severity.Success); + await LoadData(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to update tenant: {ex.Message}", Severity.Error); + } + finally + { + _busyTenantId = null; + } + } + + private async Task ShowCreate() + { + var options = new DialogOptions + { + MaxWidth = MaxWidth.Medium, + FullWidth = true, + CloseOnEscapeKey = true, + CloseButton = true + }; + + var dialog = await DialogService.ShowAsync("Create Tenant", options); + var result = await dialog.Result; + + if (result is not null && !result.Canceled) + { + await LoadData(); + } + } + + private async Task ShowUpgrade(TenantDto tenant) + { + var parameters = new DialogParameters + { + { x => x.Tenant, tenant } + }; + + var options = new DialogOptions + { + MaxWidth = MaxWidth.Small, + FullWidth = true, + CloseOnEscapeKey = true, + CloseButton = true + }; + + var dialog = await DialogService.ShowAsync("Upgrade Subscription", parameters, options); + var result = await dialog.Result; + + if (result is not null && !result.Canceled) + { + await LoadData(); + } + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Tenants/UpgradeTenantDialog.razor b/src/Playground/Playground.Blazor/Components/Pages/Tenants/UpgradeTenantDialog.razor new file mode 100644 index 0000000000..e09ca42093 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/Tenants/UpgradeTenantDialog.razor @@ -0,0 +1,245 @@ +@using FSH.Playground.Blazor.ApiClient +@inject ITenantsClient TenantsClient +@inject ISnackbar Snackbar + + + + + + + + + Upgrade Subscription + Extend the subscription for @Tenant?.Name + + + + + @if (Tenant is not null) + { + + @* Current Subscription Info *@ + + + Current Subscription + + Tenant + @Tenant.Name + + + Valid Until + + @Tenant.ValidUpto.ToString("MMMM dd, yyyy") + + + + Status + @if (IsExpired(Tenant.ValidUpto)) + { + Expired + } + else if (IsExpiringSoon(Tenant.ValidUpto)) + { + Expiring Soon + } + else + { + Active + } + + + + + @* New Expiry Date *@ + + New Expiry Date + + + + @* Quick Extend Options *@ + + Quick Extend + + + +30 Days + + + +90 Days + + + +6 Months + + + +1 Year + + + + + @* Preview *@ + @if (_newExpiryDate.HasValue) + { + + + + Preview: Subscription will be extended to @_newExpiryDate.Value.ToString("MMMM dd, yyyy") + + @{ + var daysAdded = (_newExpiryDate.Value - Tenant.ValidUpto.Date).Days; + if (daysAdded > 0) + { + + Adding @daysAdded days to the subscription + + } + } + + + } + + } + + + + Cancel + + + @if (_busy) + { + + Upgrading... + } + else + { + Upgrade Subscription + } + + + + + + +@code { + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public TenantDto? Tenant { get; set; } + + private DateTime? _newExpiryDate; + private bool _busy; + + protected override void OnParametersSet() + { + if (Tenant is not null) + { + // Default to current expiry + 30 days, or today + 30 days if expired + var baseDate = Tenant.ValidUpto.Date > DateTime.Today + ? Tenant.ValidUpto.Date + : DateTime.Today; + _newExpiryDate = baseDate.AddDays(30); + } + } + + private void ExtendBy(int days) + { + if (Tenant is null) return; + + var baseDate = Tenant.ValidUpto.Date > DateTime.Today + ? Tenant.ValidUpto.Date + : DateTime.Today; + _newExpiryDate = baseDate.AddDays(days); + } + + private static bool IsExpired(DateTimeOffset validUpto) => + validUpto <= DateTimeOffset.UtcNow; + + private static bool IsExpiringSoon(DateTimeOffset validUpto) + { + var now = DateTimeOffset.UtcNow; + return validUpto > now && validUpto <= now.AddDays(30); + } + + private static Color GetExpiryColor(DateTimeOffset validUpto) + { + if (IsExpired(validUpto)) return Color.Error; + if (IsExpiringSoon(validUpto)) return Color.Warning; + return Color.Default; + } + + private void Cancel() + { + MudDialog.Cancel(); + } + + private async Task Submit() + { + if (Tenant is null || !_newExpiryDate.HasValue) + { + Snackbar.Add("Please select a new expiry date.", Severity.Warning); + return; + } + + if (_newExpiryDate.Value <= DateTime.Today) + { + Snackbar.Add("New expiry date must be in the future.", Severity.Error); + return; + } + + _busy = true; + try + { + // Convert the local date to UTC (use end of day in UTC to ensure full day coverage) + var utcDate = DateTime.SpecifyKind(_newExpiryDate.Value.Date, DateTimeKind.Utc); + + var command = new UpgradeTenantCommand + { + Tenant = Tenant.Id, + ExtendedExpiryDate = new DateTimeOffset(utcDate) + }; + + await TenantsClient.UpgradeAsync(Tenant.Id, command); + Snackbar.Add($"Subscription for '{Tenant.Name}' upgraded successfully!", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to upgrade subscription: {ex.Message}", Severity.Error); + } + finally + { + _busy = false; + } + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/ThemeSettings.razor b/src/Playground/Playground.Blazor/Components/Pages/ThemeSettings.razor new file mode 100644 index 0000000000..cc0e37f4dd --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/ThemeSettings.razor @@ -0,0 +1,113 @@ +@page "/settings/theme" +@using FSH.Framework.Blazor.UI.Theme +@using FSH.Framework.Blazor.UI.Components.Theme +@using FSH.Framework.Blazor.UI.Components.Page +@using FSH.Playground.Blazor.Services +@inject ITenantThemeState ThemeState +@inject ISnackbar Snackbar + +Theme Settings + + + + + Reset to Defaults + + + @if (_customizer?.IsSaving ?? false) + { + + Saving... + } + else + { + Save Changes + } + + + + + + +@code { + private FshThemeCustomizer? _customizer; + + private async Task SaveChanges() + { + if (_customizer != null) + { + await _customizer.SaveChangesAsync(); + } + } + + private async Task ResetToDefaults() + { + if (_customizer != null) + { + await _customizer.ResetToDefaultsAsync(); + } + } + + private async Task OnThemeSaved() + { + // Reload theme after save to get the actual uploaded URLs + await ThemeState.LoadThemeAsync(); + Snackbar.Add("Theme saved successfully.", Severity.Success); + } + + private async Task HandleAssetUpload((IBrowserFile File, string AssetType, Action SetAsset) args) + { + try + { + // Validate file + if (args.File.Size > 2 * 1024 * 1024) // 2MB max + { + Snackbar.Add("File too large. Maximum 2MB allowed.", Severity.Error); + return; + } + + if (!args.File.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + Snackbar.Add("Only image files are allowed.", Severity.Error); + return; + } + + // Read file data + using var stream = args.File.OpenReadStream(2 * 1024 * 1024); + using var memoryStream = new MemoryStream(); + await stream.CopyToAsync(memoryStream); + var bytes = memoryStream.ToArray(); + + // Create file upload (same pattern as profile picture) + var fileUpload = new FileUpload + { + FileName = args.File.Name, + ContentType = args.File.ContentType, + Data = bytes + }; + + // Set preview URL as data URL for immediate visual feedback + var previewUrl = $"data:{args.File.ContentType};base64,{Convert.ToBase64String(bytes)}"; + + // Set both preview URL and file upload data via callback + args.SetAsset(previewUrl, fileUpload); + Snackbar.Add("Image ready. Click Save to upload.", Severity.Info); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to process file: {ex.Message}", Severity.Error); + } + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Users/CreateUserDialog.razor b/src/Playground/Playground.Blazor/Components/Pages/Users/CreateUserDialog.razor new file mode 100644 index 0000000000..a79ea7799e --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/Users/CreateUserDialog.razor @@ -0,0 +1,271 @@ +@using FSH.Playground.Blazor.ApiClient +@inject IIdentityClient IdentityClient +@inject ISnackbar Snackbar + + + + + + + + + Create New User + Add a new team member to the system + + + + + + @* Step indicator *@ + + + + + + Personal + + + + + + + Account + + + + + + + Security + + + + + @* Personal Information Section *@ + + + + Personal Information + + + + + + + + + + + @* Account Details Section *@ + + + + Account Details + + + + + + + + + + + + + + @* Security Section *@ + + + + Security + + + + + + + + + + + @* Info Alert *@ + + + + + + The user will receive an email to verify their account. You can manage their roles after creation. + + + + + + + + + + Cancel + + + @if (_busy) + { + + Creating... + } + else + { + Create User + } + + + + + + +@code { + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + private MudForm? _form; + private RegisterUserCommand _model = new(); + private bool _busy; + private bool _showPassword; + + private void TogglePasswordVisibility() + { + _showPassword = !_showPassword; + } + + private void Cancel() + { + MudDialog.Cancel(); + } + + private async Task Submit() + { + if (_form is not null) + { + await _form.Validate(); + if (!_form.IsValid) + { + return; + } + } + + if (string.IsNullOrWhiteSpace(_model.FirstName) || + string.IsNullOrWhiteSpace(_model.LastName) || + string.IsNullOrWhiteSpace(_model.Email) || + string.IsNullOrWhiteSpace(_model.UserName) || + string.IsNullOrWhiteSpace(_model.Password)) + { + Snackbar.Add("Please fill in all required fields.", Severity.Warning); + return; + } + + if (_model.Password != _model.ConfirmPassword) + { + Snackbar.Add("Passwords do not match.", Severity.Error); + return; + } + + _busy = true; + try + { + await IdentityClient.RegisterAsync(_model); + Snackbar.Add($"User '{_model.FirstName} {_model.LastName}' created successfully!", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to create user: {ex.Message}", Severity.Error); + } + finally + { + _busy = false; + } + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Users/UserDetailPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Users/UserDetailPage.razor new file mode 100644 index 0000000000..11d3bf5061 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/Users/UserDetailPage.razor @@ -0,0 +1,634 @@ +@page "/users/{Id:guid}" +@using FSH.Playground.Blazor.ApiClient +@using FSH.Framework.Blazor.UI.Components.Dialogs +@inherits ComponentBase +@using System.Security.Claims +@using FSH.Framework.Shared.Constants +@using Microsoft.AspNetCore.Components.Authorization +@inject IIdentityClient IdentityClient +@inject IUsersClient UsersClient +@inject NavigationManager Navigation +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject AuthenticationStateProvider AuthenticationStateProvider + + + + + Back to Users + + @if (_user != null) + { + + Manage Roles + + } + + + +@if (_loading) +{ + + + + Loading user... + + +} +else if (_user is null) +{ + + + + User not found + The user you're looking for doesn't exist. + + Back to Users + + + +} +else +{ + + @* Left Column - Profile Card *@ + + + @* Cover/Header *@ +
+
+ @if (!string.IsNullOrEmpty(_user.ImageUrl)) + { + + } + else + { + @GetInitials(_user) + } +
+
+
+ + @* Profile Info *@ +
+ + @_user.FirstName @_user.LastName + + + @@@_user.UserName + + + + + @(_user.IsActive ? "Active" : "Inactive") + + + @(_user.EmailConfirmed ? "Verified" : "Unverified") + + @if (_targetIsAdmin) + { + + Admin + + } + + + + + + + + Email + @_user.Email + + + + + Phone + @(string.IsNullOrEmpty(_user.PhoneNumber) ? "Not provided" : _user.PhoneNumber) + + + + + User ID + @_user.Id + + + +
+
+
+ + @* Right Column - Details & Actions *@ + + @* Stats Row *@ + + + + + + + @_roles.Count(r => r.Enabled) + Roles + + + + + + + + + + @(_user.EmailConfirmed ? "Yes" : "No") + Verified + + + + + + + + + + @(_user.IsActive ? "Active" : "Inactive") + Status + + + + + + + + + + @(_targetIsAdmin ? "Admin" : "User") + Type + + + + + + + @* Roles Card *@ + + + + + Assigned Roles + + + Manage + + + + @if (!_roles.Any(r => r.Enabled)) + { + + No roles assigned. Click "Manage" to assign roles. + + } + else + { + + @RenderRoleChips(_roles.Where(r => r.Enabled)) + + } + + + @* Groups Card *@ + + + + + Group Memberships + + + View All Groups + + + + @if (_loadingGroups) + { + + } + else if (!_groups.Any()) + { + + Not a member of any groups. + + } + else + { + + @foreach (var group in _groups) + { + + @group.Name + @if (group.IsDefault) + { + + } + + } + + } + + + @* Actions & Danger Zone Side by Side *@ + + @* Account Actions *@ + + + + + Account Actions + + + + @if (!_user.EmailConfirmed) + { + + + + + Confirm Email + + + + + Enter the confirmation code sent to the user's email. + + + Confirm Email + + + + + } + + + + + Reset Password + + + + + Requires a valid reset token from email flow. + + + + Reset Password + + + + + + + + + Send Password Reset + + + + + Send a password reset email to the user. + + Send Reset Email + + + + + + + + + @* Danger Zone *@ + + + + + Danger Zone + + + + + + + @(_user.IsActive ? "Deactivate Account" : "Activate Account") + + + @(_user.IsActive ? "User will lose access to the system" : "Restore user access") + + + + + @(_user.IsActive ? "Deactivate" : "Activate") + + + + + + + Delete Account + Permanently remove this user + + + Delete + + + + + + + +
+} + +@code { + [Parameter] public Guid Id { get; set; } + + private UserDto? _user; + private List _roles = new(); + private List _groups = new(); + private bool _loading = true; + private bool _loadingGroups = true; + private bool _busy; + private ResetPasswordCommand _resetModel = new(); + private ConfirmEmailModel _confirmModel = new(); + private const string TenantContext = "root"; + private string? _currentUserId; + private bool _targetIsAdmin; + + protected override async Task OnParametersSetAsync() + { + await ResolveCurrentUser(); + await LoadUser(); + await LoadGroups(); + } + + private async Task LoadUser() + { + _loading = true; + try + { + _user = await IdentityClient.UsersGetAsync(Id); + _roles = (await UsersClient.RolesGetAsync(Id))?.ToList() ?? new List(); + _resetModel.Email = _user?.Email ?? string.Empty; + _confirmModel.UserId = _user?.Id ?? string.Empty; + _targetIsAdmin = _roles.Any(r => string.Equals(r.RoleName, RoleConstants.Admin, StringComparison.OrdinalIgnoreCase) && r.Enabled); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to load user: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + } + } + + private async Task ResolveCurrentUser() + { + try + { + var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); + _currentUserId = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + } + catch + { + // Authentication state unavailable - user remains null (not logged in) + } + } + + private bool IsCurrentUser => + _user is not null && + !string.IsNullOrWhiteSpace(_currentUserId) && + string.Equals(_currentUserId, _user.Id, StringComparison.OrdinalIgnoreCase); + + private bool IsToggleDisabled => _busy || _user is null || IsCurrentUser || _targetIsAdmin; + + private string ToggleTooltip + { + get + { + if (_user is null) return "User not loaded"; + if (IsCurrentUser) return "Cannot modify your own account"; + if (_targetIsAdmin) return "Cannot deactivate administrators"; + return _user.IsActive ? "Deactivate user" : "Activate user"; + } + } + + private async Task ToggleStatus() + { + if (_user is null || string.IsNullOrWhiteSpace(_user.Id) || IsToggleDisabled) return; + + var action = _user.IsActive ? "deactivate" : "activate"; + var confirmed = await DialogService.ShowConfirmAsync( + $"{(_user.IsActive ? "Deactivate" : "Activate")} User", + $"Are you sure you want to {action} {_user.FirstName} {_user.LastName}?", + _user.IsActive ? "Deactivate" : "Activate", + "Cancel", + _user.IsActive ? Color.Warning : Color.Success); + + if (!confirmed) return; + + _busy = true; + try + { + await IdentityClient.UsersPatchAsync(Id, new ToggleUserStatusCommand + { + ActivateUser = !_user.IsActive, + UserId = _user.Id + }); + Snackbar.Add($"User {(_user.IsActive ? "deactivated" : "activated")} successfully", Severity.Success); + await LoadUser(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed: {ex.Message}", Severity.Error); + } + finally + { + _busy = false; + } + } + + private async Task DeleteUser() + { + if (_user is null || string.IsNullOrWhiteSpace(_user.Id)) return; + + var confirmed = await DialogService.ShowDeleteConfirmAsync($"{_user.FirstName} {_user.LastName}"); + if (!confirmed) return; + + _busy = true; + try + { + await IdentityClient.UsersDeleteAsync(Id); + Snackbar.Add("User deleted", Severity.Success); + Navigation.NavigateTo("/users"); + } + catch (Exception ex) + { + Snackbar.Add($"Failed: {ex.Message}", Severity.Error); + } + finally + { + _busy = false; + } + } + + private async Task ResetPassword() + { + if (_user is null) return; + _busy = true; + try + { + await IdentityClient.ResetPasswordAsync(TenantContext, _resetModel); + Snackbar.Add("Password reset successfully", Severity.Success); + _resetModel = new ResetPasswordCommand { Email = _user.Email ?? string.Empty }; + } + catch (Exception ex) + { + Snackbar.Add($"Failed: {ex.Message}", Severity.Error); + } + finally + { + _busy = false; + } + } + + private async Task ConfirmEmail() + { + if (_user is null) return; + _busy = true; + try + { + await IdentityClient.ConfirmEmailAsync(_confirmModel.UserId, _confirmModel.Code ?? string.Empty, TenantContext); + Snackbar.Add("Email confirmed successfully", Severity.Success); + await LoadUser(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed: {ex.Message}", Severity.Error); + } + finally + { + _busy = false; + } + } + + private void GoToRoles() => Navigation.NavigateTo($"/users/{Id}/roles"); + private void GoBack() => Navigation.NavigateTo("/users"); + private void GoToGroups() => Navigation.NavigateTo("/groups"); + private void GoToGroupMembers(Guid? groupId) + { + if (groupId.HasValue) + Navigation.NavigateTo($"/groups/{groupId}/members"); + } + + private async Task LoadGroups() + { + _loadingGroups = true; + try + { + var result = await UsersClient.GroupsAsync(Id.ToString()); + _groups = result?.ToList() ?? new List(); + } + catch + { + _groups = new List(); + } + finally + { + _loadingGroups = false; + } + } + + private static string GetInitials(UserDto user) + { + var first = user.FirstName?.FirstOrDefault() ?? ' '; + var last = user.LastName?.FirstOrDefault() ?? ' '; + return $"{first}{last}".Trim().ToUpperInvariant(); + } + + private static Color GetRoleColor(string? roleName) => roleName?.ToLowerInvariant() switch + { + "admin" or "administrator" => Color.Error, + "basic" => Color.Info, + "manager" => Color.Warning, + "moderator" => Color.Secondary, + _ => Color.Primary + }; + + private static string GetRoleIcon(string? roleName) => roleName?.ToLowerInvariant() switch + { + "admin" or "administrator" => Icons.Material.Filled.AdminPanelSettings, + "basic" or "user" => Icons.Material.Filled.Person, + "manager" => Icons.Material.Filled.SupervisorAccount, + "moderator" => Icons.Material.Filled.Shield, + _ => Icons.Material.Filled.Badge + }; + + private static RenderFragment RenderRoleChips(IEnumerable roles) + { + return builder => + { + foreach (var (role, index) in roles.Select((r, i) => (r, i))) + { + var seq = index * 10; + builder.OpenComponent>(seq++); + builder.AddAttribute(seq++, "Color", GetRoleColor(role.RoleName)); + builder.AddAttribute(seq++, "Variant", Variant.Filled); + builder.AddAttribute(seq++, "Size", Size.Medium); + builder.AddAttribute(seq++, "Icon", GetRoleIcon(role.RoleName)); + builder.AddAttribute(seq, "ChildContent", (RenderFragment)(b => b.AddContent(0, role.RoleName))); + builder.CloseComponent(); + } + }; + } + + private sealed class ConfirmEmailModel + { + public string UserId { get; set; } = string.Empty; + public string? Code { get; set; } + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Users/UserDetailPage.razor.css b/src/Playground/Playground.Blazor/Components/Pages/Users/UserDetailPage.razor.css new file mode 100644 index 0000000000..d0e4d8bd18 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/Users/UserDetailPage.razor.css @@ -0,0 +1,186 @@ +/* ===== User Detail Page - Widescreen Layout ===== */ + +/* Profile Card */ +.profile-card { + border-radius: 12px; +} + +.profile-cover { + height: 100px; + background: linear-gradient(135deg, var(--mud-palette-primary) 0%, var(--mud-palette-secondary) 100%); + display: flex; + justify-content: center; + align-items: flex-end; + padding-bottom: 0; + position: relative; +} + +.profile-avatar { + width: 100px; + height: 100px; + border-radius: 50%; + background: linear-gradient(135deg, var(--mud-palette-primary), var(--mud-palette-tertiary, #7c3aed)); + display: flex; + align-items: center; + justify-content: center; + position: absolute; + bottom: -50px; + border: 4px solid var(--mud-palette-surface); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + overflow: hidden; +} + +.profile-avatar ::deep .mud-image { + width: 100%; + height: 100%; +} + +.profile-avatar ::deep .mud-image img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.profile-avatar .avatar-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.profile-avatar .avatar-initials { + font-size: 2rem; + font-weight: 700; + color: white; + letter-spacing: -1px; +} + +.status-indicator { + position: absolute; + bottom: 4px; + right: 4px; + width: 20px; + height: 20px; + border-radius: 50%; + border: 3px solid var(--mud-palette-surface); +} + +.status-indicator.active { + background: var(--mud-palette-success); +} + +.status-indicator.inactive { + background: var(--mud-palette-error); +} + +.profile-info { + padding: 60px 20px 20px; + text-align: center; +} + +.profile-details { + text-align: left; +} + +.profile-details ::deep .mud-list-item { + padding: 8px 0; +} + +/* Stats Cards */ +.stats-card { + border-radius: 12px; + transition: transform 0.2s ease, box-shadow 0.2s ease; + text-align: center; +} + +.stats-card:hover { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1); +} + +/* Action Panels (Accordion) */ +.action-panels { + border: none !important; +} + +.action-panel { + border-radius: 8px !important; + border: 1px solid var(--mud-palette-lines-default) !important; + margin-bottom: 8px !important; + overflow: hidden; +} + +.action-panel::before { + display: none !important; +} + +.action-panel ::deep .mud-expand-panel-header { + padding: 12px 16px !important; + min-height: auto !important; +} + +.action-panel ::deep .mud-expand-panel-content { + padding: 16px !important; + background: var(--mud-palette-background-gray); +} + +/* Danger Zone */ +.danger-zone { + border: 1px solid rgba(var(--mud-palette-error-rgb), 0.3) !important; + border-radius: 12px; + background: rgba(var(--mud-palette-error-rgb), 0.03) !important; +} + +.danger-item { + border-radius: 8px; + background: rgba(var(--mud-palette-error-rgb), 0.06); + border: 1px solid rgba(var(--mud-palette-error-rgb), 0.15); +} + +.danger-item:hover { + background: rgba(var(--mud-palette-error-rgb), 0.08); +} + +/* Animations */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.profile-card { + animation: fadeInUp 0.3s ease-out; +} + +.stats-card { + animation: fadeInUp 0.3s ease-out backwards; +} + +.fsh-card { + animation: fadeInUp 0.3s ease-out 0.1s backwards; +} + +/* Responsive */ +@media (max-width: 960px) { + .profile-cover { + height: 80px; + } + + .profile-avatar { + width: 80px; + height: 80px; + bottom: -40px; + } + + .profile-avatar .avatar-initials { + font-size: 1.5rem; + } + + .profile-info { + padding-top: 50px; + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Users/UserRolesPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Users/UserRolesPage.razor new file mode 100644 index 0000000000..7bfa316653 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/Users/UserRolesPage.razor @@ -0,0 +1,406 @@ +@page "/users/{Id:guid}/roles" +@using FSH.Playground.Blazor.ApiClient +@using FSH.Framework.Blazor.UI.Components.Dialogs +@inherits ComponentBase +@inject IIdentityClient IdentityClient +@inject IUsersClient UsersClient +@inject NavigationManager Navigation +@inject ISnackbar Snackbar +@inject IDialogService DialogService + + + + + Back to Users + + + + +@if (_loading) +{ + + + + Loading user information... + + +} +else if (_user is null) +{ + + + + User Not Found + The requested user could not be found. + + Back to Users + + + +} +else +{ + @* User Info Header *@ + + + + @if (!string.IsNullOrEmpty(_user.ImageUrl)) + { + + } + else + { + @GetInitials(_user) + } + + + @_user.FirstName @_user.LastName + @_user.Email + + @if (_user.IsActive) + { + Active + } + else + { + Inactive + } + @if (_user.EmailConfirmed) + { + + Verified + + } + + + + + + @* Current Roles *@ + + + + Current Roles + + @_currentRoleIds.Count assigned + + + + @if (_currentRoleIds.Any()) + { + + @foreach (var role in _allRoles.Where(r => IsRoleAssigned(r.Id))) + { + + + + @role.Name + + + } + + } + else + { + + This user has no roles assigned. Assign roles below to grant permissions. + + } + + + + @* Available Roles *@ + + + + Available Roles + + @if (HasChanges) + { + + Reset + + } + + @if (_saving) + { + + } + Save Changes + + + + + + @if (_allRoles.Any()) + { + + @foreach (var role in _allRoles) + { + var isAssigned = IsRoleAssigned(role.Id); + var hasChanged = HasRoleChanged(role.Id); + + + + + + + + + + @role.Name + @role.Description + + + + @if (hasChanged) + { + + @(isAssigned ? "Will be added" : "Will be removed") + + } + @if (isAssigned) + { + + } + + + + + } + + } + else + { + + No roles available in the system. Contact an administrator. + + } + + +} + + + +@code { + [Parameter] public Guid Id { get; set; } + + private bool _loading = true; + private bool _saving; + private UserDto? _user; + private List _allRoles = new(); + private HashSet _originalRoleIds = new(); + private HashSet _currentRoleIds = new(); + + private bool HasChanges => !_originalRoleIds.SetEquals(_currentRoleIds); + + protected override async Task OnInitializedAsync() + { + await LoadData(); + } + + private async Task LoadData() + { + _loading = true; + try + { + // Load user details + _user = await IdentityClient.UsersGetAsync(Id); + + // Load user's current roles + var userRoles = await UsersClient.RolesGetAsync(Id); + var enabledRoleIds = userRoles?.Where(r => r.Enabled).Select(r => r.RoleId ?? "").ToHashSet() ?? new HashSet(); + + // Store original state and current state + _originalRoleIds = new HashSet(enabledRoleIds); + _currentRoleIds = new HashSet(enabledRoleIds); + + // Load all available roles + var allRoles = await IdentityClient.RolesGetAsync(); + _allRoles = allRoles?.ToList() ?? new List(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to load data: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + } + } + + private bool IsRoleAssigned(string? roleId) => !string.IsNullOrEmpty(roleId) && _currentRoleIds.Contains(roleId); + + private bool IsRoleOriginallyAssigned(string? roleId) => !string.IsNullOrEmpty(roleId) && _originalRoleIds.Contains(roleId); + + private bool HasRoleChanged(string? roleId) => IsRoleAssigned(roleId) != IsRoleOriginallyAssigned(roleId); + + private void ToggleRole(RoleDto role, bool enabled) + { + if (string.IsNullOrEmpty(role.Id)) return; + + if (enabled) + { + _currentRoleIds.Add(role.Id); + } + else + { + _currentRoleIds.Remove(role.Id); + } + StateHasChanged(); + } + + private async Task RemoveRole(string? roleId, string? roleName) + { + if (string.IsNullOrEmpty(roleId)) return; + + var confirmed = await DialogService.ShowConfirmAsync( + "Remove Role", + $"Remove the '{roleName}' role from this user?", + "Remove", + "Cancel", + Color.Warning); + + if (!confirmed) return; + + _currentRoleIds.Remove(roleId); + StateHasChanged(); + } + + private void ResetChanges() + { + _currentRoleIds = new HashSet(_originalRoleIds); + StateHasChanged(); + } + + private async Task SaveRoles() + { + _saving = true; + try + { + // Build user roles list from current selection + var userRoles = _allRoles.Select(role => new UserRoleDto + { + RoleId = role.Id, + RoleName = role.Name, + Description = role.Description, + Enabled = _currentRoleIds.Contains(role.Id ?? "") + }).ToList(); + + var command = new AssignUserRolesCommand + { + UserId = Id.ToString(), + UserRoles = userRoles + }; + + await UsersClient.RolesPostAsync(Id, command); + + // Update original state to match current + _originalRoleIds = new HashSet(_currentRoleIds); + + Snackbar.Add("Roles updated successfully.", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to save roles: {ex.Message}", Severity.Error); + } + finally + { + _saving = false; + } + } + + private void GoBack() + { + Navigation.NavigateTo("/users"); + } + + private static string GetInitials(UserDto user) + { + var first = user.FirstName?.FirstOrDefault() ?? ' '; + var last = user.LastName?.FirstOrDefault() ?? ' '; + return $"{first}{last}".Trim().ToUpperInvariant(); + } + + private static Color GetRoleColor(string? roleName) => roleName?.ToLowerInvariant() switch + { + "admin" => Color.Error, + "administrator" => Color.Error, + "basic" => Color.Info, + "user" => Color.Default, + "manager" => Color.Warning, + "moderator" => Color.Secondary, + _ => Color.Primary + }; + + private static string GetRoleIcon(string? roleName) => roleName?.ToLowerInvariant() switch + { + "admin" => Icons.Material.Filled.AdminPanelSettings, + "administrator" => Icons.Material.Filled.AdminPanelSettings, + "basic" => Icons.Material.Filled.Person, + "user" => Icons.Material.Filled.Person, + "manager" => Icons.Material.Filled.SupervisorAccount, + "moderator" => Icons.Material.Filled.Shield, + _ => Icons.Material.Filled.Badge + }; + + private static string GetRoleAvatarStyle(string? roleName) + { + var color = roleName?.ToLowerInvariant() switch + { + "admin" => "var(--mud-palette-error)", + "administrator" => "var(--mud-palette-error)", + "basic" => "var(--mud-palette-info)", + "user" => "var(--mud-palette-dark)", + "manager" => "var(--mud-palette-warning)", + "moderator" => "var(--mud-palette-secondary)", + _ => "var(--mud-palette-primary)" + }; + return $"background-color: {color}; color: white;"; + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Users/UsersPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Users/UsersPage.razor new file mode 100644 index 0000000000..29d6ddbf44 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/Users/UsersPage.razor @@ -0,0 +1,781 @@ +@page "/users" +@using FSH.Playground.Blazor.ApiClient +@using FSH.Framework.Blazor.UI.Components.Dialogs +@inherits ComponentBase +@using System.Security.Claims +@using FSH.Framework.Shared.Constants +@using Microsoft.AspNetCore.Components.Authorization +@inject IIdentityClient IdentityClient +@inject IUsersClient UsersClient +@inject NavigationManager Navigation +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject AuthenticationStateProvider AuthenticationStateProvider + + + + @if (_selectedUsers.Any()) + { + + + Activate (@_selectedUsers.Count) + + + Deactivate + + + Delete + + + Clear + + + } + + New User + + + + +@* Stats Cards *@ + + + + + + + + Total + + @_stats.Total + Total Users + + + + + + + + + + + Active + + @_stats.Active + Active Users + + + + + + + + + + + Inactive + + @_stats.Inactive + Inactive Users + + + + + + + + + + + Unconfirmed + + @_stats.UnconfirmedEmail + Pending Verification + + + + + + +@* Filter Panel *@ + + + + + + + + + All + Active + Inactive + + + + + All + Confirmed + Unconfirmed + + + + + Clear Filters + + + Refresh + + + + + + +@* Data Grid *@ + + + + + + + + + @if (!string.IsNullOrEmpty(context.Item.ImageUrl)) + { + + } + else + { + @GetInitials(context.Item) + } + + + + @($"{context.Item.FirstName} {context.Item.LastName}".Trim()) + + @@@context.Item.UserName + + + + + + + + @context.Item.Email + @if (context.Item.EmailConfirmed) + { + + + + } + else + { + + + + } + + + + + + + @if (_userRolesCache.TryGetValue(context.Item.Id ?? "", out var roles) && roles.Any()) + { + + @RenderRoleChips(roles.Where(r => r.Enabled).Take(2)) + @if (roles.Count(r => r.Enabled) > 2) + { + + +@(roles.Count(r => r.Enabled) - 2) + + } + + } + else + { + + No roles + + } + + + + + @if (context.Item.IsActive) + { + Active + } + else + { + Inactive + } + + + + + + + + + + + + + + + + + + + + + + + + + + + + No users found + Try adjusting your filters or create a new user. + + + + + + + +@code { + private MudDataGrid? _dataGrid; + private List _users = new(); + private List _filtered = new(); + private HashSet _selectedUsers = new(); + private bool _loading = true; + private string? _busyUserId; + private bool _bulkBusy; + private string? _currentUserId; + private readonly Dictionary _adminCache = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _userRolesCache = new(StringComparer.OrdinalIgnoreCase); + + private readonly int _pageSize = 10; + private readonly int[] _pageSizeOptions = { 5, 10, 25, 50 }; + + private UserStats _stats = new(); + private FilterModel _filter = new(); + + private class UserStats + { + public int Total { get; set; } + public int Active { get; set; } + public int Inactive { get; set; } + public int UnconfirmedEmail { get; set; } + } + + private class FilterModel + { + public string? Search { get; set; } + public bool? IsActive { get; set; } + public bool? EmailConfirmed { get; set; } + } + + protected override async Task OnInitializedAsync() + { + await ResolveCurrentUser(); + await LoadData(); + } + + private async Task LoadData() + { + _loading = true; + try + { + var result = await IdentityClient.UsersGetAsync(); + _users = result?.ToList() ?? new List(); + CalculateStats(); + ApplyFilters(); + await LoadRolesForUsers(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to load users: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + } + } + + private void CalculateStats() + { + _stats = new UserStats + { + Total = _users.Count, + Active = _users.Count(u => u.IsActive), + Inactive = _users.Count(u => !u.IsActive), + UnconfirmedEmail = _users.Count(u => !u.EmailConfirmed) + }; + } + + private void ApplyFilters() + { + var query = _users.AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(_filter.Search)) + { + var term = _filter.Search.ToLowerInvariant(); + query = query.Where(u => + (u.FirstName?.ToLowerInvariant().Contains(term) ?? false) || + (u.LastName?.ToLowerInvariant().Contains(term) ?? false) || + (u.Email?.ToLowerInvariant().Contains(term) ?? false) || + (u.UserName?.ToLowerInvariant().Contains(term) ?? false)); + } + + if (_filter.IsActive.HasValue) + { + query = query.Where(u => u.IsActive == _filter.IsActive.Value); + } + + if (_filter.EmailConfirmed.HasValue) + { + query = query.Where(u => u.EmailConfirmed == _filter.EmailConfirmed.Value); + } + + _filtered = query.OrderBy(u => u.FirstName).ThenBy(u => u.LastName).ToList(); + } + + private async Task LoadRolesForUsers() + { + var tasks = _filtered.Take(20).Select(async user => + { + if (string.IsNullOrEmpty(user.Id) || _userRolesCache.ContainsKey(user.Id)) return; + try + { + var roles = await UsersClient.RolesGetAsync(Guid.Parse(user.Id)); + _userRolesCache[user.Id] = roles?.ToList() ?? new List(); + } + catch + { + _userRolesCache[user.Id] = new List(); + } + }); + + await Task.WhenAll(tasks); + StateHasChanged(); + } + + private async Task RefreshData() + { + ApplyFilters(); + StateHasChanged(); + await LoadRolesForUsers(); + } + + private async Task OnStatusFilterChanged(bool? value) + { + _filter.IsActive = value; + await RefreshData(); + } + + private async Task OnEmailFilterChanged(bool? value) + { + _filter.EmailConfirmed = value; + await RefreshData(); + } + + private void ClearFilters() + { + _filter = new FilterModel(); + ApplyFilters(); + } + + private async Task ResolveCurrentUser() + { + try + { + var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); + _currentUserId = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + } + catch + { + // Authentication state unavailable - user remains null (not logged in) + } + } + + private static string GetInitials(UserDto user) + { + var first = user.FirstName?.FirstOrDefault() ?? ' '; + var last = user.LastName?.FirstOrDefault() ?? ' '; + return $"{first}{last}".Trim().ToUpperInvariant(); + } + + private static Color GetRoleColor(string? roleName) => roleName?.ToLowerInvariant() switch + { + "admin" => Color.Error, + "administrator" => Color.Error, + "basic" => Color.Info, + "user" => Color.Default, + _ => Color.Default + }; + + private static RenderFragment RenderRoleChips(IEnumerable roles) + { + return builder => + { + foreach (var (role, index) in roles.Select((r, i) => (r, i))) + { + var seq = index * 10; + builder.OpenComponent>(seq++); + builder.AddAttribute(seq++, "Size", Size.Small); + builder.AddAttribute(seq++, "Variant", Variant.Outlined); + builder.AddAttribute(seq++, "Color", GetRoleColor(role.RoleName)); + builder.AddAttribute(seq, "ChildContent", (RenderFragment)(b => b.AddContent(0, role.RoleName))); + builder.CloseComponent(); + } + }; + } + + private bool IsCurrentUser(UserDto user) => + !string.IsNullOrWhiteSpace(_currentUserId) && + string.Equals(_currentUserId, user.Id, StringComparison.OrdinalIgnoreCase); + + private bool IsToggleDisabled(UserDto user) => + string.IsNullOrWhiteSpace(user.Id) || _busyUserId == user.Id || IsCurrentUser(user); + + private string GetToggleTooltip(UserDto user) + { + if (IsCurrentUser(user)) return "Cannot modify your own account"; + return user.IsActive ? "Deactivate user" : "Activate user"; + } + + private async Task IsUserAdminAsync(string userId) + { + if (_adminCache.TryGetValue(userId, out var cached)) return cached; + + try + { + var roles = await UsersClient.RolesGetAsync(Guid.Parse(userId)); + var isAdmin = roles?.Any(r => string.Equals(r.RoleName, RoleConstants.Admin, StringComparison.OrdinalIgnoreCase) && r.Enabled) ?? false; + _adminCache[userId] = isAdmin; + return isAdmin; + } + catch + { + return false; + } + } + + private void GoToDetail(string? id) + { + if (Guid.TryParse(id, out var guid)) + Navigation.NavigateTo($"/users/{guid}"); + } + + private void GoToRoles(string? id) + { + if (Guid.TryParse(id, out var guid)) + Navigation.NavigateTo($"/users/{guid}/roles"); + } + + private void OnSelectionChanged(HashSet items) + { + _selectedUsers = items; + } + + private void ClearSelection() + { + _selectedUsers.Clear(); + StateHasChanged(); + } + + private async Task ToggleStatus(UserDto user) + { + if (string.IsNullOrWhiteSpace(user.Id)) return; + + if (IsCurrentUser(user)) + { + Snackbar.Add("You cannot modify your own account.", Severity.Warning); + return; + } + + var isAdmin = await IsUserAdminAsync(user.Id); + if (isAdmin && user.IsActive) + { + Snackbar.Add("Administrators cannot be deactivated.", Severity.Warning); + return; + } + + var action = user.IsActive ? "deactivate" : "activate"; + var confirmed = await DialogService.ShowConfirmAsync( + $"{(user.IsActive ? "Deactivate" : "Activate")} User", + $"Are you sure you want to {action} {user.FirstName} {user.LastName}?", + user.IsActive ? "Deactivate" : "Activate", + "Cancel", + user.IsActive ? Color.Warning : Color.Success); + + if (!confirmed) return; + + _busyUserId = user.Id; + try + { + var command = new ToggleUserStatusCommand { ActivateUser = !user.IsActive, UserId = user.Id }; + await IdentityClient.UsersPatchAsync(Guid.Parse(user.Id), command); + Snackbar.Add($"User {(user.IsActive ? "deactivated" : "activated")} successfully.", Severity.Success); + await LoadData(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to update user: {ex.Message}", Severity.Error); + } + finally + { + _busyUserId = null; + } + } + + private async Task DeleteUser(UserDto user) + { + if (string.IsNullOrWhiteSpace(user.Id)) return; + + var confirmed = await DialogService.ShowDeleteConfirmAsync($"{user.FirstName} {user.LastName}"); + if (!confirmed) return; + + _busyUserId = user.Id; + try + { + await IdentityClient.UsersDeleteAsync(Guid.Parse(user.Id)); + Snackbar.Add("User deleted successfully.", Severity.Success); + await LoadData(); + } + catch (Exception ex) + { + Snackbar.Add($"Failed to delete user: {ex.Message}", Severity.Error); + } + finally + { + _busyUserId = null; + } + } + + private async Task BulkActivate() + { + var users = _selectedUsers.Where(u => !u.IsActive && !IsCurrentUser(u)).ToList(); + if (!users.Any()) + { + Snackbar.Add("No inactive users selected.", Severity.Info); + return; + } + + var confirmed = await DialogService.ShowConfirmAsync( + "Bulk Activate", + $"Activate {users.Count} user(s)?", + "Activate All", + "Cancel", + Color.Success); + + if (!confirmed) return; + + _bulkBusy = true; + var results = await Task.WhenAll(users.Select(async user => + { + try + { + var command = new ToggleUserStatusCommand { ActivateUser = true, UserId = user.Id }; + await IdentityClient.UsersPatchAsync(Guid.Parse(user.Id!), command); + return true; + } + catch + { + // Continue with remaining users - failures are reflected in the success count + return false; + } + })); + var success = results.Count(r => r); + _bulkBusy = false; + Snackbar.Add($"Activated {success} of {users.Count} users.", Severity.Success); + ClearSelection(); + await LoadData(); + } + + private async Task BulkDeactivate() + { + var users = _selectedUsers.Where(u => u.IsActive && !IsCurrentUser(u)).ToList(); + if (!users.Any()) + { + Snackbar.Add("No active users selected (excluding yourself).", Severity.Info); + return; + } + + var confirmed = await DialogService.ShowConfirmAsync( + "Bulk Deactivate", + $"Deactivate {users.Count} user(s)?", + "Deactivate All", + "Cancel", + Color.Warning); + + if (!confirmed) return; + + _bulkBusy = true; + var results = await Task.WhenAll(users.Select(async user => + { + if (await IsUserAdminAsync(user.Id!)) return false; + try + { + var command = new ToggleUserStatusCommand { ActivateUser = false, UserId = user.Id }; + await IdentityClient.UsersPatchAsync(Guid.Parse(user.Id!), command); + return true; + } + catch + { + // Continue with remaining users - failures are reflected in the success count + return false; + } + })); + var success = results.Count(r => r); + _bulkBusy = false; + Snackbar.Add($"Deactivated {success} of {users.Count} users.", Severity.Success); + ClearSelection(); + await LoadData(); + } + + private async Task BulkDelete() + { + var users = _selectedUsers.Where(u => !IsCurrentUser(u)).ToList(); + if (!users.Any()) + { + Snackbar.Add("No users selected (excluding yourself).", Severity.Info); + return; + } + + var confirmed = await DialogService.ShowConfirmAsync( + "Bulk Delete", + $"Delete {users.Count} user(s)? This action cannot be undone.", + "Delete All", + "Cancel", + Color.Error, + Icons.Material.Outlined.DeleteForever, + Color.Error); + + if (!confirmed) return; + + _bulkBusy = true; + var success = 0; + foreach (var user in users) + { + try + { + await IdentityClient.UsersDeleteAsync(Guid.Parse(user.Id!)); + success++; + } + catch + { + // Continue with remaining users - failures are reflected in the success count + } + } + _bulkBusy = false; + Snackbar.Add($"Deleted {success} of {users.Count} users.", Severity.Success); + ClearSelection(); + await LoadData(); + } + + private async Task ShowCreate() + { + var options = new DialogOptions + { + MaxWidth = MaxWidth.Medium, + FullWidth = true, + CloseOnEscapeKey = true, + CloseButton = true + }; + + var dialog = await DialogService.ShowAsync("Create User", options); + var result = await dialog.Result; + + if (result is not null && !result.Canceled) + { + await LoadData(); + } + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Weather.razor b/src/Playground/Playground.Blazor/Components/Pages/Weather.razor new file mode 100644 index 0000000000..22ebe557a2 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/Weather.razor @@ -0,0 +1,56 @@ +@page "/weather" + +Weather + + + +@if (forecasts == null) +{ + +} +else +{ + + + Date + Temp. (C) + Temp. (F) + Summary + + + @context.Date + @context.TemperatureC + @context.TemperatureF + @context.Summary + + + + + +} + +@code { + private WeatherForecast[]? forecasts; + + protected override async Task OnInitializedAsync() + { + var startDate = DateOnly.FromDateTime(DateTime.Now); + var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; + forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast + { + Date = startDate.AddDays(index), + TemperatureC = Random.Shared.Next(-20, 55), + Summary = summaries[Random.Shared.Next(summaries.Length)] + }).ToArray(); + } + + private class WeatherForecast + { + public DateOnly Date { get; set; } + public int TemperatureC { get; set; } + public string? Summary { get; set; } + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + } +} diff --git a/src/Playground/Playground.Blazor/Components/Routes.razor b/src/Playground/Playground.Blazor/Components/Routes.razor new file mode 100644 index 0000000000..83f022b21b --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Routes.razor @@ -0,0 +1,13 @@ +@using FSH.Playground.Blazor.Components.Layout + + + + + + + + +

Sorry, there's nothing at this address.

+
+
+
diff --git a/src/Playground/Playground.Blazor/Components/_Imports.razor b/src/Playground/Playground.Blazor/Components/_Imports.razor new file mode 100644 index 0000000000..99c5025e85 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/_Imports.razor @@ -0,0 +1,14 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using FSH.Playground.Blazor.Components.Layout +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using MudBlazor +@using MudBlazor.Services +@using FSH.Framework.Blazor.UI.Components.Page +@using FSH.Framework.Blazor.UI.Components.User diff --git a/src/Playground/Playground.Blazor/Playground.Blazor.csproj b/src/Playground/Playground.Blazor/Playground.Blazor.csproj new file mode 100644 index 0000000000..4070df65f2 --- /dev/null +++ b/src/Playground/Playground.Blazor/Playground.Blazor.csproj @@ -0,0 +1,40 @@ + + + + FSH.Playground.Blazor + FSH.Playground.Blazor + false + + + true + true + true + true + + + 8080 + root + fsh-playground-blazor + DefaultContainer + + + + + + + + + + + + + + + + + + + + $(NoWarn);MUD0002;S3260;S2933;S3459;S3923;S108;S1144;S4487;CA1031;CA5394;CA1812 + + diff --git a/src/Playground/Playground.Blazor/Program.cs b/src/Playground/Playground.Blazor/Program.cs new file mode 100644 index 0000000000..593395a4c0 --- /dev/null +++ b/src/Playground/Playground.Blazor/Program.cs @@ -0,0 +1,138 @@ +using FSH.Framework.Blazor.UI; +using FSH.Framework.Blazor.UI.Theme; +using FSH.Playground.Blazor; +using FSH.Playground.Blazor.Components; +using FSH.Playground.Blazor.Services; +using FSH.Playground.Blazor.Services.Api; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.Server.Circuits; + +var builder = WebApplication.CreateBuilder(args); + +// Configure HTTP/3 support (only override in production, respect launchSettings in dev) +if (!builder.Environment.IsDevelopment()) +{ + builder.WebHost.ConfigureKestrel(options => + { + options.ListenAnyIP(8080, listenOptions => + { + listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http1AndHttp2AndHttp3; + }); + }); +} + +builder.Services.AddHeroUI(); + +// Authentication & Authorization +builder.Services.AddCascadingAuthenticationState(); +builder.Services.AddHttpContextAccessor(); + +// Cookie Authentication for SSR support +builder.Services.AddAuthentication("Cookies") + .AddCookie("Cookies", options => + { + options.LoginPath = "/login"; + options.LogoutPath = "/auth/logout"; + options.ExpireTimeSpan = TimeSpan.FromDays(7); + options.SlidingExpiration = true; + }); + +builder.Services.AddAuthorization(); + +// Distributed Cache (required by theme state factory) +builder.Services.AddDistributedMemoryCache(); + +// Simple cookie-based authentication +builder.Services.AddScoped(); + +// Tenant theme services +builder.Services.AddScoped(); // For Interactive mode +builder.Services.AddScoped(); // For SSR mode + +// User profile state for syncing across components +builder.Services.AddScoped(); + +// Authorization header handler for API calls +builder.Services.AddScoped(); + +// Token refresh service for handling expired access tokens +builder.Services.AddScoped(); + +builder.Services.AddHttpClient(); + +var apiBaseUrl = builder.Configuration["Api:BaseUrl"] + ?? throw new InvalidOperationException("Api:BaseUrl configuration is missing."); + +// Configure HttpClient with authorization handler for API calls +builder.Services.AddScoped(sp => +{ + var handler = sp.GetRequiredService(); + handler.InnerHandler = new HttpClientHandler(); + + return new HttpClient(handler) + { + BaseAddress = new Uri(apiBaseUrl) + }; +}); + +builder.Services.AddApiClients(builder.Configuration); + +// Response Compression for static assets and API responses +builder.Services.AddResponseCompression(options => +{ + options.EnableForHttps = true; + options.Providers.Add(); + options.Providers.Add(); +}); + +builder.Services.Configure(options => +{ + options.Level = System.IO.Compression.CompressionLevel.Fastest; +}); + +builder.Services.Configure(options => +{ + options.Level = System.IO.Compression.CompressionLevel.Fastest; +}); + +// Output Caching for static responses +builder.Services.AddOutputCache(options => +{ + options.AddBasePolicy(builder => builder +#pragma warning disable CA1307 // PathString.StartsWithSegments is case-insensitive by design + .With(c => c.HttpContext.Request.Path.StartsWithSegments("/health")) +#pragma warning restore CA1307 + .Expire(TimeSpan.FromSeconds(10))); +}); + +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +var app = builder.Build(); + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); +} + +// Simple health endpoints for ALB/ECS +app.MapGet("/health/ready", () => Results.Ok(new { status = "Healthy" })) + .AllowAnonymous(); + +app.MapGet("/health/live", () => Results.Ok(new { status = "Alive" })) + .AllowAnonymous(); + +app.UseResponseCompression(); // Must come before UseStaticFiles +app.UseOutputCache(); +app.UseHttpsRedirection(); +app.UseAuthentication(); // Must come before UseAuthorization +app.UseAuthorization(); +app.UseAntiforgery(); + +app.MapSimpleBffAuthEndpoints(); +app.MapStaticAssets(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +await app.RunAsync(); diff --git a/src/Playground/Playground.Blazor/Properties/launchSettings.json b/src/Playground/Playground.Blazor/Properties/launchSettings.json new file mode 100644 index 0000000000..55e496a95d --- /dev/null +++ b/src/Playground/Playground.Blazor/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5032", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7140;http://localhost:5032", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/src/Playground/Playground.Blazor/Services/Api/ApiClientRegistration.cs b/src/Playground/Playground.Blazor/Services/Api/ApiClientRegistration.cs new file mode 100644 index 0000000000..05cfd2ea42 --- /dev/null +++ b/src/Playground/Playground.Blazor/Services/Api/ApiClientRegistration.cs @@ -0,0 +1,58 @@ +using System.Net.Http; +using FSH.Playground.Blazor.ApiClient; +using FSH.Playground.Blazor.Services.Api; + +namespace FSH.Playground.Blazor; + +internal static class ApiClientRegistration +{ + public static IServiceCollection AddApiClients(this IServiceCollection services, IConfiguration configuration) + { + var apiBaseUrl = configuration["Api:BaseUrl"] + ?? throw new InvalidOperationException("Api:BaseUrl configuration is missing."); + + static HttpClient ResolveClient(IServiceProvider sp) => + sp.GetRequiredService(); + + // Register a named HttpClient for token operations (no auth handler to avoid circular dependency) + services.AddHttpClient("TokenClient", client => + { + client.BaseAddress = new Uri(apiBaseUrl); + }); + + // TokenClient uses the named HttpClient without the AuthorizationHeaderHandler + // This avoids circular dependency: TokenRefreshService -> ITokenClient -> HttpClient -> AuthorizationHeaderHandler -> TokenRefreshService + services.AddTransient(sp => + { + var factory = sp.GetRequiredService(); + var client = factory.CreateClient("TokenClient"); + return new TokenClient(client); + }); + + services.AddTransient(sp => + new IdentityClient(ResolveClient(sp))); + + services.AddTransient(sp => + new AuditsClient(ResolveClient(sp))); + + services.AddTransient(sp => + new TenantsClient(ResolveClient(sp))); + + services.AddTransient(sp => + new UsersClient(ResolveClient(sp))); + + services.AddTransient(sp => + new GroupsClient(ResolveClient(sp))); + + services.AddTransient(sp => + new SessionsClient(ResolveClient(sp))); + + services.AddTransient(sp => + new V1Client(ResolveClient(sp))); + + services.AddTransient(sp => + new HealthClient(ResolveClient(sp))); + + return services; + } +} diff --git a/src/Playground/Playground.Blazor/Services/Api/ApiClients.cs b/src/Playground/Playground.Blazor/Services/Api/ApiClients.cs new file mode 100644 index 0000000000..3990c360ff --- /dev/null +++ b/src/Playground/Playground.Blazor/Services/Api/ApiClients.cs @@ -0,0 +1,6 @@ +namespace FSH.Playground.Blazor.Services.Api; + +internal static class ApiClients +{ + public const string FSH = "fshapi"; +} diff --git a/src/Playground/Playground.Blazor/Services/Api/AuthorizationHeaderHandler.cs b/src/Playground/Playground.Blazor/Services/Api/AuthorizationHeaderHandler.cs new file mode 100644 index 0000000000..45c3fd6781 --- /dev/null +++ b/src/Playground/Playground.Blazor/Services/Api/AuthorizationHeaderHandler.cs @@ -0,0 +1,169 @@ +using Microsoft.AspNetCore.Authentication; +using System.Net; + +namespace FSH.Playground.Blazor.Services.Api; + +/// +/// Delegating handler that adds the JWT token to API requests and handles 401 responses +/// by attempting to refresh the access token. If refresh fails, signs out the user. +/// +internal sealed class AuthorizationHeaderHandler : DelegatingHandler +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public AuthorizationHeaderHandler( + IHttpContextAccessor httpContextAccessor, + IServiceProvider serviceProvider, + ILogger logger) + { + _httpContextAccessor = httpContextAccessor; + _serviceProvider = serviceProvider; + _logger = logger; + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + // Attach current access token + var accessToken = await GetAccessTokenAsync(); + if (!string.IsNullOrEmpty(accessToken)) + { + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); + } + + // Send the request + var response = await base.SendAsync(request, cancellationToken); + + // If we get a 401, try to refresh the token and retry once + if (response.StatusCode == HttpStatusCode.Unauthorized && !string.IsNullOrEmpty(accessToken)) + { + _logger.LogInformation("Received 401 response, attempting token refresh"); + + var newAccessToken = await TryRefreshTokenAsync(cancellationToken); + + if (!string.IsNullOrEmpty(newAccessToken)) + { + _logger.LogInformation("Token refresh successful, retrying request"); + + // Clone the request with new token + using var retryRequest = await CloneHttpRequestMessageAsync(request); + retryRequest.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", newAccessToken); + + // Dispose the original response before retrying + response.Dispose(); + + // Retry the request with the new token + response = await base.SendAsync(retryRequest, cancellationToken); + } + else + { + _logger.LogWarning("Token refresh failed, signing out user and returning 401 response"); + + // Sign out the user since refresh token is also invalid/expired + await SignOutUserAsync(); + } + } + + return response; + } + + private async Task SignOutUserAsync() + { + try + { + var httpContext = _httpContextAccessor.HttpContext; + if (httpContext is not null) + { + await httpContext.SignOutAsync("Cookies"); + _logger.LogInformation("User signed out due to expired refresh token"); + + // Redirect to login page with session expired message + httpContext.Response.Redirect("/login?toast=session_expired"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to sign out user after token refresh failure"); + } + } + + private async Task GetAccessTokenAsync() + { + try + { + var httpContext = _httpContextAccessor.HttpContext; + var user = httpContext?.User; + + if (user?.Identity?.IsAuthenticated == true) + { + return user.FindFirst("access_token")?.Value; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get access token from claims"); + } + + return null; + } + + private async Task TryRefreshTokenAsync(CancellationToken cancellationToken) + { + try + { + // Resolve the token refresh service from the service provider + // We use IServiceProvider to avoid circular dependency issues + var tokenRefreshService = _serviceProvider.GetService(); + if (tokenRefreshService is null) + { + _logger.LogWarning("TokenRefreshService is not registered"); + return null; + } + + return await tokenRefreshService.TryRefreshTokenAsync(cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during token refresh"); + return null; + } + } + + private static async Task CloneHttpRequestMessageAsync(HttpRequestMessage request) + { + var clone = new HttpRequestMessage(request.Method, request.RequestUri) + { + Version = request.Version + }; + + // Copy headers (except Authorization which we'll set separately) + foreach (var header in request.Headers.Where(h => !string.Equals(h.Key, "Authorization", StringComparison.OrdinalIgnoreCase))) + { + clone.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + // Copy content if present + if (request.Content != null) + { + var contentBytes = await request.Content.ReadAsByteArrayAsync(); + clone.Content = new ByteArrayContent(contentBytes); + + // Copy content headers + foreach (var header in request.Content.Headers) + { + clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + // Copy options + foreach (var option in request.Options) + { + clone.Options.TryAdd(option.Key, option.Value); + } + + return clone; + } +} diff --git a/src/Playground/Playground.Blazor/Services/Api/TokenRefreshService.cs b/src/Playground/Playground.Blazor/Services/Api/TokenRefreshService.cs new file mode 100644 index 0000000000..4b6670a351 --- /dev/null +++ b/src/Playground/Playground.Blazor/Services/Api/TokenRefreshService.cs @@ -0,0 +1,161 @@ +using FSH.Playground.Blazor.ApiClient; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Components.Authorization; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; + +namespace FSH.Playground.Blazor.Services.Api; + +/// +/// Service responsible for refreshing expired access tokens using the refresh token. +/// This service handles the token refresh flow when a 401 response is received. +/// +internal interface ITokenRefreshService +{ + /// + /// Attempts to refresh the access token using the stored refresh token. + /// + /// The new access token if refresh succeeded, null otherwise. + Task TryRefreshTokenAsync(CancellationToken cancellationToken = default); +} + +internal sealed class TokenRefreshService : ITokenRefreshService, IDisposable +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ITokenClient _tokenClient; + private readonly ILogger _logger; + private readonly SemaphoreSlim _refreshLock = new(1, 1); + + public TokenRefreshService( + IHttpContextAccessor httpContextAccessor, + ITokenClient tokenClient, + ILogger logger) + { + _httpContextAccessor = httpContextAccessor; + _tokenClient = tokenClient; + _logger = logger; + } + + public async Task TryRefreshTokenAsync(CancellationToken cancellationToken = default) + { + var httpContext = _httpContextAccessor.HttpContext; + if (httpContext is null) + { + _logger.LogWarning("HttpContext is not available for token refresh"); + return null; + } + + // Prevent concurrent refresh attempts + if (!await _refreshLock.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken)) + { + _logger.LogWarning("Token refresh lock acquisition timed out"); + return null; + } + + try + { + var user = httpContext.User; + if (user?.Identity?.IsAuthenticated != true) + { + _logger.LogDebug("User is not authenticated, cannot refresh token"); + return null; + } + + var currentAccessToken = user.FindFirst("access_token")?.Value; + var refreshToken = user.FindFirst("refresh_token")?.Value; + var tenant = user.FindFirst("tenant")?.Value ?? "root"; + + if (string.IsNullOrEmpty(refreshToken)) + { + _logger.LogWarning("No refresh token available"); + return null; + } + + if (string.IsNullOrEmpty(currentAccessToken)) + { + _logger.LogWarning("No access token available for refresh"); + return null; + } + + _logger.LogInformation( + "Attempting to refresh access token for tenant {Tenant}. RefreshToken length: {RefreshTokenLength}, First chars: {RefreshTokenPreview}", + tenant, + refreshToken.Length, + refreshToken[..Math.Min(8, refreshToken.Length)] + "..."); + + // Call the refresh token API + var refreshResponse = await _tokenClient.RefreshAsync( + tenant, + new RefreshTokenCommand + { + Token = currentAccessToken, + RefreshToken = refreshToken + }, + cancellationToken); + + if (refreshResponse is null || string.IsNullOrEmpty(refreshResponse.Token)) + { + _logger.LogWarning("Token refresh returned empty response"); + return null; + } + + // Parse the new JWT to extract claims + var jwtHandler = new JwtSecurityTokenHandler(); + var jwtToken = jwtHandler.ReadJwtToken(refreshResponse.Token); + + // Build new claims list with updated tokens + var newClaims = new List + { + new(ClaimTypes.NameIdentifier, jwtToken.Subject ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? Guid.NewGuid().ToString()), + new(ClaimTypes.Email, user.FindFirst(ClaimTypes.Email)?.Value ?? string.Empty), + new("access_token", refreshResponse.Token), + new("refresh_token", refreshResponse.RefreshToken), + new("tenant", tenant), + }; + + // Preserve name claim + var nameClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == "name" || c.Type == ClaimTypes.Name); + if (nameClaim != null) + { + newClaims.Add(new Claim(ClaimTypes.Name, nameClaim.Value)); + } + + // Preserve role claims + var roleClaims = jwtToken.Claims.Where(c => c.Type == "role" || c.Type == ClaimTypes.Role); + newClaims.AddRange(roleClaims.Select(r => new Claim(ClaimTypes.Role, r.Value))); + + // Re-sign in with updated claims + var identity = new ClaimsIdentity(newClaims, "Cookies"); + var principal = new ClaimsPrincipal(identity); + + await httpContext.SignInAsync("Cookies", principal, new AuthenticationProperties + { + IsPersistent = true, + ExpiresUtc = DateTimeOffset.UtcNow.AddDays(7) + }); + + _logger.LogInformation("Access token refreshed successfully"); + + return refreshResponse.Token; + } + catch (ApiException ex) when (ex.StatusCode == 401) + { + _logger.LogWarning(ex, "Refresh token is invalid or expired, user needs to re-authenticate"); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to refresh access token"); + return null; + } + finally + { + _refreshLock.Release(); + } + } + + public void Dispose() + { + _refreshLock.Dispose(); + } +} diff --git a/src/Playground/Playground.Blazor/Services/CookieAuthenticationStateProvider.cs b/src/Playground/Playground.Blazor/Services/CookieAuthenticationStateProvider.cs new file mode 100644 index 0000000000..239b06191e --- /dev/null +++ b/src/Playground/Playground.Blazor/Services/CookieAuthenticationStateProvider.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.Server; + +namespace FSH.Playground.Blazor.Services; + +/// +/// Simple authentication state provider that reads from cookie authentication. +/// Uses the built-in ServerAuthenticationStateProvider which automatically reads HttpContext.User. +/// +/// +/// This class intentionally has no custom logic - it inherits all behavior from ServerAuthenticationStateProvider, +/// which automatically reads from HttpContext.User populated by the ASP.NET Core Cookie Authentication middleware. +/// The class exists to provide a named type for DI registration and potential future customization. +/// +#pragma warning disable S2094 // Classes should not be empty - intentionally inherits all behavior from base class +internal sealed class CookieAuthenticationStateProvider : ServerAuthenticationStateProvider; +#pragma warning restore S2094 diff --git a/src/Playground/Playground.Blazor/Services/SimpleBffAuth.cs b/src/Playground/Playground.Blazor/Services/SimpleBffAuth.cs new file mode 100644 index 0000000000..bcee223ee9 --- /dev/null +++ b/src/Playground/Playground.Blazor/Services/SimpleBffAuth.cs @@ -0,0 +1,113 @@ +using FSH.Playground.Blazor.ApiClient; +using Microsoft.AspNetCore.Authentication; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; + +namespace FSH.Playground.Blazor.Services; + +#pragma warning disable CA1515 // Extension method classes must be public +internal static class SimpleBffAuth +#pragma warning restore CA1515 +{ + public static void MapSimpleBffAuthEndpoints(this WebApplication app) + { + // Login endpoint - calls identity API, sets cookie, returns success + // Note: Uses /bff/ prefix to avoid conflict with ALB routing /api/* to the API service + app.MapPost("/bff/auth/login", async ( + HttpContext httpContext, + ITokenClient tokenClient, + ILogger logger) => + { + try + { + // Read form data + var form = await httpContext.Request.ReadFormAsync(); + var email = form["Email"].ToString(); + var password = form["Password"].ToString(); + var tenant = form["Tenant"].ToString(); + + logger.LogInformation("Login attempt for {Email}", email); + + // Call the identity API to get token + var token = await tokenClient.IssueAsync( + tenant ?? "root", + new GenerateTokenCommand + { + Email = email, + Password = password + }); + + if (token == null || string.IsNullOrEmpty(token.AccessToken)) + { + return Results.Unauthorized(); + } + + // Parse JWT to extract claims + var jwtHandler = new JwtSecurityTokenHandler(); + var jwtToken = jwtHandler.ReadJwtToken(token.AccessToken); + + var claims = new List + { + new(ClaimTypes.NameIdentifier, jwtToken.Subject ?? Guid.NewGuid().ToString()), + new(ClaimTypes.Email, email), + new("access_token", token.AccessToken), // Store JWT for API calls + new("refresh_token", token.RefreshToken), // Store refresh token for token renewal + new("tenant", tenant ?? "root"), // Store tenant for token refresh + }; + + // Add name claim + var nameClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == "name" || c.Type == ClaimTypes.Name); + if (nameClaim != null) + { + claims.Add(new Claim(ClaimTypes.Name, nameClaim.Value)); + } + + // Add role claims + var roleClaims = jwtToken.Claims.Where(c => c.Type == "role" || c.Type == ClaimTypes.Role); + claims.AddRange(roleClaims.Select(r => new Claim(ClaimTypes.Role, r.Value))); + + // Create identity and sign in with cookie + var identity = new ClaimsIdentity(claims, "Cookies"); + var principal = new ClaimsPrincipal(identity); + + await httpContext.SignInAsync("Cookies", principal, new AuthenticationProperties + { + IsPersistent = true, + ExpiresUtc = DateTimeOffset.UtcNow.AddDays(7) + }); + + logger.LogInformation("Login successful for {Email}", email); + + // Redirect to home page - this ensures the cookie is properly read on the next request + return Results.Redirect("/"); + } + catch (ApiException ex) when (ex.StatusCode == 401) + { + return Results.Unauthorized(); + } + catch (Exception ex) + { + logger.LogError(ex, "Login failed"); + return Results.Problem("Login failed"); + } + }) + .AllowAnonymous() + .DisableAntiforgery(); + + // Logout endpoint - POST for API calls + app.MapPost("/bff/auth/logout", async (HttpContext httpContext) => + { + await httpContext.SignOutAsync("Cookies"); + return Results.Ok(); + }) + .DisableAntiforgery(); + + // Logout endpoint - GET for browser redirects (ensures cookie is cleared in browser) + app.MapGet("/auth/logout", async (HttpContext httpContext) => + { + await httpContext.SignOutAsync("Cookies"); + return Results.Redirect("/login?toast=logout_success"); + }) + .AllowAnonymous(); + } +} diff --git a/src/Playground/Playground.Blazor/Services/TenantThemeState.cs b/src/Playground/Playground.Blazor/Services/TenantThemeState.cs new file mode 100644 index 0000000000..3563e96a98 --- /dev/null +++ b/src/Playground/Playground.Blazor/Services/TenantThemeState.cs @@ -0,0 +1,366 @@ +using FSH.Framework.Blazor.UI.Theme; +using MudBlazor; +using System.Text.Json.Serialization; + +namespace FSH.Playground.Blazor.Services; + +/// +/// Implementation of ITenantThemeState that fetches/saves theme settings via the API. +/// +internal sealed class TenantThemeState : ITenantThemeState +{ + private static readonly Uri ThemeEndpoint = new("/api/v1/tenants/theme", UriKind.Relative); + private static readonly Uri ThemeResetEndpoint = new("/api/v1/tenants/theme/reset", UriKind.Relative); + + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly string _apiBaseUrl; + + private TenantThemeSettings _current = TenantThemeSettings.Default; + private MudTheme _theme; + private bool _isDarkMode; + + public TenantThemeState(HttpClient httpClient, ILogger logger, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + _httpClient = httpClient; + _logger = logger; + _apiBaseUrl = configuration["Api:BaseUrl"]?.TrimEnd('/') ?? string.Empty; + _theme = _current.ToMudTheme(); + } + + public TenantThemeSettings Current => _current; + + public MudTheme Theme => _theme; + + public bool IsDarkMode + { + get => _isDarkMode; + set + { + if (_isDarkMode != value) + { + _isDarkMode = value; + OnThemeChanged?.Invoke(); + } + } + } + + public event Action? OnThemeChanged; + + public async Task LoadThemeAsync(CancellationToken cancellationToken = default) + { + try + { + var response = await _httpClient.GetAsync(ThemeEndpoint, cancellationToken); + + if (response.IsSuccessStatusCode) + { + var dto = await response.Content.ReadFromJsonAsync(cancellationToken); + if (dto is not null) + { + _current = MapFromDto(dto); + _theme = _current.ToMudTheme(); + OnThemeChanged?.Invoke(); + } + } + else + { + _logger.LogWarning("Failed to load tenant theme: {StatusCode}", response.StatusCode); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading tenant theme"); + } + } + + public async Task SaveThemeAsync(CancellationToken cancellationToken = default) + { + var dto = MapToDto(_current); + var response = await _httpClient.PutAsJsonAsync("/api/v1/tenants/theme", dto, cancellationToken); + response.EnsureSuccessStatusCode(); + + _theme = _current.ToMudTheme(); + OnThemeChanged?.Invoke(); + } + + public async Task ResetThemeAsync(CancellationToken cancellationToken = default) + { + var response = await _httpClient.PostAsync(ThemeResetEndpoint, null, cancellationToken); + response.EnsureSuccessStatusCode(); + + _current = TenantThemeSettings.Default; + _theme = _current.ToMudTheme(); + OnThemeChanged?.Invoke(); + } + + public void UpdateSettings(TenantThemeSettings settings) + { + _current = settings; + _theme = _current.ToMudTheme(); + OnThemeChanged?.Invoke(); + } + + public void ToggleDarkMode() + { + IsDarkMode = !IsDarkMode; + } + + private TenantThemeSettings MapFromDto(TenantThemeApiDto dto) + { + return new TenantThemeSettings + { + LightPalette = new PaletteSettings + { + Primary = dto.LightPalette?.Primary ?? "#2563EB", + Secondary = dto.LightPalette?.Secondary ?? "#0F172A", + Tertiary = dto.LightPalette?.Tertiary ?? "#6366F1", + Background = dto.LightPalette?.Background ?? "#F8FAFC", + Surface = dto.LightPalette?.Surface ?? "#FFFFFF", + Error = dto.LightPalette?.Error ?? "#DC2626", + Warning = dto.LightPalette?.Warning ?? "#F59E0B", + Success = dto.LightPalette?.Success ?? "#16A34A", + Info = dto.LightPalette?.Info ?? "#0284C7" + }, + DarkPalette = new PaletteSettings + { + Primary = dto.DarkPalette?.Primary ?? "#38BDF8", + Secondary = dto.DarkPalette?.Secondary ?? "#94A3B8", + Tertiary = dto.DarkPalette?.Tertiary ?? "#818CF8", + Background = dto.DarkPalette?.Background ?? "#0B1220", + Surface = dto.DarkPalette?.Surface ?? "#111827", + Error = dto.DarkPalette?.Error ?? "#F87171", + Warning = dto.DarkPalette?.Warning ?? "#FBBF24", + Success = dto.DarkPalette?.Success ?? "#22C55E", + Info = dto.DarkPalette?.Info ?? "#38BDF8" + }, + BrandAssets = new BrandAssets + { + LogoUrl = ToAbsoluteUrl(dto.BrandAssets?.LogoUrl), + LogoDarkUrl = ToAbsoluteUrl(dto.BrandAssets?.LogoDarkUrl), + FaviconUrl = ToAbsoluteUrl(dto.BrandAssets?.FaviconUrl) + }, + Typography = new TypographySettings + { + FontFamily = dto.Typography?.FontFamily ?? "Inter, sans-serif", + HeadingFontFamily = dto.Typography?.HeadingFontFamily ?? "Inter, sans-serif", + FontSizeBase = dto.Typography?.FontSizeBase ?? 14, + LineHeightBase = dto.Typography?.LineHeightBase ?? 1.5 + }, + Layout = new LayoutSettings + { + BorderRadius = dto.Layout?.BorderRadius ?? "4px", + DefaultElevation = dto.Layout?.DefaultElevation ?? 1 + }, + IsDefault = dto.IsDefault + }; + } + + private string? ToAbsoluteUrl(string? relativeUrl) + { + if (string.IsNullOrEmpty(relativeUrl)) + return null; + + // Already absolute URL or data URL + if (relativeUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + relativeUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || + relativeUrl.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + return relativeUrl; + } + + // Prepend API base URL to relative path + return $"{_apiBaseUrl}/{relativeUrl}"; + } + + private static TenantThemeApiDto MapToDto(TenantThemeSettings settings) + { + return new TenantThemeApiDto + { + LightPalette = new PaletteApiDto + { + Primary = settings.LightPalette.Primary, + Secondary = settings.LightPalette.Secondary, + Tertiary = settings.LightPalette.Tertiary, + Background = settings.LightPalette.Background, + Surface = settings.LightPalette.Surface, + Error = settings.LightPalette.Error, + Warning = settings.LightPalette.Warning, + Success = settings.LightPalette.Success, + Info = settings.LightPalette.Info + }, + DarkPalette = new PaletteApiDto + { + Primary = settings.DarkPalette.Primary, + Secondary = settings.DarkPalette.Secondary, + Tertiary = settings.DarkPalette.Tertiary, + Background = settings.DarkPalette.Background, + Surface = settings.DarkPalette.Surface, + Error = settings.DarkPalette.Error, + Warning = settings.DarkPalette.Warning, + Success = settings.DarkPalette.Success, + Info = settings.DarkPalette.Info + }, + BrandAssets = new BrandAssetsApiDto + { + LogoUrl = settings.BrandAssets.LogoUrl, + LogoDarkUrl = settings.BrandAssets.LogoDarkUrl, + FaviconUrl = settings.BrandAssets.FaviconUrl, + Logo = MapFileUpload(settings.BrandAssets.Logo), + LogoDark = MapFileUpload(settings.BrandAssets.LogoDark), + Favicon = MapFileUpload(settings.BrandAssets.Favicon), + DeleteLogo = settings.BrandAssets.DeleteLogo, + DeleteLogoDark = settings.BrandAssets.DeleteLogoDark, + DeleteFavicon = settings.BrandAssets.DeleteFavicon + }, + Typography = new TypographyApiDto + { + FontFamily = settings.Typography.FontFamily, + HeadingFontFamily = settings.Typography.HeadingFontFamily, + FontSizeBase = settings.Typography.FontSizeBase, + LineHeightBase = settings.Typography.LineHeightBase + }, + Layout = new LayoutApiDto + { + BorderRadius = settings.Layout.BorderRadius, + DefaultElevation = settings.Layout.DefaultElevation + }, + IsDefault = settings.IsDefault + }; + } + + private static FileUploadApiDto? MapFileUpload(FileUpload? upload) + { + if (upload is null || upload.Data.Length == 0) + return null; + + // Convert byte[] to List for JSON serialization (same as profile picture pattern) + return new FileUploadApiDto + { + FileName = upload.FileName, + ContentType = upload.ContentType, + Data = upload.Data.Select(static b => (int)b).ToList() + }; + } +} + +// API DTOs for serialization +internal sealed record TenantThemeApiDto +{ + [JsonPropertyName("lightPalette")] + public PaletteApiDto? LightPalette { get; init; } + + [JsonPropertyName("darkPalette")] + public PaletteApiDto? DarkPalette { get; init; } + + [JsonPropertyName("brandAssets")] + public BrandAssetsApiDto? BrandAssets { get; init; } + + [JsonPropertyName("typography")] + public TypographyApiDto? Typography { get; init; } + + [JsonPropertyName("layout")] + public LayoutApiDto? Layout { get; init; } + + [JsonPropertyName("isDefault")] + public bool IsDefault { get; init; } +} + +internal sealed record PaletteApiDto +{ + [JsonPropertyName("primary")] + public string? Primary { get; init; } + + [JsonPropertyName("secondary")] + public string? Secondary { get; init; } + + [JsonPropertyName("tertiary")] + public string? Tertiary { get; init; } + + [JsonPropertyName("background")] + public string? Background { get; init; } + + [JsonPropertyName("surface")] + public string? Surface { get; init; } + + [JsonPropertyName("error")] + public string? Error { get; init; } + + [JsonPropertyName("warning")] + public string? Warning { get; init; } + + [JsonPropertyName("success")] + public string? Success { get; init; } + + [JsonPropertyName("info")] + public string? Info { get; init; } +} + +internal sealed record BrandAssetsApiDto +{ + [JsonPropertyName("logoUrl")] + public string? LogoUrl { get; init; } + + [JsonPropertyName("logoDarkUrl")] + public string? LogoDarkUrl { get; init; } + + [JsonPropertyName("faviconUrl")] + public string? FaviconUrl { get; init; } + + // File upload data (same pattern as profile picture) + [JsonPropertyName("logo")] + public FileUploadApiDto? Logo { get; init; } + + [JsonPropertyName("logoDark")] + public FileUploadApiDto? LogoDark { get; init; } + + [JsonPropertyName("favicon")] + public FileUploadApiDto? Favicon { get; init; } + + // Delete flags + [JsonPropertyName("deleteLogo")] + public bool DeleteLogo { get; init; } + + [JsonPropertyName("deleteLogoDark")] + public bool DeleteLogoDark { get; init; } + + [JsonPropertyName("deleteFavicon")] + public bool DeleteFavicon { get; init; } +} + +internal sealed record FileUploadApiDto +{ + [JsonPropertyName("fileName")] + public string FileName { get; init; } = default!; + + [JsonPropertyName("contentType")] + public string ContentType { get; init; } = default!; + + [JsonPropertyName("data")] + public ICollection Data { get; init; } = []; +} + +internal sealed record TypographyApiDto +{ + [JsonPropertyName("fontFamily")] + public string? FontFamily { get; init; } + + [JsonPropertyName("headingFontFamily")] + public string? HeadingFontFamily { get; init; } + + [JsonPropertyName("fontSizeBase")] + public double FontSizeBase { get; init; } + + [JsonPropertyName("lineHeightBase")] + public double LineHeightBase { get; init; } +} + +internal sealed record LayoutApiDto +{ + [JsonPropertyName("borderRadius")] + public string? BorderRadius { get; init; } + + [JsonPropertyName("defaultElevation")] + public int DefaultElevation { get; init; } +} diff --git a/src/Playground/Playground.Blazor/Services/ThemeStateFactory.cs b/src/Playground/Playground.Blazor/Services/ThemeStateFactory.cs new file mode 100644 index 0000000000..f7bc7fdaca --- /dev/null +++ b/src/Playground/Playground.Blazor/Services/ThemeStateFactory.cs @@ -0,0 +1,162 @@ +using System.Text.Json; +using FSH.Framework.Blazor.UI.Theme; +using Microsoft.Extensions.Caching.Distributed; + +namespace FSH.Playground.Blazor.Services; + +/// +/// Factory for loading theme state, optimized for SSR scenarios. +/// +internal interface IThemeStateFactory +{ + Task GetThemeAsync(string tenantId, CancellationToken cancellationToken = default); +} + +/// +/// Redis-cached implementation of theme state factory. +/// Efficient for SSR pages that need theme data without full circuit. +/// +internal sealed class CachedThemeStateFactory : IThemeStateFactory +{ + private static readonly Uri ThemeEndpoint = new("/api/v1/tenants/theme", UriKind.Relative); + + private readonly IDistributedCache _cache; + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly TimeSpan _cacheExpiry = TimeSpan.FromMinutes(15); + + public CachedThemeStateFactory( + IDistributedCache cache, + HttpClient httpClient, + ILogger logger) + { + _cache = cache; + _httpClient = httpClient; + _logger = logger; + } + + public async Task GetThemeAsync(string tenantId, CancellationToken cancellationToken = default) + { + var cacheKey = $"theme:{tenantId}"; + + // Try to get from cache first (with error handling for Redis failures) + try + { + var json = await _cache.GetStringAsync(cacheKey, cancellationToken); + if (json is not null) + { + try + { + var cached = JsonSerializer.Deserialize(json); + if (cached is not null) + { + return cached; + } + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to deserialize cached theme for tenant {TenantId}", tenantId); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cache unavailable, fetching theme directly for tenant {TenantId}", tenantId); + } + + // Cache miss or deserialization failed - fetch from API + try + { + var response = await _httpClient.GetAsync(ThemeEndpoint, cancellationToken); + + if (response.IsSuccessStatusCode) + { + var dto = await response.Content.ReadFromJsonAsync(cancellationToken); + if (dto is not null) + { + var settings = MapFromDto(dto); + + // Try to cache for 15 minutes (fail silently if cache unavailable) + try + { + var serialized = JsonSerializer.Serialize(settings); + var options = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = _cacheExpiry + }; + await _cache.SetStringAsync(cacheKey, serialized, options, cancellationToken); + } + catch (Exception cacheEx) + { + _logger.LogWarning(cacheEx, "Failed to cache theme, continuing without cache"); + } + + return settings; + } + } + else + { + _logger.LogWarning("Failed to load tenant theme from API: {StatusCode}", response.StatusCode); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading tenant theme for {TenantId}", tenantId); + } + + // Fallback to default theme + return TenantThemeSettings.Default; + } + + private static TenantThemeSettings MapFromDto(TenantThemeApiDto dto) + { + var defaultSettings = TenantThemeSettings.Default; + + return new TenantThemeSettings + { + LightPalette = new PaletteSettings + { + Primary = dto.LightPalette?.Primary ?? defaultSettings.LightPalette.Primary, + Secondary = dto.LightPalette?.Secondary ?? defaultSettings.LightPalette.Secondary, + Tertiary = dto.LightPalette?.Tertiary ?? defaultSettings.LightPalette.Tertiary, + Background = dto.LightPalette?.Background ?? defaultSettings.LightPalette.Background, + Surface = dto.LightPalette?.Surface ?? defaultSettings.LightPalette.Surface, + Error = dto.LightPalette?.Error ?? defaultSettings.LightPalette.Error, + Warning = dto.LightPalette?.Warning ?? defaultSettings.LightPalette.Warning, + Success = dto.LightPalette?.Success ?? defaultSettings.LightPalette.Success, + Info = dto.LightPalette?.Info ?? defaultSettings.LightPalette.Info + }, + DarkPalette = new PaletteSettings + { + Primary = dto.DarkPalette?.Primary ?? defaultSettings.DarkPalette.Primary, + Secondary = dto.DarkPalette?.Secondary ?? defaultSettings.DarkPalette.Secondary, + Tertiary = dto.DarkPalette?.Tertiary ?? defaultSettings.DarkPalette.Tertiary, + Background = dto.DarkPalette?.Background ?? defaultSettings.DarkPalette.Background, + Surface = dto.DarkPalette?.Surface ?? defaultSettings.DarkPalette.Surface, + Error = dto.DarkPalette?.Error ?? defaultSettings.DarkPalette.Error, + Warning = dto.DarkPalette?.Warning ?? defaultSettings.DarkPalette.Warning, + Success = dto.DarkPalette?.Success ?? defaultSettings.DarkPalette.Success, + Info = dto.DarkPalette?.Info ?? defaultSettings.DarkPalette.Info + }, + BrandAssets = new BrandAssets + { + LogoUrl = dto.BrandAssets?.LogoUrl, + LogoDarkUrl = dto.BrandAssets?.LogoDarkUrl, + FaviconUrl = dto.BrandAssets?.FaviconUrl + }, + Typography = new TypographySettings + { + FontFamily = dto.Typography?.FontFamily ?? defaultSettings.Typography.FontFamily, + HeadingFontFamily = dto.Typography?.HeadingFontFamily ?? defaultSettings.Typography.HeadingFontFamily, + FontSizeBase = dto.Typography?.FontSizeBase ?? defaultSettings.Typography.FontSizeBase, + LineHeightBase = dto.Typography?.LineHeightBase ?? defaultSettings.Typography.LineHeightBase + }, + Layout = new LayoutSettings + { + BorderRadius = dto.Layout?.BorderRadius ?? defaultSettings.Layout.BorderRadius, + DefaultElevation = dto.Layout?.DefaultElevation ?? defaultSettings.Layout.DefaultElevation + }, + IsDefault = dto.IsDefault + }; + } +} diff --git a/src/Playground/Playground.Blazor/Services/UserProfileState.cs b/src/Playground/Playground.Blazor/Services/UserProfileState.cs new file mode 100644 index 0000000000..3e5847fd6b --- /dev/null +++ b/src/Playground/Playground.Blazor/Services/UserProfileState.cs @@ -0,0 +1,61 @@ +namespace FSH.Playground.Blazor.Services; + +/// +/// Service for managing and sharing user profile state across components. +/// When the profile is updated (e.g., in ProfileSettings), other components +/// like the layout header can subscribe to be notified of changes. +/// +#pragma warning disable CA1056, CA1054 // Avatar URLs are passed as strings from APIs +#pragma warning disable CA1003 // Action is the idiomatic pattern for Blazor state change events +internal interface IUserProfileState +{ + string UserName { get; } + string? UserEmail { get; } + string? UserRole { get; } + string? AvatarUrl { get; } + + /// + /// Event raised when profile data changes. + /// + event Action? OnProfileChanged; + + /// + /// Updates the profile state and notifies subscribers. + /// + void UpdateProfile(string userName, string? userEmail, string? userRole, string? avatarUrl); + + /// + /// Clears the profile state (e.g., on logout). + /// + void Clear(); +} +#pragma warning restore CA1003 +#pragma warning restore CA1056, CA1054 + +internal sealed class UserProfileState : IUserProfileState +{ + public string UserName { get; private set; } = "User"; + public string? UserEmail { get; private set; } + public string? UserRole { get; private set; } + public string? AvatarUrl { get; private set; } + + public event Action? OnProfileChanged; + + public void UpdateProfile(string userName, string? userEmail, string? userRole, string? avatarUrl) + { + UserName = userName; + UserEmail = userEmail; + UserRole = userRole; + AvatarUrl = avatarUrl; + OnProfileChanged?.Invoke(); + } + + public void Clear() + { + UserName = "User"; + UserEmail = null; + UserRole = null; + AvatarUrl = null; + OnProfileChanged?.Invoke(); + } +} diff --git a/src/Playground/Playground.Blazor/appsettings.Development.json b/src/Playground/Playground.Blazor/appsettings.Development.json new file mode 100644 index 0000000000..0c208ae918 --- /dev/null +++ b/src/Playground/Playground.Blazor/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Playground/Playground.Blazor/appsettings.Production.json b/src/Playground/Playground.Blazor/appsettings.Production.json new file mode 100644 index 0000000000..afd84633ba --- /dev/null +++ b/src/Playground/Playground.Blazor/appsettings.Production.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "ui.example.com", + "Api": { + "BaseUrl": "" + } +} diff --git a/src/Playground/Playground.Blazor/appsettings.json b/src/Playground/Playground.Blazor/appsettings.json new file mode 100644 index 0000000000..3b0ebe3e6c --- /dev/null +++ b/src/Playground/Playground.Blazor/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Api": { + "BaseUrl": "https://localhost:7030" + } +} diff --git a/src/Playground/Playground.Blazor/wwwroot/favicon.ico b/src/Playground/Playground.Blazor/wwwroot/favicon.ico new file mode 100644 index 0000000000..1239223665 Binary files /dev/null and b/src/Playground/Playground.Blazor/wwwroot/favicon.ico differ diff --git a/src/Shared/Authorization/AppConstants.cs b/src/Shared/Authorization/AppConstants.cs deleted file mode 100644 index 5334ee902f..0000000000 --- a/src/Shared/Authorization/AppConstants.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.ObjectModel; - -namespace FSH.Starter.Shared.Authorization; -public static class AppConstants -{ - public static readonly Collection SupportedImageFormats = - [ - ".jpeg", - ".jpg", - ".png" - ]; - public static readonly string StandardImageFormat = "image/jpeg"; - public static readonly int MaxImageWidth = 1500; - public static readonly int MaxImageHeight = 1500; - public static readonly long MaxAllowedSize = 1000000; // Allows Max File Size of 1 Mb. -} diff --git a/src/Shared/Authorization/ClaimsPrincipalExtensions.cs b/src/Shared/Authorization/ClaimsPrincipalExtensions.cs deleted file mode 100644 index 4c4398d73a..0000000000 --- a/src/Shared/Authorization/ClaimsPrincipalExtensions.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Security.Claims; - -namespace FSH.Starter.Shared.Authorization; -public static class ClaimsPrincipalExtensions -{ - public static string? GetEmail(this ClaimsPrincipal principal) - => principal.FindFirstValue(ClaimTypes.Email); - - public static string? GetTenant(this ClaimsPrincipal principal) - => principal.FindFirstValue(FshClaims.Tenant); - - public static string? GetFullName(this ClaimsPrincipal principal) - => principal?.FindFirst(FshClaims.Fullname)?.Value; - - public static string? GetFirstName(this ClaimsPrincipal principal) - => principal?.FindFirst(ClaimTypes.Name)?.Value; - - public static string? GetSurname(this ClaimsPrincipal principal) - => principal?.FindFirst(ClaimTypes.Surname)?.Value; - - public static string? GetPhoneNumber(this ClaimsPrincipal principal) - => principal.FindFirstValue(ClaimTypes.MobilePhone); - - public static string? GetUserId(this ClaimsPrincipal principal) - => principal.FindFirstValue(ClaimTypes.NameIdentifier); - - public static Uri? GetImageUrl(this ClaimsPrincipal principal) - { - var imageUrl = principal.FindFirstValue(FshClaims.ImageUrl); - return Uri.TryCreate(imageUrl, UriKind.Absolute, out var uri) ? uri : null; - } - - public static DateTimeOffset GetExpiration(this ClaimsPrincipal principal) => - DateTimeOffset.FromUnixTimeSeconds(Convert.ToInt64( - principal.FindFirstValue(FshClaims.Expiration))); - - private static string? FindFirstValue(this ClaimsPrincipal principal, string claimType) => - principal is null - ? throw new ArgumentNullException(nameof(principal)) - : principal.FindFirst(claimType)?.Value; -} diff --git a/src/Shared/Authorization/FshActions.cs b/src/Shared/Authorization/FshActions.cs deleted file mode 100644 index a29f17e51e..0000000000 --- a/src/Shared/Authorization/FshActions.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace FSH.Starter.Shared.Authorization; -public static class FshActions -{ - public const string View = nameof(View); - public const string Search = nameof(Search); - public const string Create = nameof(Create); - public const string Update = nameof(Update); - public const string Delete = nameof(Delete); - public const string Export = nameof(Export); - public const string Generate = nameof(Generate); - public const string Clean = nameof(Clean); - public const string UpgradeSubscription = nameof(UpgradeSubscription); -} diff --git a/src/Shared/Authorization/FshClaims.cs b/src/Shared/Authorization/FshClaims.cs deleted file mode 100644 index bdf9020684..0000000000 --- a/src/Shared/Authorization/FshClaims.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace FSH.Starter.Shared.Authorization; - -public static class FshClaims -{ - public const string Tenant = "tenant"; - public const string Fullname = "fullName"; - public const string Permission = "permission"; - public const string ImageUrl = "image_url"; - public const string IpAddress = "ipAddress"; - public const string Expiration = "exp"; -} diff --git a/src/Shared/Authorization/FshPermissions.cs b/src/Shared/Authorization/FshPermissions.cs deleted file mode 100644 index fad6676ee8..0000000000 --- a/src/Shared/Authorization/FshPermissions.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System.Collections.ObjectModel; - -namespace FSH.Starter.Shared.Authorization; - -public static class FshPermissions -{ - private static readonly FshPermission[] AllPermissions = - [ - //tenants - new("View Tenants", FshActions.View, FshResources.Tenants, IsRoot: true), - new("Create Tenants", FshActions.Create, FshResources.Tenants, IsRoot: true), - new("Update Tenants", FshActions.Update, FshResources.Tenants, IsRoot: true), - new("Upgrade Tenant Subscription", FshActions.UpgradeSubscription, FshResources.Tenants, IsRoot: true), - - //identity - new("View Users", FshActions.View, FshResources.Users), - new("Search Users", FshActions.Search, FshResources.Users), - new("Create Users", FshActions.Create, FshResources.Users), - new("Update Users", FshActions.Update, FshResources.Users), - new("Delete Users", FshActions.Delete, FshResources.Users), - new("Export Users", FshActions.Export, FshResources.Users), - new("View UserRoles", FshActions.View, FshResources.UserRoles), - new("Update UserRoles", FshActions.Update, FshResources.UserRoles), - new("View Roles", FshActions.View, FshResources.Roles), - new("Create Roles", FshActions.Create, FshResources.Roles), - new("Update Roles", FshActions.Update, FshResources.Roles), - new("Delete Roles", FshActions.Delete, FshResources.Roles), - new("View RoleClaims", FshActions.View, FshResources.RoleClaims), - new("Update RoleClaims", FshActions.Update, FshResources.RoleClaims), - - //products - new("View Products", FshActions.View, FshResources.Products, IsBasic: true), - new("Search Products", FshActions.Search, FshResources.Products, IsBasic: true), - new("Create Products", FshActions.Create, FshResources.Products), - new("Update Products", FshActions.Update, FshResources.Products), - new("Delete Products", FshActions.Delete, FshResources.Products), - new("Export Products", FshActions.Export, FshResources.Products), - - //brands - new("View Brands", FshActions.View, FshResources.Brands, IsBasic: true), - new("Search Brands", FshActions.Search, FshResources.Brands, IsBasic: true), - new("Create Brands", FshActions.Create, FshResources.Brands), - new("Update Brands", FshActions.Update, FshResources.Brands), - new("Delete Brands", FshActions.Delete, FshResources.Brands), - new("Export Brands", FshActions.Export, FshResources.Brands), - - //todos - new("View Todos", FshActions.View, FshResources.Todos, IsBasic: true), - new("Search Todos", FshActions.Search, FshResources.Todos, IsBasic: true), - new("Create Todos", FshActions.Create, FshResources.Todos), - new("Update Todos", FshActions.Update, FshResources.Todos), - new("Delete Todos", FshActions.Delete, FshResources.Todos), - new("Export Todos", FshActions.Export, FshResources.Todos), - - new("View Hangfire", FshActions.View, FshResources.Hangfire), - new("View Dashboard", FshActions.View, FshResources.Dashboard), - - //audit - new("View Audit Trails", FshActions.View, FshResources.AuditTrails), - ]; - - public static IReadOnlyList All { get; } = new ReadOnlyCollection(AllPermissions); - public static IReadOnlyList Root { get; } = new ReadOnlyCollection(AllPermissions.Where(p => p.IsRoot).ToArray()); - public static IReadOnlyList Admin { get; } = new ReadOnlyCollection(AllPermissions.Where(p => !p.IsRoot).ToArray()); - public static IReadOnlyList Basic { get; } = new ReadOnlyCollection(AllPermissions.Where(p => p.IsBasic).ToArray()); -} - -public record FshPermission(string Description, string Action, string Resource, bool IsBasic = false, bool IsRoot = false) -{ - public string Name => NameFor(Action, Resource); - public static string NameFor(string action, string resource) - { - return $"Permissions.{resource}.{action}"; - } -} - - diff --git a/src/Shared/Authorization/FshResources.cs b/src/Shared/Authorization/FshResources.cs deleted file mode 100644 index e8d276c470..0000000000 --- a/src/Shared/Authorization/FshResources.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace FSH.Starter.Shared.Authorization; -public static class FshResources -{ - public const string Tenants = nameof(Tenants); - public const string Dashboard = nameof(Dashboard); - public const string Hangfire = nameof(Hangfire); - public const string Users = nameof(Users); - public const string UserRoles = nameof(UserRoles); - public const string Roles = nameof(Roles); - public const string RoleClaims = nameof(RoleClaims); - public const string Products = nameof(Products); - public const string Brands = nameof(Brands); - public const string Todos = nameof(Todos); - public const string AuditTrails = nameof(AuditTrails); -} diff --git a/src/Shared/Authorization/FshRoles.cs b/src/Shared/Authorization/FshRoles.cs deleted file mode 100644 index e471e50930..0000000000 --- a/src/Shared/Authorization/FshRoles.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.ObjectModel; - -namespace FSH.Starter.Shared.Authorization; - -public static class FshRoles -{ - public const string Admin = nameof(Admin); - public const string Basic = nameof(Basic); - - public static IReadOnlyList DefaultRoles { get; } = new ReadOnlyCollection(new[] - { - Admin, - Basic - }); - - public static bool IsDefault(string roleName) => DefaultRoles.Any(r => r == roleName); -} diff --git a/src/Shared/Authorization/IdentityConstants.cs b/src/Shared/Authorization/IdentityConstants.cs deleted file mode 100644 index 7d15a098be..0000000000 --- a/src/Shared/Authorization/IdentityConstants.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace FSH.Starter.Shared.Authorization; -public static class IdentityConstants -{ - public const int PasswordLength = 6; - public const string SchemaName = "identity"; -} diff --git a/src/Shared/Authorization/TenantConstants.cs b/src/Shared/Authorization/TenantConstants.cs deleted file mode 100644 index 984fe77e1a..0000000000 --- a/src/Shared/Authorization/TenantConstants.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace FSH.Starter.Shared.Authorization; -public static class TenantConstants -{ - public static class Root - { - public const string Id = "root"; - public const string Name = "Root"; - public const string EmailAddress = "admin@root.com"; - public const string DefaultProfilePicture = "assets/defaults/profile-picture.webp"; - } - - public const string DefaultPassword = "123Pa$$word!"; - - public const string Identifier = "tenant"; - -} diff --git a/src/Shared/Constants/SchemaNames.cs b/src/Shared/Constants/SchemaNames.cs deleted file mode 100644 index 6f6763a8b2..0000000000 --- a/src/Shared/Constants/SchemaNames.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Shared.Constants; -public static class SchemaNames -{ - public const string Todo = "todo"; - public const string Catalog = "catalog"; - public const string Tenant = "tenant"; -} diff --git a/src/Shared/Shared.csproj b/src/Shared/Shared.csproj deleted file mode 100644 index 125f4c93bc..0000000000 --- a/src/Shared/Shared.csproj +++ /dev/null @@ -1,9 +0,0 @@ - - - - net9.0 - enable - enable - - - diff --git a/src/Tests/Architecture.Tests/Architecture.Tests.csproj b/src/Tests/Architecture.Tests/Architecture.Tests.csproj new file mode 100644 index 0000000000..05485d1e21 --- /dev/null +++ b/src/Tests/Architecture.Tests/Architecture.Tests.csproj @@ -0,0 +1,39 @@ + + + net10.0 + false + false + enable + enable + $(NoWarn);CA1515;CA1861;CA1707 + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + diff --git a/src/Tests/Architecture.Tests/FeatureArchitectureTests.cs b/src/Tests/Architecture.Tests/FeatureArchitectureTests.cs new file mode 100644 index 0000000000..d93028ae8b --- /dev/null +++ b/src/Tests/Architecture.Tests/FeatureArchitectureTests.cs @@ -0,0 +1,44 @@ +using FSH.Modules.Auditing; +using FSH.Modules.Identity; +using FSH.Modules.Multitenancy; +using NetArchTest.Rules; +using Shouldly; +using Xunit; + +namespace Architecture.Tests; + +public class FeatureArchitectureTests +{ + [Fact] + public void Features_Versions_Should_Not_Depend_On_Newer_Versions() + { + // Guardrail for future versions (v2, v3, ...). For now, this is mostly + // a safety net to prevent accidental cross-version feature coupling. + var modules = new[] + { + typeof(AuditingModule).Assembly, + typeof(IdentityModule).Assembly, + typeof(MultitenancyModule).Assembly + }; + + foreach (var module in modules) + { + var v1Result = Types + .InAssembly(module) + .That() + .ResideInNamespaceEndingWith(".Features.v1") + .Should() + .NotHaveDependencyOnAny( + // If v2+ namespaces are introduced later, v1 should not depend on them. + ".Features.v2", + ".Features.v3") + .GetResult(); + + var failingTypes = v1Result.FailingTypeNames ?? Array.Empty(); + + v1Result.IsSuccessful.ShouldBeTrue( + $"v1 features in assembly '{module.FullName}' must not depend on newer feature versions. " + + $"Failing types: {string.Join(", ", failingTypes)}"); + } + } +} diff --git a/src/Tests/Architecture.Tests/ModuleArchitectureTests.cs b/src/Tests/Architecture.Tests/ModuleArchitectureTests.cs new file mode 100644 index 0000000000..37d76b373c --- /dev/null +++ b/src/Tests/Architecture.Tests/ModuleArchitectureTests.cs @@ -0,0 +1,70 @@ +using Shouldly; +using System.Xml.Linq; +using Xunit; + +namespace Architecture.Tests; + +public class ModuleArchitectureTests +{ + [Fact] + public void Modules_Should_Not_Depend_On_Other_Modules() + { + string solutionRoot = GetSolutionRoot(); + string modulesRoot = Path.Combine(solutionRoot, "src", "Modules"); + + var runtimeProjects = Directory + .GetFiles(modulesRoot, "Modules.*.csproj", SearchOption.AllDirectories) + .Where(path => !path.Contains(".Contracts", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + runtimeProjects.Length.ShouldBeGreaterThan(0); + + foreach (string projectPath in runtimeProjects) + { + string currentName = Path.GetFileNameWithoutExtension(projectPath); + + var document = XDocument.Load(projectPath); + var references = document + .Descendants("ProjectReference") + .Select(x => (string?)x.Attribute("Include") ?? string.Empty) + .Where(include => include.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + foreach (string include in references) + { + string referencedName = Path.GetFileNameWithoutExtension(include); + + bool isModuleRuntime = referencedName.StartsWith("Modules.", StringComparison.OrdinalIgnoreCase) + && !referencedName.EndsWith(".Contracts", StringComparison.OrdinalIgnoreCase); + + if (!isModuleRuntime) + { + continue; + } + + bool isSelfReference = string.Equals(referencedName, currentName, StringComparison.OrdinalIgnoreCase); + isSelfReference.ShouldBeTrue( + $"Module runtime project '{currentName}' must not reference other module runtime project '{referencedName}'. " + + "Only contracts or building block projects are allowed."); + } + } + } + + private static string GetSolutionRoot() + { + // Start at current directory and walk up until we find `src`. + var directory = new DirectoryInfo(Directory.GetCurrentDirectory()); + + while (directory is not null && !Directory.Exists(Path.Combine(directory.FullName, "src"))) + { + directory = directory.Parent; + } + + if (directory is null) + { + throw new InvalidOperationException("Unable to locate solution root containing 'src' folder."); + } + + return directory.FullName; + } +} diff --git a/src/Tests/Architecture.Tests/NamespaceConventionsTests.cs b/src/Tests/Architecture.Tests/NamespaceConventionsTests.cs new file mode 100644 index 0000000000..581564df4a --- /dev/null +++ b/src/Tests/Architecture.Tests/NamespaceConventionsTests.cs @@ -0,0 +1,48 @@ +using Shouldly; +using Xunit; + +namespace Architecture.Tests; + +public class NamespaceConventionsTests +{ + private static readonly string SolutionRoot = ModuleArchitectureTestsFixture.SolutionRoot; + + [Fact] + public void BuildingBlocks_Core_Domain_Namespaces_Should_Match_Folder() + { + string domainRoot = Path.Combine(SolutionRoot, "src", "BuildingBlocks", "Core", "Domain"); + + if (!Directory.Exists(domainRoot)) + { + // If the folder does not yet exist, treat this as a neutral pass. + return; + } + + var files = Directory + .GetFiles(domainRoot, "*.cs", SearchOption.AllDirectories) + .ToArray(); + + files.Length.ShouldBeGreaterThan(0); + + foreach (string file in files) + { + string content = File.ReadAllText(file); + + var namespaceLine = content + .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault(line => line.TrimStart().StartsWith("namespace ", StringComparison.Ordinal)); + + namespaceLine.ShouldNotBeNull($"File '{file}' must declare a namespace matching the folder structure."); + + string declaredNamespace = namespaceLine!["namespace ".Length..].Trim().TrimEnd(';'); + + declaredNamespace + .Contains(".Core.", StringComparison.Ordinal) + .ShouldBeTrue($"Namespace '{declaredNamespace}' should include '.Core.' for file '{file}'."); + + declaredNamespace + .Contains(".Domain", StringComparison.Ordinal) + .ShouldBeTrue($"Namespace '{declaredNamespace}' should include '.Domain' for file '{file}'."); + } + } +} diff --git a/src/Tests/Architecture.Tests/PlaygroundArchitectureTests.cs b/src/Tests/Architecture.Tests/PlaygroundArchitectureTests.cs new file mode 100644 index 0000000000..48f7ae79b3 --- /dev/null +++ b/src/Tests/Architecture.Tests/PlaygroundArchitectureTests.cs @@ -0,0 +1,87 @@ +using NetArchTest.Rules; +using Shouldly; +using Xunit; + +namespace Architecture.Tests; + +public class PlaygroundArchitectureTests +{ + [Fact] + public void Modules_Should_Not_Depend_On_Playground_Hosts() + { + // Assemblies / namespaces that represent Playground hosts. + string[] playgroundNamespaces = + { + "FSH.Playground.Api", + "Playground.Blazor" + }; + + var result = Types + .InCurrentDomain() + .That() + .ResideInNamespace("FSH.Modules") + .Should() + .NotHaveDependencyOnAny(playgroundNamespaces) + .GetResult(); + + var failingTypes = result.FailingTypeNames ?? Array.Empty(); + + result.IsSuccessful.ShouldBeTrue( + "Module code must not depend on Playground host assemblies. " + + $"Failing types: {string.Join(", ", failingTypes)}"); + } + + [Fact] + public void Playground_Hosts_Should_Not_Depend_On_Module_Internals() + { + // Hosts may depend on module contracts and module root types, + // but should not directly reference feature or data-layer namespaces. + string[] forbiddenNamespaces = + { + "FSH.Modules.Auditing.Features", + "FSH.Modules.Auditing.Data", + "FSH.Modules.Identity.Features", + "FSH.Modules.Identity.Data", + "FSH.Modules.Multitenancy.Features", + "FSH.Modules.Multitenancy.Data" + }; + + var hostResult = Types + .InCurrentDomain() + .That() + .ResideInNamespace("FSH.Playground") + .Or() + .ResideInNamespace("Playground.Blazor") + .Should() + .NotHaveDependencyOnAny(forbiddenNamespaces) + .GetResult(); + + var hostFailingTypes = hostResult.FailingTypeNames ?? Array.Empty(); + + hostResult.IsSuccessful.ShouldBeTrue( + "Playground hosts should not depend directly on module feature or data internals. " + + $"Failing types: {string.Join(", ", hostFailingTypes)}"); + } +} + +internal static class ModuleArchitectureTestsFixture +{ + public static readonly string SolutionRoot = GetSolutionRoot(); + + private static string GetSolutionRoot() + { + var directory = new DirectoryInfo(Directory.GetCurrentDirectory()); + + while (directory is not null && !Directory.Exists(Path.Combine(directory.FullName, "src"))) + { + directory = directory.Parent; + } + + if (directory is null) + { + throw new InvalidOperationException("Unable to locate solution root containing 'src' folder."); + } + + return directory.FullName; + } +} diff --git a/src/Tests/Auditing.Tests/Auditing.Tests.csproj b/src/Tests/Auditing.Tests/Auditing.Tests.csproj new file mode 100644 index 0000000000..4bbcc25c4f --- /dev/null +++ b/src/Tests/Auditing.Tests/Auditing.Tests.csproj @@ -0,0 +1,28 @@ + + + net10.0 + false + false + enable + enable + $(NoWarn);CA1515;CA1861;CA1707 + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/src/Tests/Auditing.Tests/Contracts/AuditEnvelopeTests.cs b/src/Tests/Auditing.Tests/Contracts/AuditEnvelopeTests.cs new file mode 100644 index 0000000000..3aebb6ae5c --- /dev/null +++ b/src/Tests/Auditing.Tests/Contracts/AuditEnvelopeTests.cs @@ -0,0 +1,285 @@ +using FSH.Modules.Auditing.Contracts; + +namespace Auditing.Tests.Contracts; + +/// +/// Tests for AuditEnvelope - the concrete event instance for audit persistence. +/// +public sealed class AuditEnvelopeTests +{ + private static readonly Guid TestId = Guid.NewGuid(); + private static readonly DateTime TestOccurredAt = new(2024, 1, 15, 12, 0, 0, DateTimeKind.Utc); + private static readonly DateTime TestReceivedAt = new(2024, 1, 15, 12, 0, 1, DateTimeKind.Utc); + + [Fact] + public void Constructor_Should_ThrowArgumentNullException_When_PayloadIsNull() + { + // Act & Assert + Should.Throw(() => new AuditEnvelope( + TestId, + TestOccurredAt, + TestReceivedAt, + AuditEventType.Activity, + AuditSeverity.Information, + "tenant1", + "user1", + "John Doe", + "trace-123", + "span-456", + "correlation-789", + "request-abc", + "TestSource", + AuditTag.None, + null!)); + } + + [Fact] + public void Constructor_Should_SetAllProperties_Correctly() + { + // Arrange + var payload = new { action = "test" }; + + // Act + var envelope = new AuditEnvelope( + TestId, + TestOccurredAt, + TestReceivedAt, + AuditEventType.Security, + AuditSeverity.Warning, + "tenant1", + "user1", + "John Doe", + "trace-123", + "span-456", + "correlation-789", + "request-abc", + "TestSource", + AuditTag.PiiMasked | AuditTag.Authentication, + payload); + + // Assert + envelope.Id.ShouldBe(TestId); + envelope.OccurredAtUtc.ShouldBe(TestOccurredAt); + envelope.ReceivedAtUtc.ShouldBe(TestReceivedAt); + envelope.EventType.ShouldBe(AuditEventType.Security); + envelope.Severity.ShouldBe(AuditSeverity.Warning); + envelope.TenantId.ShouldBe("tenant1"); + envelope.UserId.ShouldBe("user1"); + envelope.UserName.ShouldBe("John Doe"); + envelope.TraceId.ShouldBe("trace-123"); + envelope.SpanId.ShouldBe("span-456"); + envelope.CorrelationId.ShouldBe("correlation-789"); + envelope.RequestId.ShouldBe("request-abc"); + envelope.Source.ShouldBe("TestSource"); + envelope.Tags.ShouldBe(AuditTag.PiiMasked | AuditTag.Authentication); + envelope.Payload.ShouldBe(payload); + } + + [Fact] + public void Constructor_Should_ConvertToUtc_When_OccurredAtNotUtc() + { + // Arrange + var localTime = new DateTime(2024, 1, 15, 12, 0, 0, DateTimeKind.Local); + var payload = new { action = "test" }; + + // Act + var envelope = new AuditEnvelope( + TestId, + localTime, + TestReceivedAt, + AuditEventType.Activity, + AuditSeverity.Information, + null, null, null, null, null, null, null, null, + AuditTag.None, + payload); + + // Assert + envelope.OccurredAtUtc.Kind.ShouldBe(DateTimeKind.Utc); + } + + [Fact] + public void Constructor_Should_ConvertToUtc_When_ReceivedAtNotUtc() + { + // Arrange + var localTime = new DateTime(2024, 1, 15, 12, 0, 0, DateTimeKind.Local); + var payload = new { action = "test" }; + + // Act + var envelope = new AuditEnvelope( + TestId, + TestOccurredAt, + localTime, + AuditEventType.Activity, + AuditSeverity.Information, + null, null, null, null, null, null, null, null, + AuditTag.None, + payload); + + // Assert + envelope.ReceivedAtUtc.Kind.ShouldBe(DateTimeKind.Utc); + } + + [Fact] + public void Constructor_Should_PreserveUtc_When_OccurredAtIsUtc() + { + // Arrange + var payload = new { action = "test" }; + + // Act + var envelope = new AuditEnvelope( + TestId, + TestOccurredAt, + TestReceivedAt, + AuditEventType.Activity, + AuditSeverity.Information, + null, null, null, null, null, null, null, null, + AuditTag.None, + payload); + + // Assert + envelope.OccurredAtUtc.ShouldBe(TestOccurredAt); + envelope.OccurredAtUtc.Kind.ShouldBe(DateTimeKind.Utc); + } + + [Fact] + public void Constructor_Should_AllowNullOptionalFields() + { + // Arrange + var payload = new { action = "test" }; + + // Act + var envelope = new AuditEnvelope( + TestId, + TestOccurredAt, + TestReceivedAt, + AuditEventType.Activity, + AuditSeverity.Information, + tenantId: null, + userId: null, + userName: null, + traceId: null, + spanId: null, + correlationId: null, + requestId: null, + source: null, + AuditTag.None, + payload); + + // Assert + envelope.TenantId.ShouldBeNull(); + envelope.UserId.ShouldBeNull(); + envelope.UserName.ShouldBeNull(); + envelope.CorrelationId.ShouldBeNull(); + envelope.RequestId.ShouldBeNull(); + envelope.Source.ShouldBeNull(); + // TraceId and SpanId may be populated from Activity.Current if null + } + + [Fact] + public void Constructor_Should_AcceptAllEventTypes() + { + // Arrange + var payload = new { action = "test" }; + + foreach (var eventType in Enum.GetValues()) + { + // Act + var envelope = new AuditEnvelope( + Guid.NewGuid(), + TestOccurredAt, + TestReceivedAt, + eventType, + AuditSeverity.Information, + null, null, null, null, null, null, null, null, + AuditTag.None, + payload); + + // Assert + envelope.EventType.ShouldBe(eventType); + } + } + + [Fact] + public void Constructor_Should_AcceptAllSeverityLevels() + { + // Arrange + var payload = new { action = "test" }; + + foreach (var severity in Enum.GetValues()) + { + // Act + var envelope = new AuditEnvelope( + Guid.NewGuid(), + TestOccurredAt, + TestReceivedAt, + AuditEventType.Activity, + severity, + null, null, null, null, null, null, null, null, + AuditTag.None, + payload); + + // Assert + envelope.Severity.ShouldBe(severity); + } + } + + [Fact] + public void Constructor_Should_AcceptCombinedTags() + { + // Arrange + var payload = new { action = "test" }; + var combinedTags = AuditTag.PiiMasked | AuditTag.Authentication | AuditTag.RetainedLong; + + // Act + var envelope = new AuditEnvelope( + TestId, + TestOccurredAt, + TestReceivedAt, + AuditEventType.Security, + AuditSeverity.Information, + null, null, null, null, null, null, null, null, + combinedTags, + payload); + + // Assert + envelope.Tags.ShouldBe(combinedTags); + envelope.Tags.HasFlag(AuditTag.PiiMasked).ShouldBeTrue(); + envelope.Tags.HasFlag(AuditTag.Authentication).ShouldBeTrue(); + envelope.Tags.HasFlag(AuditTag.RetainedLong).ShouldBeTrue(); + envelope.Tags.HasFlag(AuditTag.HealthCheck).ShouldBeFalse(); + } + + [Fact] + public void Constructor_Should_AcceptComplexPayload() + { + // Arrange + var complexPayload = new + { + Users = new[] + { + new { Id = 1, Name = "John" }, + new { Id = 2, Name = "Jane" } + }, + Metadata = new Dictionary + { + ["key1"] = "value1", + ["key2"] = 123 + }, + Timestamp = DateTime.UtcNow + }; + + // Act + var envelope = new AuditEnvelope( + TestId, + TestOccurredAt, + TestReceivedAt, + AuditEventType.EntityChange, + AuditSeverity.Information, + null, null, null, null, null, null, null, null, + AuditTag.None, + complexPayload); + + // Assert + envelope.Payload.ShouldBe(complexPayload); + } +} diff --git a/src/Tests/Auditing.Tests/Contracts/ExceptionSeverityClassifierTests.cs b/src/Tests/Auditing.Tests/Contracts/ExceptionSeverityClassifierTests.cs new file mode 100644 index 0000000000..7425909bf6 --- /dev/null +++ b/src/Tests/Auditing.Tests/Contracts/ExceptionSeverityClassifierTests.cs @@ -0,0 +1,144 @@ +using FSH.Modules.Auditing.Contracts; + +namespace Auditing.Tests.Contracts; + +/// +/// Tests for ExceptionSeverityClassifier - maps exception types to audit severity levels. +/// +public sealed class ExceptionSeverityClassifierTests +{ + [Fact] + public void Classify_Should_ReturnInformation_For_OperationCanceledException() + { + // Arrange + var exception = new OperationCanceledException(); + + // Act + var result = ExceptionSeverityClassifier.Classify(exception); + + // Assert + result.ShouldBe(AuditSeverity.Information); + } + + [Fact] + public void Classify_Should_ReturnInformation_For_TaskCanceledException() + { + // Arrange + var exception = new TaskCanceledException(); + + // Act + var result = ExceptionSeverityClassifier.Classify(exception); + + // Assert + // TaskCanceledException inherits from OperationCanceledException + result.ShouldBe(AuditSeverity.Information); + } + + [Fact] + public void Classify_Should_ReturnWarning_For_UnauthorizedAccessException() + { + // Arrange + var exception = new UnauthorizedAccessException(); + + // Act + var result = ExceptionSeverityClassifier.Classify(exception); + + // Assert + result.ShouldBe(AuditSeverity.Warning); + } + + [Fact] + public void Classify_Should_ReturnError_For_ArgumentException() + { + // Arrange + var exception = new ArgumentException("Invalid argument"); + + // Act + var result = ExceptionSeverityClassifier.Classify(exception); + + // Assert + result.ShouldBe(AuditSeverity.Error); + } + + [Fact] + public void Classify_Should_ReturnError_For_InvalidOperationException() + { + // Arrange + var exception = new InvalidOperationException("Invalid operation"); + + // Act + var result = ExceptionSeverityClassifier.Classify(exception); + + // Assert + result.ShouldBe(AuditSeverity.Error); + } + + [Fact] + public void Classify_Should_ReturnError_For_NullReferenceException() + { + // Arrange + var exception = new NullReferenceException(); + + // Act + var result = ExceptionSeverityClassifier.Classify(exception); + + // Assert + result.ShouldBe(AuditSeverity.Error); + } + + [Fact] + public void Classify_Should_ReturnError_For_GenericException() + { + // Arrange + var exception = new Exception("Generic error"); + + // Act + var result = ExceptionSeverityClassifier.Classify(exception); + + // Assert + result.ShouldBe(AuditSeverity.Error); + } + + [Fact] + public void Classify_Should_ReturnError_For_IOException() + { + // Arrange + var exception = new IOException("IO error"); + + // Act + var result = ExceptionSeverityClassifier.Classify(exception); + + // Assert + result.ShouldBe(AuditSeverity.Error); + } + + [Fact] + public void Classify_Should_ReturnError_For_TimeoutException() + { + // Arrange + var exception = new TimeoutException("Operation timed out"); + + // Act + var result = ExceptionSeverityClassifier.Classify(exception); + + // Assert + result.ShouldBe(AuditSeverity.Error); + } + + [Fact] + public void Classify_Should_ReturnInformation_For_DerivedOperationCanceledException() + { + // Arrange - Custom exception derived from OperationCanceledException + var exception = new CustomCanceledException(); + + // Act + var result = ExceptionSeverityClassifier.Classify(exception); + + // Assert + result.ShouldBe(AuditSeverity.Information); + } + + private sealed class CustomCanceledException : OperationCanceledException + { + } +} diff --git a/src/Tests/Auditing.Tests/GlobalUsings.cs b/src/Tests/Auditing.Tests/GlobalUsings.cs new file mode 100644 index 0000000000..3a6ad15e89 --- /dev/null +++ b/src/Tests/Auditing.Tests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Shouldly; +global using Xunit; diff --git a/src/Tests/Auditing.Tests/Http/ContentTypeHelperTests.cs b/src/Tests/Auditing.Tests/Http/ContentTypeHelperTests.cs new file mode 100644 index 0000000000..ee1019899a --- /dev/null +++ b/src/Tests/Auditing.Tests/Http/ContentTypeHelperTests.cs @@ -0,0 +1,204 @@ +using System.Reflection; + +namespace Auditing.Tests.Http; + +/// +/// Tests for ContentTypeHelper - determines if content types are JSON-like for body capture. +/// +public sealed class ContentTypeHelperTests +{ + private static readonly HashSet DefaultAllowedTypes = new(StringComparer.OrdinalIgnoreCase) + { + "application/json", + "application/problem+json", + "text/json" + }; + + // Use reflection to access internal static class + private static bool IsJsonLike(string? contentType, ISet allowed) + { + var assembly = typeof(FSH.Modules.Auditing.AuditingModule).Assembly; + var helperType = assembly.GetType("FSH.Modules.Auditing.ContentTypeHelper"); + helperType.ShouldNotBeNull("ContentTypeHelper type should exist"); + + var method = helperType.GetMethod("IsJsonLike", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); + method.ShouldNotBeNull("IsJsonLike method should exist"); + + return (bool)method.Invoke(null, [contentType, allowed])!; + } + + [Fact] + public void IsJsonLike_Should_ReturnFalse_When_ContentTypeIsNull() + { + // Act + var result = IsJsonLike(null, DefaultAllowedTypes); + + // Assert + result.ShouldBeFalse(); + } + + [Fact] + public void IsJsonLike_Should_ReturnFalse_When_ContentTypeIsEmpty() + { + // Act + var result = IsJsonLike(string.Empty, DefaultAllowedTypes); + + // Assert + result.ShouldBeFalse(); + } + + [Fact] + public void IsJsonLike_Should_ReturnFalse_When_ContentTypeIsWhitespace() + { + // Act + var result = IsJsonLike(" ", DefaultAllowedTypes); + + // Assert + result.ShouldBeFalse(); + } + + [Fact] + public void IsJsonLike_Should_ReturnTrue_For_ApplicationJson() + { + // Act + var result = IsJsonLike("application/json", DefaultAllowedTypes); + + // Assert + result.ShouldBeTrue(); + } + + [Fact] + public void IsJsonLike_Should_ReturnTrue_For_ApplicationProblemJson() + { + // Act + var result = IsJsonLike("application/problem+json", DefaultAllowedTypes); + + // Assert + result.ShouldBeTrue(); + } + + [Fact] + public void IsJsonLike_Should_ReturnTrue_For_TextJson() + { + // Act + var result = IsJsonLike("text/json", DefaultAllowedTypes); + + // Assert + result.ShouldBeTrue(); + } + + [Fact] + public void IsJsonLike_Should_IgnoreCharset_In_ContentType() + { + // Act + var result = IsJsonLike("application/json; charset=utf-8", DefaultAllowedTypes); + + // Assert + result.ShouldBeTrue(); + } + + [Fact] + public void IsJsonLike_Should_IgnoreMultipleParameters_In_ContentType() + { + // Act + var result = IsJsonLike("application/json; charset=utf-8; boundary=something", DefaultAllowedTypes); + + // Assert + result.ShouldBeTrue(); + } + + [Fact] + public void IsJsonLike_Should_ReturnFalse_For_TextHtml() + { + // Act + var result = IsJsonLike("text/html", DefaultAllowedTypes); + + // Assert + result.ShouldBeFalse(); + } + + [Fact] + public void IsJsonLike_Should_ReturnFalse_For_TextPlain() + { + // Act + var result = IsJsonLike("text/plain", DefaultAllowedTypes); + + // Assert + result.ShouldBeFalse(); + } + + [Fact] + public void IsJsonLike_Should_ReturnFalse_For_ApplicationXml() + { + // Act + var result = IsJsonLike("application/xml", DefaultAllowedTypes); + + // Assert + result.ShouldBeFalse(); + } + + [Fact] + public void IsJsonLike_Should_ReturnFalse_For_MultipartFormData() + { + // Act + var result = IsJsonLike("multipart/form-data", DefaultAllowedTypes); + + // Assert + result.ShouldBeFalse(); + } + + [Fact] + public void IsJsonLike_Should_ReturnFalse_For_ApplicationOctetStream() + { + // Act + var result = IsJsonLike("application/octet-stream", DefaultAllowedTypes); + + // Assert + result.ShouldBeFalse(); + } + + [Theory] + [InlineData("APPLICATION/JSON")] + [InlineData("Application/Json")] + [InlineData("application/JSON")] + public void IsJsonLike_Should_BeCaseInsensitive(string contentType) + { + // Act + var result = IsJsonLike(contentType, DefaultAllowedTypes); + + // Assert + result.ShouldBeTrue(); + } + + [Fact] + public void IsJsonLike_Should_ReturnTrue_For_CustomAllowedType() + { + // Arrange + var customAllowed = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "application/vnd.api+json" + }; + + // Act + var result = IsJsonLike("application/vnd.api+json", customAllowed); + + // Assert + result.ShouldBeTrue(); + } + + [Fact] + public void IsJsonLike_Should_ReturnFalse_When_TypeNotInAllowedSet() + { + // Arrange + var limitedAllowed = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "application/json" + }; + + // Act - text/json is not in the limited set + var result = IsJsonLike("text/json", limitedAllowed); + + // Assert + result.ShouldBeFalse(); + } +} diff --git a/src/Tests/Auditing.Tests/Serialization/JsonMaskingServiceTests.cs b/src/Tests/Auditing.Tests/Serialization/JsonMaskingServiceTests.cs new file mode 100644 index 0000000000..a4333d1d8e --- /dev/null +++ b/src/Tests/Auditing.Tests/Serialization/JsonMaskingServiceTests.cs @@ -0,0 +1,383 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FSH.Modules.Auditing; + +namespace Auditing.Tests.Serialization; + +/// +/// Tests for JsonMaskingService - security critical functionality +/// that masks sensitive fields before audit persistence. +/// +public sealed class JsonMaskingServiceTests +{ + private readonly JsonMaskingService _sut = new(); + + #region Basic Field Masking + + [Fact] + public void ApplyMasking_Should_Mask_Password_Field() + { + // Arrange + var payload = new { username = "john", password = "secret123" }; + + // Act + var result = _sut.ApplyMasking(payload); + + // Assert + var json = result as JsonNode; + json.ShouldNotBeNull(); + json["password"]?.GetValue().ShouldBe("****"); + json["username"]?.GetValue().ShouldBe("john"); + } + + [Fact] + public void ApplyMasking_Should_Mask_Secret_Field() + { + // Arrange + var payload = new { apiSecret = "abc123", name = "test" }; + + // Act + var result = _sut.ApplyMasking(payload); + + // Assert + var json = result as JsonNode; + json.ShouldNotBeNull(); + json["apiSecret"]?.GetValue().ShouldBe("****"); + json["name"]?.GetValue().ShouldBe("test"); + } + + [Fact] + public void ApplyMasking_Should_Mask_Token_Field() + { + // Arrange + var payload = new { token = "jwt-token-value", userId = "user1" }; + + // Act + var result = _sut.ApplyMasking(payload); + + // Assert + var json = result as JsonNode; + json.ShouldNotBeNull(); + json["token"]?.GetValue().ShouldBe("****"); + } + + [Fact] + public void ApplyMasking_Should_Mask_Otp_Field() + { + // Arrange + var payload = new { otp = "123456", email = "test@example.com" }; + + // Act + var result = _sut.ApplyMasking(payload); + + // Assert + var json = result as JsonNode; + json.ShouldNotBeNull(); + json["otp"]?.GetValue().ShouldBe("****"); + json["email"]?.GetValue().ShouldBe("test@example.com"); + } + + [Fact] + public void ApplyMasking_Should_Mask_Pin_Field() + { + // Arrange + var payload = new { pin = "1234", accountNumber = "ACC123" }; + + // Act + var result = _sut.ApplyMasking(payload); + + // Assert + var json = result as JsonNode; + json.ShouldNotBeNull(); + json["pin"]?.GetValue().ShouldBe("****"); + } + + [Fact] + public void ApplyMasking_Should_Mask_AccessToken_Field() + { + // Arrange + var payload = new { accessToken = "access-token-value", scope = "read" }; + + // Act + var result = _sut.ApplyMasking(payload); + + // Assert + var json = result as JsonNode; + json.ShouldNotBeNull(); + json["accessToken"]?.GetValue().ShouldBe("****"); + json["scope"]?.GetValue().ShouldBe("read"); + } + + [Fact] + public void ApplyMasking_Should_Mask_RefreshToken_Field() + { + // Arrange + var payload = new { refreshToken = "refresh-token-value", expiresIn = 3600 }; + + // Act + var result = _sut.ApplyMasking(payload); + + // Assert + var json = result as JsonNode; + json.ShouldNotBeNull(); + json["refreshToken"]?.GetValue().ShouldBe("****"); + json["expiresIn"]?.GetValue().ShouldBe(3600); + } + + #endregion + + #region Case Insensitivity + + [Theory] + [InlineData("PASSWORD")] + [InlineData("Password")] + [InlineData("password")] + [InlineData("passWord")] + public void ApplyMasking_Should_Mask_Password_CaseInsensitive(string fieldName) + { + // Arrange + var payload = new Dictionary { [fieldName] = "secret123" }; + + // Act + var result = _sut.ApplyMasking(payload); + + // Assert + var json = result as JsonNode; + json.ShouldNotBeNull(); + json[fieldName]?.GetValue().ShouldBe("****"); + } + + [Fact] + public void ApplyMasking_Should_Mask_PartialMatch_UserPassword() + { + // Arrange + var payload = new { userPassword = "secret123", userId = "user1" }; + + // Act + var result = _sut.ApplyMasking(payload); + + // Assert + var json = result as JsonNode; + json.ShouldNotBeNull(); + json["userPassword"]?.GetValue().ShouldBe("****"); + json["userId"]?.GetValue().ShouldBe("user1"); + } + + [Fact] + public void ApplyMasking_Should_Mask_PartialMatch_ClientSecret() + { + // Arrange + var payload = new { clientSecret = "secret-value", clientId = "client1" }; + + // Act + var result = _sut.ApplyMasking(payload); + + // Assert + var json = result as JsonNode; + json.ShouldNotBeNull(); + json["clientSecret"]?.GetValue().ShouldBe("****"); + json["clientId"]?.GetValue().ShouldBe("client1"); + } + + #endregion + + #region Nested Object Masking + + [Fact] + public void ApplyMasking_Should_Mask_NestedObject_Password() + { + // Arrange + var payload = new + { + user = new { name = "john", password = "secret123" } + }; + + // Act + var result = _sut.ApplyMasking(payload); + + // Assert + var json = result as JsonNode; + json.ShouldNotBeNull(); + json["user"]?["password"]?.GetValue().ShouldBe("****"); + json["user"]?["name"]?.GetValue().ShouldBe("john"); + } + + [Fact] + public void ApplyMasking_Should_Mask_DeeplyNested_Token() + { + // Arrange + var payload = new + { + auth = new + { + credentials = new + { + token = "deep-token-value", + type = "bearer" + } + } + }; + + // Act + var result = _sut.ApplyMasking(payload); + + // Assert + var json = result as JsonNode; + json.ShouldNotBeNull(); + json["auth"]?["credentials"]?["token"]?.GetValue().ShouldBe("****"); + json["auth"]?["credentials"]?["type"]?.GetValue().ShouldBe("bearer"); + } + + [Fact] + public void ApplyMasking_Should_Mask_Array_Elements_With_Password() + { + // Arrange + var payload = new + { + users = new[] + { + new { name = "john", password = "pass1" }, + new { name = "jane", password = "pass2" } + } + }; + + // Act + var result = _sut.ApplyMasking(payload); + + // Assert + var json = result as JsonNode; + json.ShouldNotBeNull(); + var users = json["users"] as JsonArray; + users.ShouldNotBeNull(); + users[0]?["password"]?.GetValue().ShouldBe("****"); + users[1]?["password"]?.GetValue().ShouldBe("****"); + users[0]?["name"]?.GetValue().ShouldBe("john"); + users[1]?["name"]?.GetValue().ShouldBe("jane"); + } + + #endregion + + #region Edge Cases + + [Fact] + public void ApplyMasking_Should_ReturnOriginal_When_Null() + { + // Arrange + object? payload = null; + + // Act + var result = _sut.ApplyMasking(payload!); + + // Assert + result.ShouldBeNull(); + } + + [Fact] + public void ApplyMasking_Should_NotMask_UnrelatedFields() + { + // Arrange + var payload = new + { + username = "john", + email = "john@example.com", + age = 30, + isActive = true + }; + + // Act + var result = _sut.ApplyMasking(payload); + + // Assert + var json = result as JsonNode; + json.ShouldNotBeNull(); + json["username"]?.GetValue().ShouldBe("john"); + json["email"]?.GetValue().ShouldBe("john@example.com"); + json["age"]?.GetValue().ShouldBe(30); + json["isActive"]?.GetValue().ShouldBeTrue(); + } + + [Fact] + public void ApplyMasking_Should_Handle_EmptyObject() + { + // Arrange + var payload = new { }; + + // Act + var result = _sut.ApplyMasking(payload); + + // Assert + var json = result as JsonNode; + json.ShouldNotBeNull(); + } + + [Fact] + public void ApplyMasking_Should_Handle_EmptyArray() + { + // Arrange + var payload = new { items = Array.Empty() }; + + // Act + var result = _sut.ApplyMasking(payload); + + // Assert + var json = result as JsonNode; + json.ShouldNotBeNull(); + var items = json["items"] as JsonArray; + items.ShouldNotBeNull(); + items.Count.ShouldBe(0); + } + + [Fact] + public void ApplyMasking_Should_Handle_MixedTypes_InArray() + { + // Arrange + var payload = new + { + data = new object[] { "string", 123, true, new { password = "secret" } } + }; + + // Act + var result = _sut.ApplyMasking(payload); + + // Assert + var json = result as JsonNode; + json.ShouldNotBeNull(); + var data = json["data"] as JsonArray; + data.ShouldNotBeNull(); + data[3]?["password"]?.GetValue().ShouldBe("****"); + } + + [Fact] + public void ApplyMasking_Should_Mask_AllSensitiveFields_InSingleObject() + { + // Arrange + var payload = new + { + password = "pass", + secret = "sec", + token = "tok", + otp = "123", + pin = "456", + accessToken = "at", + refreshToken = "rt", + normalField = "normal" + }; + + // Act + var result = _sut.ApplyMasking(payload); + + // Assert + var json = result as JsonNode; + json.ShouldNotBeNull(); + json["password"]?.GetValue().ShouldBe("****"); + json["secret"]?.GetValue().ShouldBe("****"); + json["token"]?.GetValue().ShouldBe("****"); + json["otp"]?.GetValue().ShouldBe("****"); + json["pin"]?.GetValue().ShouldBe("****"); + json["accessToken"]?.GetValue().ShouldBe("****"); + json["refreshToken"]?.GetValue().ShouldBe("****"); + json["normalField"]?.GetValue().ShouldBe("normal"); + } + + #endregion +} diff --git a/src/Tests/Generic.Tests/Architecture/HandlerArchitectureTests.cs b/src/Tests/Generic.Tests/Architecture/HandlerArchitectureTests.cs new file mode 100644 index 0000000000..0c5301421b --- /dev/null +++ b/src/Tests/Generic.Tests/Architecture/HandlerArchitectureTests.cs @@ -0,0 +1,225 @@ +using System.Reflection; +using System.Text; +using Mediator; + +namespace Generic.Tests.Architecture; + +/// +/// Architecture tests to ensure all handlers follow consistent patterns +/// across all modules (null checks, naming conventions, etc.). +/// +public sealed class HandlerArchitectureTests +{ + private static readonly Assembly[] ModuleAssemblies = + [ + typeof(FSH.Modules.Auditing.AuditingModule).Assembly, + typeof(FSH.Modules.Identity.IdentityModule).Assembly, + typeof(FSH.Modules.Multitenancy.MultitenancyModule).Assembly + ]; + + [Fact] + public void QueryHandlers_Should_FollowNamingConvention() + { + // Arrange + var failures = new List(); + + foreach (var assembly in ModuleAssemblies) + { + var handlerTypes = assembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract) + .Where(t => t.GetInterfaces().Any(i => + i.IsGenericType && + i.GetGenericTypeDefinition() == typeof(IQueryHandler<,>))); + + foreach (var handlerType in handlerTypes) + { + if (!handlerType.Name.EndsWith("QueryHandler", StringComparison.Ordinal)) + { + failures.Add($"{handlerType.FullName} should end with 'QueryHandler'"); + } + } + } + + // Assert + failures.ShouldBeEmpty(BuildFailureMessage(failures)); + } + + [Fact] + public void CommandHandlers_Should_FollowNamingConvention() + { + // Arrange + var failures = new List(); + + foreach (var assembly in ModuleAssemblies) + { + var handlerTypes = assembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract) + .Where(t => t.GetInterfaces().Any(i => + i.IsGenericType && + (i.GetGenericTypeDefinition() == typeof(ICommandHandler<>) || + i.GetGenericTypeDefinition() == typeof(ICommandHandler<,>)))); + + foreach (var handlerType in handlerTypes) + { + if (!handlerType.Name.EndsWith("CommandHandler", StringComparison.Ordinal)) + { + failures.Add($"{handlerType.FullName} should end with 'CommandHandler'"); + } + } + } + + // Assert + failures.ShouldBeEmpty(BuildFailureMessage(failures)); + } + + [Fact] + public void Handlers_Should_BeSealed() + { + // Arrange + var failures = new List(); + + foreach (var assembly in ModuleAssemblies) + { + var handlerTypes = assembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract) + .Where(t => t.GetInterfaces().Any(i => + i.IsGenericType && + (i.GetGenericTypeDefinition() == typeof(IQueryHandler<,>) || + i.GetGenericTypeDefinition() == typeof(ICommandHandler<>) || + i.GetGenericTypeDefinition() == typeof(ICommandHandler<,>)))); + + foreach (var handlerType in handlerTypes) + { + if (!handlerType.IsSealed) + { + failures.Add($"{handlerType.FullName} should be sealed"); + } + } + } + + // Assert + failures.ShouldBeEmpty(BuildFailureMessage(failures)); + } + + [Fact] + public void Handlers_Should_HaveHandleMethod_WithCancellationToken() + { + // Arrange + var failures = new List(); + + foreach (var assembly in ModuleAssemblies) + { + var handlerTypes = assembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract) + .Where(t => t.GetInterfaces().Any(i => + i.IsGenericType && + (i.GetGenericTypeDefinition() == typeof(IQueryHandler<,>) || + i.GetGenericTypeDefinition() == typeof(ICommandHandler<>) || + i.GetGenericTypeDefinition() == typeof(ICommandHandler<,>)))); + + foreach (var handlerType in handlerTypes) + { + var handleMethods = handlerType.GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(m => m.Name == "Handle"); + + foreach (var method in handleMethods) + { + var parameters = method.GetParameters(); + var hasCancellationToken = parameters.Any(p => p.ParameterType == typeof(CancellationToken)); + + if (!hasCancellationToken) + { + failures.Add($"{handlerType.FullName}.Handle() should have CancellationToken parameter"); + } + } + } + } + + // Assert + failures.ShouldBeEmpty(BuildFailureMessage(failures)); + } + + [Fact] + public void Validators_Should_FollowNamingConvention() + { + // Arrange + var failures = new List(); + + foreach (var assembly in ModuleAssemblies) + { + var validatorTypes = assembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract) + .Where(t => t.BaseType != null && + t.BaseType.IsGenericType && + t.BaseType.GetGenericTypeDefinition().Name.Contains("AbstractValidator", StringComparison.Ordinal)); + + foreach (var validatorType in validatorTypes) + { + if (!validatorType.Name.EndsWith("Validator", StringComparison.Ordinal)) + { + failures.Add($"{validatorType.FullName} should end with 'Validator'"); + } + } + } + + // Assert + failures.ShouldBeEmpty(BuildFailureMessage(failures)); + } + + [Fact] + public void Validators_Should_BeSealed() + { + // Arrange + var failures = new List(); + + foreach (var assembly in ModuleAssemblies) + { + var validatorTypes = assembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract) + .Where(t => t.BaseType != null && + t.BaseType.IsGenericType && + t.BaseType.GetGenericTypeDefinition().Name.Contains("AbstractValidator", StringComparison.Ordinal)); + + foreach (var validatorType in validatorTypes) + { + // Skip partial classes (e.g., UpdateTenantThemeCommandValidator uses partial for source-generated regex) + // Partial classes cannot be sealed, but their nested validators are sealed + if (IsPartialClass(validatorType)) + { + continue; + } + + if (!validatorType.IsSealed) + { + failures.Add($"{validatorType.FullName} should be sealed"); + } + } + } + + // Assert + failures.ShouldBeEmpty(BuildFailureMessage(failures)); + } + + private static bool IsPartialClass(Type type) + { + // Partial classes that use source generators (like GeneratedRegex) will have + // compiler-generated nested types or methods. We check for the presence of + // GeneratedRegex attribute on any method as an indicator. + return type.GetMethods(BindingFlags.NonPublic | BindingFlags.Static) + .Any(m => m.GetCustomAttributes() + .Any(a => a.GetType().Name == "GeneratedRegexAttribute")); + } + + private static string BuildFailureMessage(List failures) + { + if (failures.Count == 0) return string.Empty; + + var sb = new StringBuilder(); + sb.Append("Found ").Append(failures.Count).AppendLine(" violation(s):"); + foreach (var failure in failures) + { + sb.Append(" - ").AppendLine(failure); + } + return sb.ToString(); + } +} diff --git a/src/Tests/Generic.Tests/Generic.Tests.csproj b/src/Tests/Generic.Tests/Generic.Tests.csproj new file mode 100644 index 0000000000..6b28c1e96a --- /dev/null +++ b/src/Tests/Generic.Tests/Generic.Tests.csproj @@ -0,0 +1,34 @@ + + + net10.0 + false + false + enable + enable + $(NoWarn);CA1515;CA1861;CA1707 + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/src/Tests/Generic.Tests/GlobalUsings.cs b/src/Tests/Generic.Tests/GlobalUsings.cs new file mode 100644 index 0000000000..1677544c11 --- /dev/null +++ b/src/Tests/Generic.Tests/GlobalUsings.cs @@ -0,0 +1,3 @@ +global using FluentValidation; +global using Shouldly; +global using Xunit; diff --git a/src/Tests/Generic.Tests/Validators/DateRangeValidatorTests.cs b/src/Tests/Generic.Tests/Validators/DateRangeValidatorTests.cs new file mode 100644 index 0000000000..49f0ff7f9a --- /dev/null +++ b/src/Tests/Generic.Tests/Validators/DateRangeValidatorTests.cs @@ -0,0 +1,276 @@ +using FSH.Modules.Auditing.Contracts.v1.GetAudits; +using FSH.Modules.Auditing.Contracts.v1.GetAuditsByCorrelation; +using FSH.Modules.Auditing.Contracts.v1.GetAuditsByTrace; +using FSH.Modules.Auditing.Contracts.v1.GetAuditSummary; +using FSH.Modules.Auditing.Contracts.v1.GetExceptionAudits; +using FSH.Modules.Auditing.Contracts.v1.GetSecurityAudits; +using FSH.Modules.Auditing.Features.v1.GetAudits; +using FSH.Modules.Auditing.Features.v1.GetAuditsByCorrelation; +using FSH.Modules.Auditing.Features.v1.GetAuditsByTrace; +using FSH.Modules.Auditing.Features.v1.GetAuditSummary; +using FSH.Modules.Auditing.Features.v1.GetExceptionAudits; +using FSH.Modules.Auditing.Features.v1.GetSecurityAudits; + +namespace Generic.Tests.Validators; + +/// +/// Tests for generic date range validation rules (FromUtc less than or equal to ToUtc) +/// that are shared across queries with date filtering. +/// +public sealed class DateRangeValidatorTests +{ + private static readonly DateTime BaseDate = new(2024, 1, 15, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void DateRange_Should_Pass_When_BothNull_GetAudits() + { + // Arrange + var validator = new GetAuditsQueryValidator(); + var query = new GetAuditsQuery { FromUtc = null, ToUtc = null }; + + // Act + var result = validator.Validate(query); + + // Assert + result.IsValid.ShouldBeTrue(); + } + + [Fact] + public void DateRange_Should_Pass_When_BothNull_GetAuditsByCorrelation() + { + // Arrange + var validator = new GetAuditsByCorrelationQueryValidator(); + var query = new GetAuditsByCorrelationQuery { CorrelationId = "test-id", FromUtc = null, ToUtc = null }; + + // Act + var result = validator.Validate(query); + + // Assert + result.IsValid.ShouldBeTrue(); + } + + [Fact] + public void DateRange_Should_Pass_When_BothNull_GetAuditsByTrace() + { + // Arrange + var validator = new GetAuditsByTraceQueryValidator(); + var query = new GetAuditsByTraceQuery { TraceId = "test-trace", FromUtc = null, ToUtc = null }; + + // Act + var result = validator.Validate(query); + + // Assert + result.IsValid.ShouldBeTrue(); + } + + [Fact] + public void DateRange_Should_Pass_When_BothNull_GetAuditSummary() + { + // Arrange + var validator = new GetAuditSummaryQueryValidator(); + var query = new GetAuditSummaryQuery { FromUtc = null, ToUtc = null }; + + // Act + var result = validator.Validate(query); + + // Assert + result.IsValid.ShouldBeTrue(); + } + + [Fact] + public void DateRange_Should_Pass_When_OnlyFromUtcSet_GetAudits() + { + // Arrange + var validator = new GetAuditsQueryValidator(); + var query = new GetAuditsQuery { FromUtc = BaseDate, ToUtc = null }; + + // Act + var result = validator.Validate(query); + + // Assert + result.IsValid.ShouldBeTrue(); + } + + [Fact] + public void DateRange_Should_Pass_When_OnlyToUtcSet_GetAudits() + { + // Arrange + var validator = new GetAuditsQueryValidator(); + var query = new GetAuditsQuery { FromUtc = null, ToUtc = BaseDate }; + + // Act + var result = validator.Validate(query); + + // Assert + result.IsValid.ShouldBeTrue(); + } + + [Fact] + public void DateRange_Should_Pass_When_FromUtcEqualsToUtc_GetAudits() + { + // Arrange + var validator = new GetAuditsQueryValidator(); + var query = new GetAuditsQuery { FromUtc = BaseDate, ToUtc = BaseDate }; + + // Act + var result = validator.Validate(query); + + // Assert + result.IsValid.ShouldBeTrue(); + } + + [Fact] + public void DateRange_Should_Pass_When_FromUtcBeforeToUtc_GetAudits() + { + // Arrange + var validator = new GetAuditsQueryValidator(); + var query = new GetAuditsQuery + { + FromUtc = BaseDate, + ToUtc = BaseDate.AddDays(7) + }; + + // Act + var result = validator.Validate(query); + + // Assert + result.IsValid.ShouldBeTrue(); + } + + [Fact] + public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAudits() + { + // Arrange + var validator = new GetAuditsQueryValidator(); + var query = new GetAuditsQuery + { + FromUtc = BaseDate.AddDays(7), + ToUtc = BaseDate + }; + + // Act + var result = validator.Validate(query); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.ErrorMessage.Contains("FromUtc must be less than or equal to ToUtc")); + } + + [Fact] + public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAuditsByCorrelation() + { + // Arrange + var validator = new GetAuditsByCorrelationQueryValidator(); + var query = new GetAuditsByCorrelationQuery + { + CorrelationId = "test-id", + FromUtc = BaseDate.AddDays(7), + ToUtc = BaseDate + }; + + // Act + var result = validator.Validate(query); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.ErrorMessage.Contains("FromUtc must be less than or equal to ToUtc")); + } + + [Fact] + public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAuditsByTrace() + { + // Arrange + var validator = new GetAuditsByTraceQueryValidator(); + var query = new GetAuditsByTraceQuery + { + TraceId = "test-trace", + FromUtc = BaseDate.AddDays(7), + ToUtc = BaseDate + }; + + // Act + var result = validator.Validate(query); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.ErrorMessage.Contains("FromUtc must be less than or equal to ToUtc")); + } + + [Fact] + public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAuditSummary() + { + // Arrange + var validator = new GetAuditSummaryQueryValidator(); + var query = new GetAuditSummaryQuery + { + FromUtc = BaseDate.AddDays(7), + ToUtc = BaseDate + }; + + // Act + var result = validator.Validate(query); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.ErrorMessage.Contains("FromUtc must be less than or equal to ToUtc")); + } + + [Fact] + public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetExceptionAudits() + { + // Arrange + var validator = new GetExceptionAuditsQueryValidator(); + var query = new GetExceptionAuditsQuery + { + FromUtc = BaseDate.AddDays(7), + ToUtc = BaseDate + }; + + // Act + var result = validator.Validate(query); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.ErrorMessage.Contains("FromUtc must be less than or equal to ToUtc")); + } + + [Fact] + public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetSecurityAudits() + { + // Arrange + var validator = new GetSecurityAuditsQueryValidator(); + var query = new GetSecurityAuditsQuery + { + FromUtc = BaseDate.AddDays(7), + ToUtc = BaseDate + }; + + // Act + var result = validator.Validate(query); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.ErrorMessage.Contains("FromUtc must be less than or equal to ToUtc")); + } + + [Theory] + [InlineData(1)] // 1 second apart + [InlineData(60)] // 1 minute apart + [InlineData(3600)] // 1 hour apart + public void DateRange_Should_Pass_When_FromUtcSlightlyBeforeToUtc(int secondsDiff) + { + // Arrange + var validator = new GetAuditsQueryValidator(); + var query = new GetAuditsQuery + { + FromUtc = BaseDate, + ToUtc = BaseDate.AddSeconds(secondsDiff) + }; + + // Act + var result = validator.Validate(query); + + // Assert + result.IsValid.ShouldBeTrue(); + } +} diff --git a/src/Tests/Generic.Tests/Validators/PagedQueryValidatorTests.cs b/src/Tests/Generic.Tests/Validators/PagedQueryValidatorTests.cs new file mode 100644 index 0000000000..261466f519 --- /dev/null +++ b/src/Tests/Generic.Tests/Validators/PagedQueryValidatorTests.cs @@ -0,0 +1,234 @@ +using FSH.Modules.Auditing.Contracts.v1.GetAudits; +using FSH.Modules.Auditing.Features.v1.GetAudits; +using FSH.Modules.Identity.Contracts.v1.Users.SearchUsers; +using FSH.Modules.Identity.Features.v1.Users.SearchUsers; + +namespace Generic.Tests.Validators; + +/// +/// Tests for generic paged query validation rules (PageNumber, PageSize) +/// that are shared across all modules implementing IPagedQuery. +/// +public sealed class PagedQueryValidatorTests +{ + public static TheoryData PagedQueryValidators => new() + { + { new GetAuditsQueryValidator(), new GetAuditsQuery() }, + { new SearchUsersQueryValidator(), new SearchUsersQuery() } + }; + + [Theory] + [MemberData(nameof(PagedQueryValidators))] + public void PageNumber_Should_Pass_When_Null(IValidator validator, object query) + { + // Arrange - PageNumber is null by default + + // Act + var result = validator.Validate(new ValidationContext(query)); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "PageNumber"); + } + + [Fact] + public void PageNumber_Should_Pass_When_GreaterThanZero_Auditing() + { + // Arrange + var validator = new GetAuditsQueryValidator(); + var query = new GetAuditsQuery { PageNumber = 1 }; + + // Act + var result = validator.Validate(query); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "PageNumber"); + } + + [Fact] + public void PageNumber_Should_Pass_When_GreaterThanZero_Identity() + { + // Arrange + var validator = new SearchUsersQueryValidator(); + var query = new SearchUsersQuery { PageNumber = 5 }; + + // Act + var result = validator.Validate(query); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "PageNumber"); + } + + [Fact] + public void PageNumber_Should_Fail_When_Zero_Auditing() + { + // Arrange + var validator = new GetAuditsQueryValidator(); + var query = new GetAuditsQuery { PageNumber = 0 }; + + // Act + var result = validator.Validate(query); + + // Assert + result.Errors.ShouldContain(e => e.PropertyName == "PageNumber"); + } + + [Fact] + public void PageNumber_Should_Fail_When_Zero_Identity() + { + // Arrange + var validator = new SearchUsersQueryValidator(); + var query = new SearchUsersQuery { PageNumber = 0 }; + + // Act + var result = validator.Validate(query); + + // Assert + result.Errors.ShouldContain(e => e.PropertyName == "PageNumber"); + } + + [Fact] + public void PageNumber_Should_Fail_When_Negative_Auditing() + { + // Arrange + var validator = new GetAuditsQueryValidator(); + var query = new GetAuditsQuery { PageNumber = -1 }; + + // Act + var result = validator.Validate(query); + + // Assert + result.Errors.ShouldContain(e => e.PropertyName == "PageNumber"); + } + + [Fact] + public void PageNumber_Should_Fail_When_Negative_Identity() + { + // Arrange + var validator = new SearchUsersQueryValidator(); + var query = new SearchUsersQuery { PageNumber = -5 }; + + // Act + var result = validator.Validate(query); + + // Assert + result.Errors.ShouldContain(e => e.PropertyName == "PageNumber"); + } + + [Fact] + public void PageSize_Should_Pass_When_Null_Auditing() + { + // Arrange + var validator = new GetAuditsQueryValidator(); + var query = new GetAuditsQuery { PageSize = null }; + + // Act + var result = validator.Validate(query); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "PageSize"); + } + + [Fact] + public void PageSize_Should_Pass_When_Null_Identity() + { + // Arrange + var validator = new SearchUsersQueryValidator(); + var query = new SearchUsersQuery { PageSize = null }; + + // Act + var result = validator.Validate(query); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "PageSize"); + } + + [Theory] + [InlineData(1)] + [InlineData(50)] + [InlineData(100)] + public void PageSize_Should_Pass_When_Between1And100_Auditing(int pageSize) + { + // Arrange + var validator = new GetAuditsQueryValidator(); + var query = new GetAuditsQuery { PageSize = pageSize }; + + // Act + var result = validator.Validate(query); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "PageSize"); + } + + [Theory] + [InlineData(1)] + [InlineData(50)] + [InlineData(100)] + public void PageSize_Should_Pass_When_Between1And100_Identity(int pageSize) + { + // Arrange + var validator = new SearchUsersQueryValidator(); + var query = new SearchUsersQuery { PageSize = pageSize }; + + // Act + var result = validator.Validate(query); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "PageSize"); + } + + [Fact] + public void PageSize_Should_Fail_When_Zero_Auditing() + { + // Arrange + var validator = new GetAuditsQueryValidator(); + var query = new GetAuditsQuery { PageSize = 0 }; + + // Act + var result = validator.Validate(query); + + // Assert + result.Errors.ShouldContain(e => e.PropertyName == "PageSize"); + } + + [Fact] + public void PageSize_Should_Fail_When_Zero_Identity() + { + // Arrange + var validator = new SearchUsersQueryValidator(); + var query = new SearchUsersQuery { PageSize = 0 }; + + // Act + var result = validator.Validate(query); + + // Assert + result.Errors.ShouldContain(e => e.PropertyName == "PageSize"); + } + + [Fact] + public void PageSize_Should_Fail_When_GreaterThan100_Auditing() + { + // Arrange + var validator = new GetAuditsQueryValidator(); + var query = new GetAuditsQuery { PageSize = 101 }; + + // Act + var result = validator.Validate(query); + + // Assert + result.Errors.ShouldContain(e => e.PropertyName == "PageSize"); + } + + [Fact] + public void PageSize_Should_Fail_When_GreaterThan100_Identity() + { + // Arrange + var validator = new SearchUsersQueryValidator(); + var query = new SearchUsersQuery { PageSize = 150 }; + + // Act + var result = validator.Validate(query); + + // Assert + result.Errors.ShouldContain(e => e.PropertyName == "PageSize"); + } +} diff --git a/src/Tests/Identity.Tests/Authorization/JwtOptionsTests.cs b/src/Tests/Identity.Tests/Authorization/JwtOptionsTests.cs new file mode 100644 index 0000000000..1587da5f85 --- /dev/null +++ b/src/Tests/Identity.Tests/Authorization/JwtOptionsTests.cs @@ -0,0 +1,235 @@ +using System.ComponentModel.DataAnnotations; +using FSH.Modules.Identity.Authorization.Jwt; + +namespace Identity.Tests.Authorization; + +/// +/// Tests for JwtOptions validation - security critical configuration. +/// +public sealed class JwtOptionsTests +{ + [Fact] + public void Validate_Should_ReturnNoErrors_When_AllFieldsValid() + { + // Arrange + var options = new JwtOptions + { + SigningKey = "ThisIsAVeryLongSecretKeyForJwtSigning", // 40 chars + Issuer = "https://example.com", + Audience = "https://api.example.com" + }; + var context = new ValidationContext(options); + + // Act + var results = options.Validate(context).ToList(); + + // Assert + results.ShouldBeEmpty(); + } + + [Fact] + public void Validate_Should_ReturnError_When_SigningKeyIsEmpty() + { + // Arrange + var options = new JwtOptions + { + SigningKey = string.Empty, + Issuer = "https://example.com", + Audience = "https://api.example.com" + }; + var context = new ValidationContext(options); + + // Act + var results = options.Validate(context).ToList(); + + // Assert + results.ShouldContain(r => r.MemberNames.Contains(nameof(JwtOptions.SigningKey))); + results.ShouldContain(r => r.ErrorMessage!.Contains("No Key defined")); + } + + [Fact] + public void Validate_Should_ReturnError_When_SigningKeyIsNull() + { + // Arrange + var options = new JwtOptions + { + SigningKey = null!, + Issuer = "https://example.com", + Audience = "https://api.example.com" + }; + var context = new ValidationContext(options); + + // Act + var results = options.Validate(context).ToList(); + + // Assert + results.ShouldContain(r => r.MemberNames.Contains(nameof(JwtOptions.SigningKey))); + } + + [Fact] + public void Validate_Should_ReturnError_When_SigningKeyTooShort() + { + // Arrange + var options = new JwtOptions + { + SigningKey = "ShortKey", // Only 8 chars + Issuer = "https://example.com", + Audience = "https://api.example.com" + }; + var context = new ValidationContext(options); + + // Act + var results = options.Validate(context).ToList(); + + // Assert + results.ShouldContain(r => r.MemberNames.Contains(nameof(JwtOptions.SigningKey))); + results.ShouldContain(r => r.ErrorMessage!.Contains("at least 32 characters")); + } + + [Fact] + public void Validate_Should_Pass_When_SigningKeyExactly32Chars() + { + // Arrange + var options = new JwtOptions + { + SigningKey = "12345678901234567890123456789012", // Exactly 32 chars + Issuer = "https://example.com", + Audience = "https://api.example.com" + }; + var context = new ValidationContext(options); + + // Act + var results = options.Validate(context).ToList(); + + // Assert + results.ShouldBeEmpty(); + } + + [Fact] + public void Validate_Should_ReturnError_When_IssuerIsEmpty() + { + // Arrange + var options = new JwtOptions + { + SigningKey = "ThisIsAVeryLongSecretKeyForJwtSigning", + Issuer = string.Empty, + Audience = "https://api.example.com" + }; + var context = new ValidationContext(options); + + // Act + var results = options.Validate(context).ToList(); + + // Assert + results.ShouldContain(r => r.MemberNames.Contains(nameof(JwtOptions.Issuer))); + results.ShouldContain(r => r.ErrorMessage!.Contains("No Issuer defined")); + } + + [Fact] + public void Validate_Should_ReturnError_When_AudienceIsEmpty() + { + // Arrange + var options = new JwtOptions + { + SigningKey = "ThisIsAVeryLongSecretKeyForJwtSigning", + Issuer = "https://example.com", + Audience = string.Empty + }; + var context = new ValidationContext(options); + + // Act + var results = options.Validate(context).ToList(); + + // Assert + results.ShouldContain(r => r.MemberNames.Contains(nameof(JwtOptions.Audience))); + results.ShouldContain(r => r.ErrorMessage!.Contains("No Audience defined")); + } + + [Fact] + public void Validate_Should_ReturnMultipleErrors_When_AllFieldsInvalid() + { + // Arrange + var options = new JwtOptions + { + SigningKey = string.Empty, + Issuer = string.Empty, + Audience = string.Empty + }; + var context = new ValidationContext(options); + + // Act + var results = options.Validate(context).ToList(); + + // Assert + results.Count.ShouldBe(3); + results.ShouldContain(r => r.MemberNames.Contains(nameof(JwtOptions.SigningKey))); + results.ShouldContain(r => r.MemberNames.Contains(nameof(JwtOptions.Issuer))); + results.ShouldContain(r => r.MemberNames.Contains(nameof(JwtOptions.Audience))); + } + + [Fact] + public void DefaultValues_Should_BeSet() + { + // Arrange & Act + var options = new JwtOptions(); + + // Assert + options.AccessTokenMinutes.ShouldBe(30); + options.RefreshTokenDays.ShouldBe(7); + options.Issuer.ShouldBe(string.Empty); + options.Audience.ShouldBe(string.Empty); + options.SigningKey.ShouldBe(string.Empty); + } + + [Theory] + [InlineData(31)] + [InlineData(32)] + [InlineData(64)] + [InlineData(256)] + public void Validate_Should_Pass_When_SigningKeyLengthIsValid(int length) + { + // Arrange + var options = new JwtOptions + { + SigningKey = new string('x', length), + Issuer = "https://example.com", + Audience = "https://api.example.com" + }; + var context = new ValidationContext(options); + + // Act + var results = options.Validate(context).ToList(); + + // Assert + if (length >= 32) + { + results.ShouldBeEmpty(); + } + else + { + results.ShouldNotBeEmpty(); + } + } + + [Theory] + [InlineData(1)] + [InlineData(16)] + [InlineData(31)] + public void Validate_Should_Fail_When_SigningKeyLengthIsInsufficient(int length) + { + // Arrange + var options = new JwtOptions + { + SigningKey = new string('x', length), + Issuer = "https://example.com", + Audience = "https://api.example.com" + }; + var context = new ValidationContext(options); + + // Act + var results = options.Validate(context).ToList(); + + // Assert + results.ShouldContain(r => r.MemberNames.Contains(nameof(JwtOptions.SigningKey))); + } +} diff --git a/src/Tests/Identity.Tests/Data/PasswordPolicyOptionsTests.cs b/src/Tests/Identity.Tests/Data/PasswordPolicyOptionsTests.cs new file mode 100644 index 0000000000..509a88520e --- /dev/null +++ b/src/Tests/Identity.Tests/Data/PasswordPolicyOptionsTests.cs @@ -0,0 +1,108 @@ +using FSH.Modules.Identity.Data; + +namespace Identity.Tests.Data; + +/// +/// Tests for PasswordPolicyOptions - configuration for password policies. +/// +public sealed class PasswordPolicyOptionsTests +{ + [Fact] + public void DefaultValues_Should_BeSecure() + { + // Arrange & Act + var options = new PasswordPolicyOptions(); + + // Assert - Verify secure defaults + options.PasswordHistoryCount.ShouldBe(5); + options.PasswordExpiryDays.ShouldBe(90); + options.PasswordExpiryWarningDays.ShouldBe(14); + options.EnforcePasswordExpiry.ShouldBeTrue(); + } + + [Fact] + public void PasswordHistoryCount_Should_BeSettable() + { + // Arrange + var options = new PasswordPolicyOptions + { + PasswordHistoryCount = 10 + }; + + // Assert + options.PasswordHistoryCount.ShouldBe(10); + } + + [Fact] + public void PasswordExpiryDays_Should_BeSettable() + { + // Arrange + var options = new PasswordPolicyOptions + { + PasswordExpiryDays = 30 + }; + + // Assert + options.PasswordExpiryDays.ShouldBe(30); + } + + [Fact] + public void PasswordExpiryWarningDays_Should_BeSettable() + { + // Arrange + var options = new PasswordPolicyOptions + { + PasswordExpiryWarningDays = 7 + }; + + // Assert + options.PasswordExpiryWarningDays.ShouldBe(7); + } + + [Fact] + public void EnforcePasswordExpiry_Should_BeSettable() + { + // Arrange + var options = new PasswordPolicyOptions + { + EnforcePasswordExpiry = false + }; + + // Assert + options.EnforcePasswordExpiry.ShouldBeFalse(); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(1)] + [InlineData(100)] + public void PasswordHistoryCount_Should_AcceptAnyInteger(int value) + { + // Arrange + var options = new PasswordPolicyOptions + { + PasswordHistoryCount = value + }; + + // Assert + options.PasswordHistoryCount.ShouldBe(value); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(365)] + [InlineData(730)] + public void PasswordExpiryDays_Should_AcceptAnyInteger(int value) + { + // Arrange + var options = new PasswordPolicyOptions + { + PasswordExpiryDays = value + }; + + // Assert + options.PasswordExpiryDays.ShouldBe(value); + } +} diff --git a/src/Tests/Identity.Tests/GlobalUsings.cs b/src/Tests/Identity.Tests/GlobalUsings.cs new file mode 100644 index 0000000000..b37457ed83 --- /dev/null +++ b/src/Tests/Identity.Tests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Xunit; +global using Shouldly; diff --git a/src/Tests/Identity.Tests/Identity.Tests.csproj b/src/Tests/Identity.Tests/Identity.Tests.csproj new file mode 100644 index 0000000000..6b9d2742c9 --- /dev/null +++ b/src/Tests/Identity.Tests/Identity.Tests.csproj @@ -0,0 +1,29 @@ + + + net10.0 + false + false + enable + enable + $(NoWarn);CA1515;CA1861;CA1707 + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/src/Tests/Identity.Tests/Services/CurrentUserServiceTests.cs b/src/Tests/Identity.Tests/Services/CurrentUserServiceTests.cs new file mode 100644 index 0000000000..921f2e6351 --- /dev/null +++ b/src/Tests/Identity.Tests/Services/CurrentUserServiceTests.cs @@ -0,0 +1,411 @@ +using System.Security.Claims; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Shared.Constants; +using FSH.Modules.Identity.Services; + +namespace Identity.Tests.Services; + +/// +/// Tests for CurrentUserService - handles current user context. +/// +public sealed class CurrentUserServiceTests +{ + private static ClaimsPrincipal CreateAuthenticatedPrincipal( + string userId, + string? email = null, + string? name = null, + string? tenant = null, + params string[] roles) + { + var claims = new List + { + new(ClaimTypes.NameIdentifier, userId) + }; + + if (email != null) + claims.Add(new Claim(ClaimTypes.Email, email)); + if (name != null) + claims.Add(new Claim(ClaimTypes.Name, name)); + if (tenant != null) + claims.Add(new Claim(CustomClaims.Tenant, tenant)); + + foreach (var role in roles) + { + claims.Add(new Claim(ClaimTypes.Role, role)); + } + + var identity = new ClaimsIdentity(claims, "TestAuthType"); + return new ClaimsPrincipal(identity); + } + + private static ClaimsPrincipal CreateUnauthenticatedPrincipal() + { + return new ClaimsPrincipal(new ClaimsIdentity()); + } + + #region GetUserId Tests + + [Fact] + public void GetUserId_Should_ReturnGuid_When_Authenticated() + { + // Arrange + var service = new CurrentUserService(); + var userId = Guid.NewGuid(); + var principal = CreateAuthenticatedPrincipal(userId.ToString()); + service.SetCurrentUser(principal); + + // Act + var result = service.GetUserId(); + + // Assert + result.ShouldBe(userId); + } + + [Fact] + public void GetUserId_Should_ReturnStoredId_When_NotAuthenticated() + { + // Arrange + var service = new CurrentUserService(); + var userId = Guid.NewGuid(); + service.SetCurrentUserId(userId.ToString()); + + // Act + var result = service.GetUserId(); + + // Assert + result.ShouldBe(userId); + } + + [Fact] + public void GetUserId_Should_ReturnEmptyGuid_When_NoSource() + { + // Arrange + var service = new CurrentUserService(); + + // Act + var result = service.GetUserId(); + + // Assert + result.ShouldBe(Guid.Empty); + } + + #endregion + + #region GetUserEmail Tests + + [Fact] + public void GetUserEmail_Should_ReturnEmail_When_Authenticated() + { + // Arrange + var service = new CurrentUserService(); + var principal = CreateAuthenticatedPrincipal( + Guid.NewGuid().ToString(), + email: "test@example.com"); + service.SetCurrentUser(principal); + + // Act + var result = service.GetUserEmail(); + + // Assert + result.ShouldBe("test@example.com"); + } + + [Fact] + public void GetUserEmail_Should_ReturnEmpty_When_NotAuthenticated() + { + // Arrange + var service = new CurrentUserService(); + var principal = CreateUnauthenticatedPrincipal(); + service.SetCurrentUser(principal); + + // Act + var result = service.GetUserEmail(); + + // Assert + result.ShouldBe(string.Empty); + } + + #endregion + + #region IsAuthenticated Tests + + [Fact] + public void IsAuthenticated_Should_ReturnTrue_When_UserAuthenticated() + { + // Arrange + var service = new CurrentUserService(); + var principal = CreateAuthenticatedPrincipal(Guid.NewGuid().ToString()); + service.SetCurrentUser(principal); + + // Act + var result = service.IsAuthenticated(); + + // Assert + result.ShouldBeTrue(); + } + + [Fact] + public void IsAuthenticated_Should_ReturnFalse_When_UserNotAuthenticated() + { + // Arrange + var service = new CurrentUserService(); + var principal = CreateUnauthenticatedPrincipal(); + service.SetCurrentUser(principal); + + // Act + var result = service.IsAuthenticated(); + + // Assert + result.ShouldBeFalse(); + } + + [Fact] + public void IsAuthenticated_Should_ReturnFalse_When_NoPrincipalSet() + { + // Arrange + var service = new CurrentUserService(); + + // Act + var result = service.IsAuthenticated(); + + // Assert + result.ShouldBeFalse(); + } + + #endregion + + #region IsInRole Tests + + [Fact] + public void IsInRole_Should_ReturnTrue_When_UserHasRole() + { + // Arrange + var service = new CurrentUserService(); + var principal = CreateAuthenticatedPrincipal( + Guid.NewGuid().ToString(), + roles: ["Admin", "User"]); + service.SetCurrentUser(principal); + + // Act + var result = service.IsInRole("Admin"); + + // Assert + result.ShouldBeTrue(); + } + + [Fact] + public void IsInRole_Should_ReturnFalse_When_UserLacksRole() + { + // Arrange + var service = new CurrentUserService(); + var principal = CreateAuthenticatedPrincipal( + Guid.NewGuid().ToString(), + roles: ["User"]); + service.SetCurrentUser(principal); + + // Act + var result = service.IsInRole("Admin"); + + // Assert + result.ShouldBeFalse(); + } + + [Fact] + public void IsInRole_Should_ReturnFalse_When_NoPrincipal() + { + // Arrange + var service = new CurrentUserService(); + + // Act + var result = service.IsInRole("Admin"); + + // Assert + result.ShouldBeFalse(); + } + + #endregion + + #region GetUserClaims Tests + + [Fact] + public void GetUserClaims_Should_ReturnAllClaims_When_Authenticated() + { + // Arrange + var service = new CurrentUserService(); + var principal = CreateAuthenticatedPrincipal( + Guid.NewGuid().ToString(), + email: "test@example.com", + name: "Test User"); + service.SetCurrentUser(principal); + + // Act + var result = service.GetUserClaims(); + + // Assert + result.ShouldNotBeNull(); + result.ShouldContain(c => c.Type == ClaimTypes.Email && c.Value == "test@example.com"); + result.ShouldContain(c => c.Type == ClaimTypes.Name && c.Value == "Test User"); + } + + [Fact] + public void GetUserClaims_Should_ReturnNull_When_NoPrincipal() + { + // Arrange + var service = new CurrentUserService(); + + // Act + var result = service.GetUserClaims(); + + // Assert + result.ShouldBeNull(); + } + + #endregion + + #region GetTenant Tests + + [Fact] + public void GetTenant_Should_ReturnTenant_When_Authenticated() + { + // Arrange + var service = new CurrentUserService(); + var principal = CreateAuthenticatedPrincipal( + Guid.NewGuid().ToString(), + tenant: "tenant-1"); + service.SetCurrentUser(principal); + + // Act + var result = service.GetTenant(); + + // Assert + result.ShouldBe("tenant-1"); + } + + [Fact] + public void GetTenant_Should_ReturnEmpty_When_NotAuthenticated() + { + // Arrange + var service = new CurrentUserService(); + var principal = CreateUnauthenticatedPrincipal(); + service.SetCurrentUser(principal); + + // Act + var result = service.GetTenant(); + + // Assert + result.ShouldBe(string.Empty); + } + + #endregion + + #region SetCurrentUser Tests + + [Fact] + public void SetCurrentUser_Should_Throw_When_CalledTwice() + { + // Arrange + var service = new CurrentUserService(); + var principal = CreateAuthenticatedPrincipal(Guid.NewGuid().ToString()); + service.SetCurrentUser(principal); + + // Act & Assert + Should.Throw(() => + service.SetCurrentUser(principal)); + } + + [Fact] + public void SetCurrentUser_Should_StorePrincipal() + { + // Arrange + var service = new CurrentUserService(); + var principal = CreateAuthenticatedPrincipal(Guid.NewGuid().ToString()); + + // Act + service.SetCurrentUser(principal); + + // Assert + service.IsAuthenticated().ShouldBeTrue(); + } + + #endregion + + #region SetCurrentUserId Tests + + [Fact] + public void SetCurrentUserId_Should_Throw_When_CalledTwice() + { + // Arrange + var service = new CurrentUserService(); + var userId = Guid.NewGuid().ToString(); + service.SetCurrentUserId(userId); + + // Act & Assert + Should.Throw(() => + service.SetCurrentUserId(userId)); + } + + [Fact] + public void SetCurrentUserId_Should_NotThrow_When_NullOrEmpty() + { + // Arrange + var service = new CurrentUserService(); + + // Act & Assert - Should not throw + Should.NotThrow(() => service.SetCurrentUserId(null!)); + Should.NotThrow(() => service.SetCurrentUserId(string.Empty)); + } + + [Fact] + public void SetCurrentUserId_Should_ParseAndStoreGuid() + { + // Arrange + var service = new CurrentUserService(); + var userId = Guid.NewGuid(); + + // Act + service.SetCurrentUserId(userId.ToString()); + + // Assert + service.GetUserId().ShouldBe(userId); + } + + #endregion + + #region Name Property Tests + + [Fact] + public void Name_Should_ReturnIdentityName_When_Set() + { + // Arrange + var service = new CurrentUserService(); + var claims = new List + { + new(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()), + new(ClaimTypes.Name, "John Doe") + }; + var identity = new ClaimsIdentity(claims, "TestAuthType", ClaimTypes.Name, ClaimTypes.Role); + var principal = new ClaimsPrincipal(identity); + service.SetCurrentUser(principal); + + // Act + var result = service.Name; + + // Assert + result.ShouldBe("John Doe"); + } + + [Fact] + public void Name_Should_ReturnNull_When_NoPrincipal() + { + // Arrange + var service = new CurrentUserService(); + + // Act + var result = service.Name; + + // Assert + result.ShouldBeNull(); + } + + #endregion +} diff --git a/src/Tests/Identity.Tests/Services/PasswordExpiryServiceTests.cs b/src/Tests/Identity.Tests/Services/PasswordExpiryServiceTests.cs new file mode 100644 index 0000000000..db4ae8a325 --- /dev/null +++ b/src/Tests/Identity.Tests/Services/PasswordExpiryServiceTests.cs @@ -0,0 +1,437 @@ +using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Features.v1.Users; +using FSH.Modules.Identity.Services; +using Microsoft.Extensions.Options; + +namespace Identity.Tests.Services; + +/// +/// Tests for PasswordExpiryService - handles password expiry logic. +/// +public sealed class PasswordExpiryServiceTests +{ + private static PasswordExpiryService CreateService(PasswordPolicyOptions options) + { + return new PasswordExpiryService(Options.Create(options)); + } + + private static FshUser CreateUser(DateTime lastPasswordChangeDate) + { + return new FshUser + { + Id = Guid.NewGuid().ToString(), + Email = "test@example.com", + UserName = "testuser", + LastPasswordChangeDate = lastPasswordChangeDate + }; + } + + #region IsPasswordExpired Tests + + [Fact] + public void IsPasswordExpired_Should_ReturnFalse_When_EnforcePasswordExpiryIsFalse() + { + // Arrange + var options = new PasswordPolicyOptions { EnforcePasswordExpiry = false }; + var service = CreateService(options); + var user = CreateUser(DateTime.UtcNow.AddDays(-1000)); // Very old password + + // Act + var result = service.IsPasswordExpired(user); + + // Assert + result.ShouldBeFalse(); + } + + [Fact] + public void IsPasswordExpired_Should_ReturnTrue_When_PasswordExceedsExpiryDays() + { + // Arrange + var options = new PasswordPolicyOptions + { + EnforcePasswordExpiry = true, + PasswordExpiryDays = 90 + }; + var service = CreateService(options); + var user = CreateUser(DateTime.UtcNow.AddDays(-91)); // Password changed 91 days ago + + // Act + var result = service.IsPasswordExpired(user); + + // Assert + result.ShouldBeTrue(); + } + + [Fact] + public void IsPasswordExpired_Should_ReturnFalse_When_PasswordWithinExpiryDays() + { + // Arrange + var options = new PasswordPolicyOptions + { + EnforcePasswordExpiry = true, + PasswordExpiryDays = 90 + }; + var service = CreateService(options); + var user = CreateUser(DateTime.UtcNow.AddDays(-89)); // Password changed 89 days ago + + // Act + var result = service.IsPasswordExpired(user); + + // Assert + result.ShouldBeFalse(); + } + + [Fact] + public void IsPasswordExpired_Should_ReturnFalse_When_PasswordChangedToday() + { + // Arrange + var options = new PasswordPolicyOptions + { + EnforcePasswordExpiry = true, + PasswordExpiryDays = 90 + }; + var service = CreateService(options); + var user = CreateUser(DateTime.UtcNow); + + // Act + var result = service.IsPasswordExpired(user); + + // Assert + result.ShouldBeFalse(); + } + + [Fact] + public void IsPasswordExpired_Should_ReturnTrue_When_ExactlyOnExpiryBoundary() + { + // Arrange + var options = new PasswordPolicyOptions + { + EnforcePasswordExpiry = true, + PasswordExpiryDays = 90 + }; + var service = CreateService(options); + // Password changed exactly 90 days and 1 second ago (just past expiry) + var user = CreateUser(DateTime.UtcNow.AddDays(-90).AddSeconds(-1)); + + // Act + var result = service.IsPasswordExpired(user); + + // Assert + result.ShouldBeTrue(); + } + + #endregion + + #region GetDaysUntilExpiry Tests + + [Fact] + public void GetDaysUntilExpiry_Should_ReturnMaxValue_When_EnforcePasswordExpiryIsFalse() + { + // Arrange + var options = new PasswordPolicyOptions { EnforcePasswordExpiry = false }; + var service = CreateService(options); + var user = CreateUser(DateTime.UtcNow.AddDays(-1000)); + + // Act + var result = service.GetDaysUntilExpiry(user); + + // Assert + result.ShouldBe(int.MaxValue); + } + + [Fact] + public void GetDaysUntilExpiry_Should_ReturnPositiveDays_When_PasswordNotExpired() + { + // Arrange + var options = new PasswordPolicyOptions + { + EnforcePasswordExpiry = true, + PasswordExpiryDays = 90 + }; + var service = CreateService(options); + var user = CreateUser(DateTime.UtcNow.AddDays(-80)); // 80 days ago + + // Act + var result = service.GetDaysUntilExpiry(user); + + // Assert - TotalDays truncates, so could be 9 or 10 depending on time of day + result.ShouldBeInRange(9, 10); + } + + [Fact] + public void GetDaysUntilExpiry_Should_ReturnNegativeDays_When_PasswordExpired() + { + // Arrange + var options = new PasswordPolicyOptions + { + EnforcePasswordExpiry = true, + PasswordExpiryDays = 90 + }; + var service = CreateService(options); + var user = CreateUser(DateTime.UtcNow.AddDays(-100)); // 100 days ago + + // Act + var result = service.GetDaysUntilExpiry(user); + + // Assert + result.ShouldBeLessThan(0); // Expired 10 days ago + } + + [Fact] + public void GetDaysUntilExpiry_Should_ReturnExpiryDays_When_PasswordJustChanged() + { + // Arrange + var options = new PasswordPolicyOptions + { + EnforcePasswordExpiry = true, + PasswordExpiryDays = 90 + }; + var service = CreateService(options); + var user = CreateUser(DateTime.UtcNow); + + // Act + var result = service.GetDaysUntilExpiry(user); + + // Assert - TotalDays truncates, so could be 89 or 90 depending on time of day + result.ShouldBeInRange(89, 90); + } + + #endregion + + #region IsPasswordExpiringWithinWarningPeriod Tests + + [Fact] + public void IsPasswordExpiringWithinWarningPeriod_Should_ReturnFalse_When_EnforcePasswordExpiryIsFalse() + { + // Arrange + var options = new PasswordPolicyOptions { EnforcePasswordExpiry = false }; + var service = CreateService(options); + var user = CreateUser(DateTime.UtcNow.AddDays(-85)); + + // Act + var result = service.IsPasswordExpiringWithinWarningPeriod(user); + + // Assert + result.ShouldBeFalse(); + } + + [Fact] + public void IsPasswordExpiringWithinWarningPeriod_Should_ReturnTrue_When_WithinWarningDays() + { + // Arrange + var options = new PasswordPolicyOptions + { + EnforcePasswordExpiry = true, + PasswordExpiryDays = 90, + PasswordExpiryWarningDays = 14 + }; + var service = CreateService(options); + var user = CreateUser(DateTime.UtcNow.AddDays(-80)); // 10 days until expiry + + // Act + var result = service.IsPasswordExpiringWithinWarningPeriod(user); + + // Assert + result.ShouldBeTrue(); // 10 days <= 14 warning days + } + + [Fact] + public void IsPasswordExpiringWithinWarningPeriod_Should_ReturnFalse_When_OutsideWarningDays() + { + // Arrange + var options = new PasswordPolicyOptions + { + EnforcePasswordExpiry = true, + PasswordExpiryDays = 90, + PasswordExpiryWarningDays = 14 + }; + var service = CreateService(options); + var user = CreateUser(DateTime.UtcNow.AddDays(-70)); // 20 days until expiry + + // Act + var result = service.IsPasswordExpiringWithinWarningPeriod(user); + + // Assert + result.ShouldBeFalse(); // 20 days > 14 warning days + } + + [Fact] + public void IsPasswordExpiringWithinWarningPeriod_Should_ReturnFalse_When_AlreadyExpired() + { + // Arrange + var options = new PasswordPolicyOptions + { + EnforcePasswordExpiry = true, + PasswordExpiryDays = 90, + PasswordExpiryWarningDays = 14 + }; + var service = CreateService(options); + var user = CreateUser(DateTime.UtcNow.AddDays(-100)); // Already expired + + // Act + var result = service.IsPasswordExpiringWithinWarningPeriod(user); + + // Assert + result.ShouldBeFalse(); // Already expired, not "expiring soon" + } + + [Fact] + public void IsPasswordExpiringWithinWarningPeriod_Should_ReturnTrue_When_ExpiringToday() + { + // Arrange + var options = new PasswordPolicyOptions + { + EnforcePasswordExpiry = true, + PasswordExpiryDays = 90, + PasswordExpiryWarningDays = 14 + }; + var service = CreateService(options); + var user = CreateUser(DateTime.UtcNow.AddDays(-90)); // Expiring today (0 days) + + // Act + var result = service.IsPasswordExpiringWithinWarningPeriod(user); + + // Assert + result.ShouldBeTrue(); // 0 days is within warning period + } + + #endregion + + #region GetPasswordExpiryStatus Tests + + [Fact] + public void GetPasswordExpiryStatus_Should_ReturnExpiredStatus_When_PasswordExpired() + { + // Arrange + var options = new PasswordPolicyOptions + { + EnforcePasswordExpiry = true, + PasswordExpiryDays = 90, + PasswordExpiryWarningDays = 14 + }; + var service = CreateService(options); + var user = CreateUser(DateTime.UtcNow.AddDays(-100)); + + // Act + var result = service.GetPasswordExpiryStatus(user); + + // Assert + result.IsExpired.ShouldBeTrue(); + result.IsExpiringWithinWarningPeriod.ShouldBeFalse(); + result.DaysUntilExpiry.ShouldBeLessThan(0); + result.ExpiryDate.ShouldNotBeNull(); + result.Status.ShouldBe("Expired"); + } + + [Fact] + public void GetPasswordExpiryStatus_Should_ReturnExpiringSoonStatus_When_WithinWarningPeriod() + { + // Arrange + var options = new PasswordPolicyOptions + { + EnforcePasswordExpiry = true, + PasswordExpiryDays = 90, + PasswordExpiryWarningDays = 14 + }; + var service = CreateService(options); + var user = CreateUser(DateTime.UtcNow.AddDays(-80)); // ~10 days until expiry + + // Act + var result = service.GetPasswordExpiryStatus(user); + + // Assert + result.IsExpired.ShouldBeFalse(); + result.IsExpiringWithinWarningPeriod.ShouldBeTrue(); + result.DaysUntilExpiry.ShouldBeInRange(9, 10); // TotalDays truncates + result.ExpiryDate.ShouldNotBeNull(); + result.Status.ShouldBe("Expiring Soon"); + } + + [Fact] + public void GetPasswordExpiryStatus_Should_ReturnValidStatus_When_PasswordValid() + { + // Arrange + var options = new PasswordPolicyOptions + { + EnforcePasswordExpiry = true, + PasswordExpiryDays = 90, + PasswordExpiryWarningDays = 14 + }; + var service = CreateService(options); + var user = CreateUser(DateTime.UtcNow.AddDays(-30)); // ~60 days until expiry + + // Act + var result = service.GetPasswordExpiryStatus(user); + + // Assert + result.IsExpired.ShouldBeFalse(); + result.IsExpiringWithinWarningPeriod.ShouldBeFalse(); + result.DaysUntilExpiry.ShouldBeInRange(59, 60); // TotalDays truncates + result.ExpiryDate.ShouldNotBeNull(); + result.Status.ShouldBe("Valid"); + } + + [Fact] + public void GetPasswordExpiryStatus_Should_ReturnNullExpiryDate_When_ExpiryNotEnforced() + { + // Arrange + var options = new PasswordPolicyOptions { EnforcePasswordExpiry = false }; + var service = CreateService(options); + var user = CreateUser(DateTime.UtcNow.AddDays(-30)); + + // Act + var result = service.GetPasswordExpiryStatus(user); + + // Assert + result.IsExpired.ShouldBeFalse(); + result.IsExpiringWithinWarningPeriod.ShouldBeFalse(); + result.DaysUntilExpiry.ShouldBe(int.MaxValue); + result.ExpiryDate.ShouldBeNull(); + result.Status.ShouldBe("Valid"); + } + + [Fact] + public void GetPasswordExpiryStatus_Should_CalculateCorrectExpiryDate() + { + // Arrange + var lastChange = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc); + var options = new PasswordPolicyOptions + { + EnforcePasswordExpiry = true, + PasswordExpiryDays = 90 + }; + var service = CreateService(options); + var user = CreateUser(lastChange); + + // Act + var result = service.GetPasswordExpiryStatus(user); + + // Assert + result.ExpiryDate.ShouldBe(lastChange.AddDays(90)); + } + + #endregion + + #region UpdateLastPasswordChangeDate Tests + + [Fact] + public void UpdateLastPasswordChangeDate_Should_SetToCurrentUtcTime() + { + // Arrange + var options = new PasswordPolicyOptions(); + var service = CreateService(options); + var oldDate = DateTime.UtcNow.AddDays(-100); + var user = CreateUser(oldDate); + + // Act + var beforeUpdate = DateTime.UtcNow; + service.UpdateLastPasswordChangeDate(user); + var afterUpdate = DateTime.UtcNow; + + // Assert + user.LastPasswordChangeDate.ShouldBeGreaterThanOrEqualTo(beforeUpdate); + user.LastPasswordChangeDate.ShouldBeLessThanOrEqualTo(afterUpdate); + } + + #endregion +} diff --git a/src/Tests/Identity.Tests/Services/PasswordExpiryStatusTests.cs b/src/Tests/Identity.Tests/Services/PasswordExpiryStatusTests.cs new file mode 100644 index 0000000000..ddadceb9ac --- /dev/null +++ b/src/Tests/Identity.Tests/Services/PasswordExpiryStatusTests.cs @@ -0,0 +1,138 @@ +using FSH.Modules.Identity.Services; + +namespace Identity.Tests.Services; + +/// +/// Tests for PasswordExpiryStatus - the status object returned by PasswordExpiryService. +/// +public sealed class PasswordExpiryStatusTests +{ + [Fact] + public void Status_Should_ReturnExpired_When_IsExpiredTrue() + { + // Arrange + var status = new PasswordExpiryStatus + { + IsExpired = true, + IsExpiringWithinWarningPeriod = false, + DaysUntilExpiry = -10, + ExpiryDate = DateTime.UtcNow.AddDays(-10) + }; + + // Act + var result = status.Status; + + // Assert + result.ShouldBe("Expired"); + } + + [Fact] + public void Status_Should_ReturnExpiringSoon_When_WithinWarningPeriod() + { + // Arrange + var status = new PasswordExpiryStatus + { + IsExpired = false, + IsExpiringWithinWarningPeriod = true, + DaysUntilExpiry = 5, + ExpiryDate = DateTime.UtcNow.AddDays(5) + }; + + // Act + var result = status.Status; + + // Assert + result.ShouldBe("Expiring Soon"); + } + + [Fact] + public void Status_Should_ReturnValid_When_NotExpiredAndNotExpiringSoon() + { + // Arrange + var status = new PasswordExpiryStatus + { + IsExpired = false, + IsExpiringWithinWarningPeriod = false, + DaysUntilExpiry = 60, + ExpiryDate = DateTime.UtcNow.AddDays(60) + }; + + // Act + var result = status.Status; + + // Assert + result.ShouldBe("Valid"); + } + + [Fact] + public void Status_Should_PrioritizeExpired_Over_ExpiringSoon() + { + // Arrange - Both flags set (edge case) + var status = new PasswordExpiryStatus + { + IsExpired = true, + IsExpiringWithinWarningPeriod = true, // Should be ignored + DaysUntilExpiry = -1, + ExpiryDate = DateTime.UtcNow.AddDays(-1) + }; + + // Act + var result = status.Status; + + // Assert + result.ShouldBe("Expired"); // Expired takes priority + } + + [Fact] + public void Properties_Should_BeSettableAndGettable() + { + // Arrange + var expiryDate = new DateTime(2024, 12, 31, 12, 0, 0, DateTimeKind.Utc); + + // Act + var status = new PasswordExpiryStatus + { + IsExpired = true, + IsExpiringWithinWarningPeriod = false, + DaysUntilExpiry = -5, + ExpiryDate = expiryDate + }; + + // Assert + status.IsExpired.ShouldBeTrue(); + status.IsExpiringWithinWarningPeriod.ShouldBeFalse(); + status.DaysUntilExpiry.ShouldBe(-5); + status.ExpiryDate.ShouldBe(expiryDate); + } + + [Fact] + public void ExpiryDate_Should_AllowNull() + { + // Arrange & Act + var status = new PasswordExpiryStatus + { + IsExpired = false, + IsExpiringWithinWarningPeriod = false, + DaysUntilExpiry = int.MaxValue, + ExpiryDate = null + }; + + // Assert + status.ExpiryDate.ShouldBeNull(); + status.Status.ShouldBe("Valid"); + } + + [Fact] + public void DefaultValues_Should_BeDefaults() + { + // Arrange & Act + var status = new PasswordExpiryStatus(); + + // Assert + status.IsExpired.ShouldBeFalse(); + status.IsExpiringWithinWarningPeriod.ShouldBeFalse(); + status.DaysUntilExpiry.ShouldBe(0); + status.ExpiryDate.ShouldBeNull(); + status.Status.ShouldBe("Valid"); + } +} diff --git a/src/Tests/Identity.Tests/Validators/CreateGroupCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/CreateGroupCommandValidatorTests.cs new file mode 100644 index 0000000000..727addfeb4 --- /dev/null +++ b/src/Tests/Identity.Tests/Validators/CreateGroupCommandValidatorTests.cs @@ -0,0 +1,243 @@ +using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup; +using FSH.Modules.Identity.Features.v1.Groups.CreateGroup; + +namespace Identity.Tests.Validators; + +/// +/// Tests for CreateGroupCommandValidator - validates group creation requests. +/// +public sealed class CreateGroupCommandValidatorTests +{ + private readonly CreateGroupCommandValidator _sut = new(); + + #region Name Validation + + [Fact] + public void Name_Should_Pass_When_Valid() + { + // Arrange + var command = new CreateGroupCommand("Developers", null, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "Name"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Name_Should_Fail_When_Empty(string? name) + { + // Arrange + var command = new CreateGroupCommand(name!, null, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.PropertyName == "Name"); + } + + [Fact] + public void Name_Should_Fail_When_ExceedsMaxLength() + { + // Arrange + var longName = new string('a', 257); + var command = new CreateGroupCommand(longName, null, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.PropertyName == "Name"); + } + + [Fact] + public void Name_Should_Pass_When_ExactlyMaxLength() + { + // Arrange + var maxLengthName = new string('a', 256); + var command = new CreateGroupCommand(maxLengthName, null, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "Name"); + } + + [Fact] + public void Name_Should_Have_CorrectErrorMessage_When_Empty() + { + // Arrange + var command = new CreateGroupCommand("", null, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors + .Where(e => e.PropertyName == "Name") + .ShouldContain(e => e.ErrorMessage == "Group name is required."); + } + + [Fact] + public void Name_Should_Have_CorrectErrorMessage_When_TooLong() + { + // Arrange + var longName = new string('a', 257); + var command = new CreateGroupCommand(longName, null, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors + .Where(e => e.PropertyName == "Name") + .ShouldContain(e => e.ErrorMessage == "Group name must not exceed 256 characters."); + } + + #endregion + + #region Description Validation + + [Fact] + public void Description_Should_Pass_When_Null() + { + // Arrange + var command = new CreateGroupCommand("Developers", null, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "Description"); + } + + [Fact] + public void Description_Should_Pass_When_Empty() + { + // Arrange + var command = new CreateGroupCommand("Developers", "", false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "Description"); + } + + [Fact] + public void Description_Should_Pass_When_Valid() + { + // Arrange + var command = new CreateGroupCommand("Developers", "Software development team", false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "Description"); + } + + [Fact] + public void Description_Should_Fail_When_ExceedsMaxLength() + { + // Arrange + var longDescription = new string('a', 1025); + var command = new CreateGroupCommand("Developers", longDescription, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.PropertyName == "Description"); + } + + [Fact] + public void Description_Should_Pass_When_ExactlyMaxLength() + { + // Arrange + var maxLengthDescription = new string('a', 1024); + var command = new CreateGroupCommand("Developers", maxLengthDescription, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "Description"); + } + + [Fact] + public void Description_Should_Have_CorrectErrorMessage_When_TooLong() + { + // Arrange + var longDescription = new string('a', 1025); + var command = new CreateGroupCommand("Developers", longDescription, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors + .Where(e => e.PropertyName == "Description") + .ShouldContain(e => e.ErrorMessage == "Description must not exceed 1024 characters."); + } + + #endregion + + #region Combined Validation + + [Fact] + public void Validate_Should_Pass_When_AllFieldsValid() + { + // Arrange + var command = new CreateGroupCommand( + "Engineering Team", + "All software engineers", + true, + ["role-1", "role-2"]); + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeTrue(); + result.Errors.ShouldBeEmpty(); + } + + [Fact] + public void Validate_Should_Pass_When_OptionalFieldsAreNull() + { + // Arrange + var command = new CreateGroupCommand("Basic Group", null, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeTrue(); + } + + [Fact] + public void Validate_Should_Fail_When_BothNameAndDescriptionInvalid() + { + // Arrange + var command = new CreateGroupCommand("", new string('a', 1025), false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.Count.ShouldBe(2); + } + + #endregion +} diff --git a/src/Tests/Identity.Tests/Validators/GenerateTokenCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/GenerateTokenCommandValidatorTests.cs new file mode 100644 index 0000000000..057bacb725 --- /dev/null +++ b/src/Tests/Identity.Tests/Validators/GenerateTokenCommandValidatorTests.cs @@ -0,0 +1,148 @@ +using FSH.Modules.Identity.Contracts.v1.Tokens.TokenGeneration; +using FSH.Modules.Identity.Features.v1.Tokens.TokenGeneration; + +namespace Identity.Tests.Validators; + +/// +/// Tests for GenerateTokenCommandValidator - validates login credentials. +/// +public sealed class GenerateTokenCommandValidatorTests +{ + private readonly GenerateTokenCommandValidator _sut = new(); + + #region Email Validation + + [Fact] + public void Email_Should_Pass_When_Valid() + { + // Arrange + var command = new GenerateTokenCommand("user@example.com", "password123"); + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "Email"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Email_Should_Fail_When_Empty(string? email) + { + // Arrange + var command = new GenerateTokenCommand(email!, "password123"); + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.PropertyName == "Email"); + } + + [Theory] + [InlineData("invalid")] + [InlineData("invalid@")] + [InlineData("@example.com")] + [InlineData("user@")] + [InlineData("user.example.com")] + public void Email_Should_Fail_When_InvalidFormat(string email) + { + // Arrange + var command = new GenerateTokenCommand(email, "password123"); + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.PropertyName == "Email"); + } + + [Theory] + [InlineData("user@example.com")] + [InlineData("user.name@example.com")] + [InlineData("user+tag@example.com")] + [InlineData("user@subdomain.example.com")] + public void Email_Should_Pass_When_ValidFormat(string email) + { + // Arrange + var command = new GenerateTokenCommand(email, "password123"); + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "Email"); + } + + #endregion + + #region Password Validation + + [Fact] + public void Password_Should_Pass_When_Valid() + { + // Arrange + var command = new GenerateTokenCommand("user@example.com", "password123"); + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "Password"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Password_Should_Fail_When_Empty(string? password) + { + // Arrange + var command = new GenerateTokenCommand("user@example.com", password!); + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.PropertyName == "Password"); + } + + #endregion + + #region Combined Validation + + [Fact] + public void Validate_Should_Pass_When_AllFieldsValid() + { + // Arrange + var command = new GenerateTokenCommand("user@example.com", "SecureP@ssw0rd!"); + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeTrue(); + result.Errors.ShouldBeEmpty(); + } + + [Fact] + public void Validate_Should_Fail_When_AllFieldsInvalid() + { + // Arrange + var command = new GenerateTokenCommand("", ""); + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.Count.ShouldBeGreaterThanOrEqualTo(2); + } + + #endregion +} diff --git a/src/Tests/Identity.Tests/Validators/RefreshTokenCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/RefreshTokenCommandValidatorTests.cs new file mode 100644 index 0000000000..bfe1d60b90 --- /dev/null +++ b/src/Tests/Identity.Tests/Validators/RefreshTokenCommandValidatorTests.cs @@ -0,0 +1,144 @@ +using FSH.Modules.Identity.Contracts.v1.Tokens.RefreshToken; +using FSH.Modules.Identity.Features.v1.Tokens.RefreshToken; + +namespace Identity.Tests.Validators; + +/// +/// Tests for RefreshTokenCommandValidator - validates token refresh requests. +/// +public sealed class RefreshTokenCommandValidatorTests +{ + private readonly RefreshTokenCommandValidator _sut = new(); + + #region Token Validation + + [Fact] + public void Token_Should_Pass_When_Valid() + { + // Arrange + var command = new RefreshTokenCommand("valid-jwt-token", "valid-refresh-token"); + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "Token"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Token_Should_Fail_When_Empty(string? token) + { + // Arrange + var command = new RefreshTokenCommand(token!, "valid-refresh-token"); + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.PropertyName == "Token"); + } + + #endregion + + #region RefreshToken Validation + + [Fact] + public void RefreshToken_Should_Pass_When_Valid() + { + // Arrange + var command = new RefreshTokenCommand("valid-jwt-token", "valid-refresh-token"); + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "RefreshToken"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void RefreshToken_Should_Fail_When_Empty(string? refreshToken) + { + // Arrange + var command = new RefreshTokenCommand("valid-jwt-token", refreshToken!); + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.PropertyName == "RefreshToken"); + } + + #endregion + + #region Combined Validation + + [Fact] + public void Validate_Should_Pass_When_AllFieldsValid() + { + // Arrange + var command = new RefreshTokenCommand( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", + "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4="); + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeTrue(); + result.Errors.ShouldBeEmpty(); + } + + [Fact] + public void Validate_Should_Fail_When_AllFieldsEmpty() + { + // Arrange + var command = new RefreshTokenCommand("", ""); + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.Count.ShouldBe(2); + } + + [Fact] + public void Validate_Should_Fail_When_TokenEmpty_RefreshTokenValid() + { + // Arrange + var command = new RefreshTokenCommand("", "valid-refresh-token"); + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.Count.ShouldBe(1); + result.Errors.ShouldContain(e => e.PropertyName == "Token"); + } + + [Fact] + public void Validate_Should_Fail_When_TokenValid_RefreshTokenEmpty() + { + // Arrange + var command = new RefreshTokenCommand("valid-jwt-token", ""); + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.Count.ShouldBe(1); + result.Errors.ShouldContain(e => e.PropertyName == "RefreshToken"); + } + + #endregion +} diff --git a/src/Tests/Identity.Tests/Validators/UpdateGroupCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/UpdateGroupCommandValidatorTests.cs new file mode 100644 index 0000000000..58700a0f4a --- /dev/null +++ b/src/Tests/Identity.Tests/Validators/UpdateGroupCommandValidatorTests.cs @@ -0,0 +1,306 @@ +using FSH.Modules.Identity.Contracts.v1.Groups.UpdateGroup; +using FSH.Modules.Identity.Features.v1.Groups.UpdateGroup; + +namespace Identity.Tests.Validators; + +/// +/// Tests for UpdateGroupCommandValidator - validates group update requests. +/// +public sealed class UpdateGroupCommandValidatorTests +{ + private readonly UpdateGroupCommandValidator _sut = new(); + + #region Id Validation + + [Fact] + public void Id_Should_Pass_When_Valid() + { + // Arrange + var command = new UpdateGroupCommand(Guid.NewGuid(), "Developers", null, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "Id"); + } + + [Fact] + public void Id_Should_Fail_When_Empty() + { + // Arrange + var command = new UpdateGroupCommand(Guid.Empty, "Developers", null, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.PropertyName == "Id"); + } + + [Fact] + public void Id_Should_Have_CorrectErrorMessage() + { + // Arrange + var command = new UpdateGroupCommand(Guid.Empty, "Developers", null, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors + .Where(e => e.PropertyName == "Id") + .ShouldContain(e => e.ErrorMessage == "Group ID is required."); + } + + #endregion + + #region Name Validation + + [Fact] + public void Name_Should_Pass_When_Valid() + { + // Arrange + var command = new UpdateGroupCommand(Guid.NewGuid(), "Developers", null, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "Name"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Name_Should_Fail_When_Empty(string? name) + { + // Arrange + var command = new UpdateGroupCommand(Guid.NewGuid(), name!, null, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.PropertyName == "Name"); + } + + [Fact] + public void Name_Should_Fail_When_ExceedsMaxLength() + { + // Arrange + var longName = new string('a', 257); + var command = new UpdateGroupCommand(Guid.NewGuid(), longName, null, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.PropertyName == "Name"); + } + + [Fact] + public void Name_Should_Pass_When_ExactlyMaxLength() + { + // Arrange + var maxLengthName = new string('a', 256); + var command = new UpdateGroupCommand(Guid.NewGuid(), maxLengthName, null, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "Name"); + } + + [Fact] + public void Name_Should_Have_CorrectErrorMessage_When_Empty() + { + // Arrange + var command = new UpdateGroupCommand(Guid.NewGuid(), "", null, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors + .Where(e => e.PropertyName == "Name") + .ShouldContain(e => e.ErrorMessage == "Group name is required."); + } + + [Fact] + public void Name_Should_Have_CorrectErrorMessage_When_TooLong() + { + // Arrange + var longName = new string('a', 257); + var command = new UpdateGroupCommand(Guid.NewGuid(), longName, null, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors + .Where(e => e.PropertyName == "Name") + .ShouldContain(e => e.ErrorMessage == "Group name must not exceed 256 characters."); + } + + #endregion + + #region Description Validation + + [Fact] + public void Description_Should_Pass_When_Null() + { + // Arrange + var command = new UpdateGroupCommand(Guid.NewGuid(), "Developers", null, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "Description"); + } + + [Fact] + public void Description_Should_Pass_When_Empty() + { + // Arrange + var command = new UpdateGroupCommand(Guid.NewGuid(), "Developers", "", false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "Description"); + } + + [Fact] + public void Description_Should_Pass_When_Valid() + { + // Arrange + var command = new UpdateGroupCommand(Guid.NewGuid(), "Developers", "Software development team", false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "Description"); + } + + [Fact] + public void Description_Should_Fail_When_ExceedsMaxLength() + { + // Arrange + var longDescription = new string('a', 1025); + var command = new UpdateGroupCommand(Guid.NewGuid(), "Developers", longDescription, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.PropertyName == "Description"); + } + + [Fact] + public void Description_Should_Pass_When_ExactlyMaxLength() + { + // Arrange + var maxLengthDescription = new string('a', 1024); + var command = new UpdateGroupCommand(Guid.NewGuid(), "Developers", maxLengthDescription, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "Description"); + } + + [Fact] + public void Description_Should_Have_CorrectErrorMessage_When_TooLong() + { + // Arrange + var longDescription = new string('a', 1025); + var command = new UpdateGroupCommand(Guid.NewGuid(), "Developers", longDescription, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors + .Where(e => e.PropertyName == "Description") + .ShouldContain(e => e.ErrorMessage == "Description must not exceed 1024 characters."); + } + + #endregion + + #region Combined Validation + + [Fact] + public void Validate_Should_Pass_When_AllFieldsValid() + { + // Arrange + var command = new UpdateGroupCommand( + Guid.NewGuid(), + "Engineering Team", + "All software engineers", + true, + ["role-1", "role-2"]); + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeTrue(); + result.Errors.ShouldBeEmpty(); + } + + [Fact] + public void Validate_Should_Pass_When_OptionalFieldsAreNull() + { + // Arrange + var command = new UpdateGroupCommand(Guid.NewGuid(), "Basic Group", null, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeTrue(); + } + + [Fact] + public void Validate_Should_Fail_When_AllRequiredFieldsInvalid() + { + // Arrange + var command = new UpdateGroupCommand(Guid.Empty, "", null, false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.Count.ShouldBe(2); + result.Errors.ShouldContain(e => e.PropertyName == "Id"); + result.Errors.ShouldContain(e => e.PropertyName == "Name"); + } + + [Fact] + public void Validate_Should_Fail_When_AllFieldsInvalid() + { + // Arrange + var command = new UpdateGroupCommand(Guid.Empty, "", new string('a', 1025), false, null); + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.Count.ShouldBe(3); + } + + #endregion +} diff --git a/src/Tests/Identity.Tests/Validators/UpdatePermissionsCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/UpdatePermissionsCommandValidatorTests.cs new file mode 100644 index 0000000000..198271d627 --- /dev/null +++ b/src/Tests/Identity.Tests/Validators/UpdatePermissionsCommandValidatorTests.cs @@ -0,0 +1,184 @@ +using FSH.Modules.Identity.Contracts.v1.Roles.UpdatePermissions; +using FSH.Modules.Identity.Features.v1.Roles.UpdateRolePermissions; + +namespace Identity.Tests.Validators; + +/// +/// Tests for UpdatePermissionsCommandValidator - validates role permission update requests. +/// +public sealed class UpdatePermissionsCommandValidatorTests +{ + private readonly UpdatePermissionsCommandValidator _sut = new(); + + #region RoleId Validation + + [Fact] + public void RoleId_Should_Pass_When_Valid() + { + // Arrange + var command = new UpdatePermissionsCommand + { + RoleId = "admin-role", + Permissions = ["read", "write"] + }; + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "RoleId"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void RoleId_Should_Fail_When_Empty(string? roleId) + { + // Arrange + var command = new UpdatePermissionsCommand + { + RoleId = roleId!, + Permissions = ["read"] + }; + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.PropertyName == "RoleId"); + } + + #endregion + + #region Permissions Validation + + [Fact] + public void Permissions_Should_Pass_When_ValidList() + { + // Arrange + var command = new UpdatePermissionsCommand + { + RoleId = "admin-role", + Permissions = ["Users.View", "Users.Create", "Users.Update"] + }; + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "Permissions"); + } + + [Fact] + public void Permissions_Should_Pass_When_EmptyList() + { + // Arrange - Empty list is valid (removing all permissions) + var command = new UpdatePermissionsCommand + { + RoleId = "admin-role", + Permissions = [] + }; + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "Permissions"); + } + + [Fact] + public void Permissions_Should_Fail_When_Null() + { + // Arrange + var command = new UpdatePermissionsCommand + { + RoleId = "admin-role", + Permissions = null! + }; + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.PropertyName == "Permissions"); + } + + #endregion + + #region Combined Validation + + [Fact] + public void Validate_Should_Pass_When_AllFieldsValid() + { + // Arrange + var command = new UpdatePermissionsCommand + { + RoleId = "manager-role", + Permissions = ["Reports.View", "Reports.Export", "Dashboard.View"] + }; + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeTrue(); + result.Errors.ShouldBeEmpty(); + } + + [Fact] + public void Validate_Should_Fail_When_AllFieldsInvalid() + { + // Arrange + var command = new UpdatePermissionsCommand + { + RoleId = "", + Permissions = null! + }; + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.Count.ShouldBe(2); + } + + [Fact] + public void Validate_Should_Pass_WithSinglePermission() + { + // Arrange + var command = new UpdatePermissionsCommand + { + RoleId = "basic-role", + Permissions = ["Dashboard.View"] + }; + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeTrue(); + } + + [Fact] + public void Validate_Should_Pass_WithManyPermissions() + { + // Arrange + var command = new UpdatePermissionsCommand + { + RoleId = "super-admin", + Permissions = Enumerable.Range(1, 50).Select(i => $"Permission.{i}").ToList() + }; + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeTrue(); + } + + #endregion +} diff --git a/src/Tests/Identity.Tests/Validators/UpsertRoleCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/UpsertRoleCommandValidatorTests.cs new file mode 100644 index 0000000000..142ba799fe --- /dev/null +++ b/src/Tests/Identity.Tests/Validators/UpsertRoleCommandValidatorTests.cs @@ -0,0 +1,119 @@ +using FSH.Modules.Identity.Contracts.v1.Roles.UpsertRole; +using FSH.Modules.Identity.Features.v1.Roles.UpsertRole; + +namespace Identity.Tests.Validators; + +/// +/// Tests for UpsertRoleCommandValidator - validates role creation/update requests. +/// +public sealed class UpsertRoleCommandValidatorTests +{ + private readonly UpsertRoleCommandValidator _sut = new(); + + #region Name Validation + + [Fact] + public void Name_Should_Pass_When_Valid() + { + // Arrange + var command = new UpsertRoleCommand { Id = "role-1", Name = "Admin" }; + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors.ShouldNotContain(e => e.PropertyName == "Name"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Name_Should_Fail_When_Empty(string? name) + { + // Arrange + var command = new UpsertRoleCommand { Id = "role-1", Name = name! }; + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.PropertyName == "Name"); + } + + [Fact] + public void Name_Should_Have_CorrectErrorMessage() + { + // Arrange + var command = new UpsertRoleCommand { Id = "role-1", Name = "" }; + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors + .Where(e => e.PropertyName == "Name") + .ShouldContain(e => e.ErrorMessage == "Role name is required."); + } + + #endregion + + #region Combined Validation + + [Fact] + public void Validate_Should_Pass_When_NameProvided() + { + // Arrange + var command = new UpsertRoleCommand + { + Id = "role-1", + Name = "Manager", + Description = "Manager role with elevated permissions" + }; + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeTrue(); + result.Errors.ShouldBeEmpty(); + } + + [Fact] + public void Validate_Should_Pass_When_DescriptionIsNull() + { + // Arrange + var command = new UpsertRoleCommand + { + Id = "role-1", + Name = "User", + Description = null + }; + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeTrue(); + } + + [Theory] + [InlineData("Admin")] + [InlineData("Super Admin")] + [InlineData("Read-Only User")] + [InlineData("API_Access")] + public void Validate_Should_Pass_ForVariousRoleNames(string roleName) + { + // Arrange + var command = new UpsertRoleCommand { Id = "role-1", Name = roleName }; + + // Act + var result = _sut.Validate(command); + + // Assert + result.IsValid.ShouldBeTrue(); + } + + #endregion +} diff --git a/src/Tests/Multitenacy.Tests/Domain/TenantThemeTests.cs b/src/Tests/Multitenacy.Tests/Domain/TenantThemeTests.cs new file mode 100644 index 0000000000..a4baac19c9 --- /dev/null +++ b/src/Tests/Multitenacy.Tests/Domain/TenantThemeTests.cs @@ -0,0 +1,390 @@ +using FSH.Modules.Multitenancy.Domain; + +namespace Multitenancy.Tests.Domain; + +/// +/// Tests for TenantTheme domain entity - theme configuration per tenant. +/// +public sealed class TenantThemeTests +{ + #region Create Factory Method Tests + + [Fact] + public void Create_Should_SetTenantId() + { + // Arrange + var tenantId = "tenant-1"; + + // Act + var theme = TenantTheme.Create(tenantId); + + // Assert + theme.TenantId.ShouldBe(tenantId); + } + + [Fact] + public void Create_Should_GenerateNewId() + { + // Act + var theme = TenantTheme.Create("tenant-1"); + + // Assert + theme.Id.ShouldNotBe(Guid.Empty); + } + + [Fact] + public void Create_Should_SetCreatedBy_When_Provided() + { + // Arrange + var createdBy = "user-123"; + + // Act + var theme = TenantTheme.Create("tenant-1", createdBy); + + // Assert + theme.CreatedBy.ShouldBe(createdBy); + } + + [Fact] + public void Create_Should_AllowNullCreatedBy() + { + // Act + var theme = TenantTheme.Create("tenant-1"); + + // Assert + theme.CreatedBy.ShouldBeNull(); + } + + [Fact] + public void Create_Should_SetCreatedOnUtc() + { + // Arrange + var before = DateTimeOffset.UtcNow; + + // Act + var theme = TenantTheme.Create("tenant-1"); + var after = DateTimeOffset.UtcNow; + + // Assert + theme.CreatedOnUtc.ShouldBeGreaterThanOrEqualTo(before); + theme.CreatedOnUtc.ShouldBeLessThanOrEqualTo(after); + } + + [Fact] + public void Create_Should_InitializeDefaultLightPalette() + { + // Act + var theme = TenantTheme.Create("tenant-1"); + + // Assert + theme.PrimaryColor.ShouldBe("#2563EB"); + theme.SecondaryColor.ShouldBe("#0F172A"); + theme.TertiaryColor.ShouldBe("#6366F1"); + theme.BackgroundColor.ShouldBe("#F8FAFC"); + theme.SurfaceColor.ShouldBe("#FFFFFF"); + theme.ErrorColor.ShouldBe("#DC2626"); + theme.WarningColor.ShouldBe("#F59E0B"); + theme.SuccessColor.ShouldBe("#16A34A"); + theme.InfoColor.ShouldBe("#0284C7"); + } + + [Fact] + public void Create_Should_InitializeDefaultDarkPalette() + { + // Act + var theme = TenantTheme.Create("tenant-1"); + + // Assert + theme.DarkPrimaryColor.ShouldBe("#38BDF8"); + theme.DarkSecondaryColor.ShouldBe("#94A3B8"); + theme.DarkTertiaryColor.ShouldBe("#818CF8"); + theme.DarkBackgroundColor.ShouldBe("#0B1220"); + theme.DarkSurfaceColor.ShouldBe("#111827"); + theme.DarkErrorColor.ShouldBe("#F87171"); + theme.DarkWarningColor.ShouldBe("#FBBF24"); + theme.DarkSuccessColor.ShouldBe("#22C55E"); + theme.DarkInfoColor.ShouldBe("#38BDF8"); + } + + [Fact] + public void Create_Should_InitializeDefaultTypography() + { + // Act + var theme = TenantTheme.Create("tenant-1"); + + // Assert + theme.FontFamily.ShouldBe("Inter, sans-serif"); + theme.HeadingFontFamily.ShouldBe("Inter, sans-serif"); + theme.FontSizeBase.ShouldBe(14); + theme.LineHeightBase.ShouldBe(1.5); + } + + [Fact] + public void Create_Should_InitializeDefaultLayout() + { + // Act + var theme = TenantTheme.Create("tenant-1"); + + // Assert + theme.BorderRadius.ShouldBe("4px"); + theme.DefaultElevation.ShouldBe(1); + } + + [Fact] + public void Create_Should_InitializeNullBrandAssets() + { + // Act + var theme = TenantTheme.Create("tenant-1"); + + // Assert + theme.LogoUrl.ShouldBeNull(); + theme.LogoDarkUrl.ShouldBeNull(); + theme.FaviconUrl.ShouldBeNull(); + } + + [Fact] + public void Create_Should_InitializeIsDefaultFalse() + { + // Act + var theme = TenantTheme.Create("tenant-1"); + + // Assert + theme.IsDefault.ShouldBeFalse(); + } + + #endregion + + #region Update Method Tests + + [Fact] + public void Update_Should_SetLastModifiedOnUtc() + { + // Arrange + var theme = TenantTheme.Create("tenant-1"); + var before = DateTimeOffset.UtcNow; + + // Act + theme.Update("modifier-user"); + var after = DateTimeOffset.UtcNow; + + // Assert + theme.LastModifiedOnUtc.ShouldNotBeNull(); + theme.LastModifiedOnUtc.Value.ShouldBeGreaterThanOrEqualTo(before); + theme.LastModifiedOnUtc.Value.ShouldBeLessThanOrEqualTo(after); + } + + [Fact] + public void Update_Should_SetLastModifiedBy() + { + // Arrange + var theme = TenantTheme.Create("tenant-1"); + var modifiedBy = "modifier-user"; + + // Act + theme.Update(modifiedBy); + + // Assert + theme.LastModifiedBy.ShouldBe(modifiedBy); + } + + [Fact] + public void Update_Should_AllowNullModifier() + { + // Arrange + var theme = TenantTheme.Create("tenant-1"); + + // Act + theme.Update(null); + + // Assert + theme.LastModifiedBy.ShouldBeNull(); + theme.LastModifiedOnUtc.ShouldNotBeNull(); + } + + #endregion + + #region ResetToDefaults Method Tests + + [Fact] + public void ResetToDefaults_Should_ResetLightPalette() + { + // Arrange + var theme = TenantTheme.Create("tenant-1"); + theme.PrimaryColor = "#FF0000"; + theme.SecondaryColor = "#00FF00"; + theme.TertiaryColor = "#0000FF"; + theme.BackgroundColor = "#AAAAAA"; + theme.SurfaceColor = "#BBBBBB"; + theme.ErrorColor = "#CCCCCC"; + theme.WarningColor = "#DDDDDD"; + theme.SuccessColor = "#EEEEEE"; + theme.InfoColor = "#FFFFFF"; + + // Act + theme.ResetToDefaults(); + + // Assert + theme.PrimaryColor.ShouldBe("#2563EB"); + theme.SecondaryColor.ShouldBe("#0F172A"); + theme.TertiaryColor.ShouldBe("#6366F1"); + theme.BackgroundColor.ShouldBe("#F8FAFC"); + theme.SurfaceColor.ShouldBe("#FFFFFF"); + theme.ErrorColor.ShouldBe("#DC2626"); + theme.WarningColor.ShouldBe("#F59E0B"); + theme.SuccessColor.ShouldBe("#16A34A"); + theme.InfoColor.ShouldBe("#0284C7"); + } + + [Fact] + public void ResetToDefaults_Should_ResetDarkPalette() + { + // Arrange + var theme = TenantTheme.Create("tenant-1"); + theme.DarkPrimaryColor = "#FF0000"; + theme.DarkSecondaryColor = "#00FF00"; + theme.DarkTertiaryColor = "#0000FF"; + + // Act + theme.ResetToDefaults(); + + // Assert + theme.DarkPrimaryColor.ShouldBe("#38BDF8"); + theme.DarkSecondaryColor.ShouldBe("#94A3B8"); + theme.DarkTertiaryColor.ShouldBe("#818CF8"); + theme.DarkBackgroundColor.ShouldBe("#0B1220"); + theme.DarkSurfaceColor.ShouldBe("#111827"); + theme.DarkErrorColor.ShouldBe("#F87171"); + theme.DarkWarningColor.ShouldBe("#FBBF24"); + theme.DarkSuccessColor.ShouldBe("#22C55E"); + theme.DarkInfoColor.ShouldBe("#38BDF8"); + } + + [Fact] + public void ResetToDefaults_Should_ClearBrandAssets() + { + // Arrange + var theme = TenantTheme.Create("tenant-1"); + theme.LogoUrl = "https://example.com/logo.png"; + theme.LogoDarkUrl = "https://example.com/logo-dark.png"; + theme.FaviconUrl = "https://example.com/favicon.ico"; + + // Act + theme.ResetToDefaults(); + + // Assert + theme.LogoUrl.ShouldBeNull(); + theme.LogoDarkUrl.ShouldBeNull(); + theme.FaviconUrl.ShouldBeNull(); + } + + [Fact] + public void ResetToDefaults_Should_ResetTypography() + { + // Arrange + var theme = TenantTheme.Create("tenant-1"); + theme.FontFamily = "Roboto, sans-serif"; + theme.HeadingFontFamily = "Montserrat, sans-serif"; + theme.FontSizeBase = 18; + theme.LineHeightBase = 2.0; + + // Act + theme.ResetToDefaults(); + + // Assert + theme.FontFamily.ShouldBe("Inter, sans-serif"); + theme.HeadingFontFamily.ShouldBe("Inter, sans-serif"); + theme.FontSizeBase.ShouldBe(14); + theme.LineHeightBase.ShouldBe(1.5); + } + + [Fact] + public void ResetToDefaults_Should_ResetLayout() + { + // Arrange + var theme = TenantTheme.Create("tenant-1"); + theme.BorderRadius = "8px"; + theme.DefaultElevation = 5; + + // Act + theme.ResetToDefaults(); + + // Assert + theme.BorderRadius.ShouldBe("4px"); + theme.DefaultElevation.ShouldBe(1); + } + + [Fact] + public void ResetToDefaults_Should_NotResetTenantId() + { + // Arrange + var theme = TenantTheme.Create("tenant-1"); + + // Act + theme.ResetToDefaults(); + + // Assert + theme.TenantId.ShouldBe("tenant-1"); + } + + [Fact] + public void ResetToDefaults_Should_NotResetId() + { + // Arrange + var theme = TenantTheme.Create("tenant-1"); + var originalId = theme.Id; + + // Act + theme.ResetToDefaults(); + + // Assert + theme.Id.ShouldBe(originalId); + } + + [Fact] + public void ResetToDefaults_Should_NotResetIsDefault() + { + // Arrange + var theme = TenantTheme.Create("tenant-1"); + theme.IsDefault = true; + + // Act + theme.ResetToDefaults(); + + // Assert - IsDefault is not reset by ResetToDefaults + theme.IsDefault.ShouldBeTrue(); + } + + #endregion + + #region Property Tests + + [Fact] + public void Properties_Should_BeSettable() + { + // Arrange + var theme = TenantTheme.Create("tenant-1"); + + // Act + theme.PrimaryColor = "#CUSTOM1"; + theme.IsDefault = true; + theme.LogoUrl = "https://example.com/logo.png"; + + // Assert + theme.PrimaryColor.ShouldBe("#CUSTOM1"); + theme.IsDefault.ShouldBeTrue(); + theme.LogoUrl.ShouldBe("https://example.com/logo.png"); + } + + [Fact] + public void Create_Should_GenerateUniqueIds() + { + // Act + var theme1 = TenantTheme.Create("tenant-1"); + var theme2 = TenantTheme.Create("tenant-2"); + + // Assert + theme1.Id.ShouldNotBe(theme2.Id); + } + + #endregion +} diff --git a/src/Tests/Multitenacy.Tests/GlobalUsings.cs b/src/Tests/Multitenacy.Tests/GlobalUsings.cs new file mode 100644 index 0000000000..b37457ed83 --- /dev/null +++ b/src/Tests/Multitenacy.Tests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Xunit; +global using Shouldly; diff --git a/src/Tests/Multitenacy.Tests/Multitenacy.Tests.csproj b/src/Tests/Multitenacy.Tests/Multitenacy.Tests.csproj new file mode 100644 index 0000000000..048e72ca4f --- /dev/null +++ b/src/Tests/Multitenacy.Tests/Multitenacy.Tests.csproj @@ -0,0 +1,29 @@ + + + net10.0 + false + false + enable + enable + $(NoWarn);S125;S3261;CA1707 + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + \ No newline at end of file diff --git a/src/Tests/Multitenacy.Tests/MultitenancyOptionsTests.cs b/src/Tests/Multitenacy.Tests/MultitenancyOptionsTests.cs new file mode 100644 index 0000000000..8a12fe97af --- /dev/null +++ b/src/Tests/Multitenacy.Tests/MultitenancyOptionsTests.cs @@ -0,0 +1,76 @@ +using FSH.Modules.Multitenancy; + +namespace Multitenancy.Tests; + +/// +/// Tests for MultitenancyOptions - configuration for multitenancy behavior. +/// +public sealed class MultitenancyOptionsTests +{ + [Fact] + public void DefaultValues_Should_BeSet() + { + // Arrange & Act + var options = new MultitenancyOptions(); + + // Assert + options.RunTenantMigrationsOnStartup.ShouldBeFalse(); + options.AutoProvisionOnStartup.ShouldBeTrue(); + } + + [Fact] + public void RunTenantMigrationsOnStartup_Should_BeSettable() + { + // Arrange + var options = new MultitenancyOptions + { + RunTenantMigrationsOnStartup = true + }; + + // Assert + options.RunTenantMigrationsOnStartup.ShouldBeTrue(); + } + + [Fact] + public void AutoProvisionOnStartup_Should_BeSettable() + { + // Arrange + var options = new MultitenancyOptions + { + AutoProvisionOnStartup = false + }; + + // Assert + options.AutoProvisionOnStartup.ShouldBeFalse(); + } + + [Fact] + public void BothOptions_Can_BeEnabled() + { + // Arrange + var options = new MultitenancyOptions + { + RunTenantMigrationsOnStartup = true, + AutoProvisionOnStartup = true + }; + + // Assert + options.RunTenantMigrationsOnStartup.ShouldBeTrue(); + options.AutoProvisionOnStartup.ShouldBeTrue(); + } + + [Fact] + public void BothOptions_Can_BeDisabled() + { + // Arrange + var options = new MultitenancyOptions + { + RunTenantMigrationsOnStartup = false, + AutoProvisionOnStartup = false + }; + + // Assert + options.RunTenantMigrationsOnStartup.ShouldBeFalse(); + options.AutoProvisionOnStartup.ShouldBeFalse(); + } +} diff --git a/src/Tests/Multitenacy.Tests/Provisioning/TenantProvisioningStatusTests.cs b/src/Tests/Multitenacy.Tests/Provisioning/TenantProvisioningStatusTests.cs new file mode 100644 index 0000000000..a3d0363d19 --- /dev/null +++ b/src/Tests/Multitenacy.Tests/Provisioning/TenantProvisioningStatusTests.cs @@ -0,0 +1,58 @@ +using FSH.Modules.Multitenancy.Provisioning; + +namespace Multitenancy.Tests.Provisioning; + +/// +/// Tests for TenantProvisioningStatus enum values. +/// +public sealed class TenantProvisioningStatusTests +{ + [Fact] + public void Pending_Should_BeZero() + { + // Assert + ((int)TenantProvisioningStatus.Pending).ShouldBe(0); + } + + [Fact] + public void Running_Should_BeOne() + { + // Assert + ((int)TenantProvisioningStatus.Running).ShouldBe(1); + } + + [Fact] + public void Completed_Should_BeTwo() + { + // Assert + ((int)TenantProvisioningStatus.Completed).ShouldBe(2); + } + + [Fact] + public void Failed_Should_BeThree() + { + // Assert + ((int)TenantProvisioningStatus.Failed).ShouldBe(3); + } + + [Fact] + public void Enum_Should_HaveFourValues() + { + // Act + var values = Enum.GetValues(); + + // Assert + values.Length.ShouldBe(4); + } + + [Theory] + [InlineData(TenantProvisioningStatus.Pending, "Pending")] + [InlineData(TenantProvisioningStatus.Running, "Running")] + [InlineData(TenantProvisioningStatus.Completed, "Completed")] + [InlineData(TenantProvisioningStatus.Failed, "Failed")] + public void Enum_Should_HaveCorrectNames(TenantProvisioningStatus status, string expectedName) + { + // Assert + status.ToString().ShouldBe(expectedName); + } +} diff --git a/src/Tests/Multitenacy.Tests/Provisioning/TenantProvisioningStepTests.cs b/src/Tests/Multitenacy.Tests/Provisioning/TenantProvisioningStepTests.cs new file mode 100644 index 0000000000..fc2da77c52 --- /dev/null +++ b/src/Tests/Multitenacy.Tests/Provisioning/TenantProvisioningStepTests.cs @@ -0,0 +1,258 @@ +using FSH.Modules.Multitenancy.Provisioning; + +namespace Multitenancy.Tests.Provisioning; + +/// +/// Tests for TenantProvisioningStep - individual provisioning step tracking. +/// +public sealed class TenantProvisioningStepTests +{ + #region Constructor Tests + + [Fact] + public void Constructor_Should_SetProvisioningId() + { + // Arrange + var provisioningId = Guid.NewGuid(); + + // Act + var step = new TenantProvisioningStep(provisioningId, TenantProvisioningStepName.Database); + + // Assert + step.ProvisioningId.ShouldBe(provisioningId); + } + + [Fact] + public void Constructor_Should_SetStep() + { + // Arrange + var provisioningId = Guid.NewGuid(); + + // Act + var step = new TenantProvisioningStep(provisioningId, TenantProvisioningStepName.Migrations); + + // Assert + step.Step.ShouldBe(TenantProvisioningStepName.Migrations); + } + + [Fact] + public void Constructor_Should_SetStatusToPending() + { + // Act + var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Database); + + // Assert + step.Status.ShouldBe(TenantProvisioningStatus.Pending); + } + + [Fact] + public void Constructor_Should_GenerateNewId() + { + // Act + var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Database); + + // Assert + step.Id.ShouldNotBe(Guid.Empty); + } + + [Fact] + public void Constructor_Should_InitializeNullFields() + { + // Act + var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Database); + + // Assert + step.Error.ShouldBeNull(); + step.StartedUtc.ShouldBeNull(); + step.CompletedUtc.ShouldBeNull(); + } + + [Theory] + [InlineData(TenantProvisioningStepName.Database)] + [InlineData(TenantProvisioningStepName.Migrations)] + [InlineData(TenantProvisioningStepName.Seeding)] + [InlineData(TenantProvisioningStepName.CacheWarm)] + public void Constructor_Should_AcceptAllStepNames(TenantProvisioningStepName stepName) + { + // Act + var step = new TenantProvisioningStep(Guid.NewGuid(), stepName); + + // Assert + step.Step.ShouldBe(stepName); + } + + #endregion + + #region MarkRunning Tests + + [Fact] + public void MarkRunning_Should_SetStatusToRunning() + { + // Arrange + var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Database); + + // Act + step.MarkRunning(); + + // Assert + step.Status.ShouldBe(TenantProvisioningStatus.Running); + } + + [Fact] + public void MarkRunning_Should_SetStartedUtc_OnFirstCall() + { + // Arrange + var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Database); + var before = DateTime.UtcNow; + + // Act + step.MarkRunning(); + var after = DateTime.UtcNow; + + // Assert + step.StartedUtc.ShouldNotBeNull(); + step.StartedUtc.Value.ShouldBeGreaterThanOrEqualTo(before); + step.StartedUtc.Value.ShouldBeLessThanOrEqualTo(after); + } + + [Fact] + public void MarkRunning_Should_NotOverwriteStartedUtc_OnSubsequentCalls() + { + // Arrange + var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Database); + step.MarkRunning(); + var firstStartedUtc = step.StartedUtc; + + // Act - Call again + step.MarkRunning(); + + // Assert - StartedUtc should not change (due to ??= operator) + step.StartedUtc.ShouldBe(firstStartedUtc); + } + + #endregion + + #region MarkCompleted Tests + + [Fact] + public void MarkCompleted_Should_SetStatusToCompleted() + { + // Arrange + var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Database); + step.MarkRunning(); + + // Act + step.MarkCompleted(); + + // Assert + step.Status.ShouldBe(TenantProvisioningStatus.Completed); + } + + [Fact] + public void MarkCompleted_Should_SetCompletedUtc() + { + // Arrange + var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Database); + var before = DateTime.UtcNow; + + // Act + step.MarkCompleted(); + var after = DateTime.UtcNow; + + // Assert + step.CompletedUtc.ShouldNotBeNull(); + step.CompletedUtc.Value.ShouldBeGreaterThanOrEqualTo(before); + step.CompletedUtc.Value.ShouldBeLessThanOrEqualTo(after); + } + + #endregion + + #region MarkFailed Tests + + [Fact] + public void MarkFailed_Should_SetStatusToFailed() + { + // Arrange + var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Database); + + // Act + step.MarkFailed("Connection failed"); + + // Assert + step.Status.ShouldBe(TenantProvisioningStatus.Failed); + } + + [Fact] + public void MarkFailed_Should_SetError() + { + // Arrange + var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Database); + var error = "Database connection timeout"; + + // Act + step.MarkFailed(error); + + // Assert + step.Error.ShouldBe(error); + } + + [Fact] + public void MarkFailed_Should_SetCompletedUtc() + { + // Arrange + var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Database); + var before = DateTime.UtcNow; + + // Act + step.MarkFailed("Error"); + var after = DateTime.UtcNow; + + // Assert + step.CompletedUtc.ShouldNotBeNull(); + step.CompletedUtc.Value.ShouldBeGreaterThanOrEqualTo(before); + step.CompletedUtc.Value.ShouldBeLessThanOrEqualTo(after); + } + + #endregion + + #region Lifecycle Tests + + [Fact] + public void Step_Should_SupportSuccessfulLifecycle() + { + // Arrange + var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Migrations); + step.Status.ShouldBe(TenantProvisioningStatus.Pending); + + // Act - Running + step.MarkRunning(); + step.Status.ShouldBe(TenantProvisioningStatus.Running); + step.StartedUtc.ShouldNotBeNull(); + + // Act - Completed + step.MarkCompleted(); + step.Status.ShouldBe(TenantProvisioningStatus.Completed); + step.CompletedUtc.ShouldNotBeNull(); + } + + [Fact] + public void Step_Should_SupportFailureLifecycle() + { + // Arrange + var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Seeding); + + // Act - Running + step.MarkRunning(); + step.Status.ShouldBe(TenantProvisioningStatus.Running); + + // Act - Failed + step.MarkFailed("Seeding failed: unique constraint violation"); + + // Assert + step.Status.ShouldBe(TenantProvisioningStatus.Failed); + step.Error.ShouldContain("unique constraint violation"); + step.CompletedUtc.ShouldNotBeNull(); + } + + #endregion +} diff --git a/src/Tests/Multitenacy.Tests/Provisioning/TenantProvisioningTests.cs b/src/Tests/Multitenacy.Tests/Provisioning/TenantProvisioningTests.cs new file mode 100644 index 0000000000..0d0821f915 --- /dev/null +++ b/src/Tests/Multitenacy.Tests/Provisioning/TenantProvisioningTests.cs @@ -0,0 +1,361 @@ +using FSH.Modules.Multitenancy.Provisioning; + +namespace Multitenancy.Tests.Provisioning; + +/// +/// Tests for TenantProvisioning domain entity - tenant provisioning workflow. +/// +public sealed class TenantProvisioningTests +{ + #region Constructor Tests + + [Fact] + public void Constructor_Should_SetTenantId() + { + // Arrange + var tenantId = "tenant-1"; + var correlationId = Guid.NewGuid().ToString(); + + // Act + var provisioning = new TenantProvisioning(tenantId, correlationId); + + // Assert + provisioning.TenantId.ShouldBe(tenantId); + } + + [Fact] + public void Constructor_Should_SetCorrelationId() + { + // Arrange + var tenantId = "tenant-1"; + var correlationId = Guid.NewGuid().ToString(); + + // Act + var provisioning = new TenantProvisioning(tenantId, correlationId); + + // Assert + provisioning.CorrelationId.ShouldBe(correlationId); + } + + [Fact] + public void Constructor_Should_SetStatusToPending() + { + // Act + var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + + // Assert + provisioning.Status.ShouldBe(TenantProvisioningStatus.Pending); + } + + [Fact] + public void Constructor_Should_SetCreatedUtc() + { + // Arrange + var before = DateTime.UtcNow; + + // Act + var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var after = DateTime.UtcNow; + + // Assert + provisioning.CreatedUtc.ShouldBeGreaterThanOrEqualTo(before); + provisioning.CreatedUtc.ShouldBeLessThanOrEqualTo(after); + } + + [Fact] + public void Constructor_Should_GenerateNewId() + { + // Act + var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + + // Assert + provisioning.Id.ShouldNotBe(Guid.Empty); + } + + [Fact] + public void Constructor_Should_InitializeNullFields() + { + // Act + var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + + // Assert + provisioning.CurrentStep.ShouldBeNull(); + provisioning.Error.ShouldBeNull(); + provisioning.JobId.ShouldBeNull(); + provisioning.StartedUtc.ShouldBeNull(); + provisioning.CompletedUtc.ShouldBeNull(); + } + + [Fact] + public void Constructor_Should_InitializeEmptySteps() + { + // Act + var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + + // Assert + provisioning.Steps.ShouldNotBeNull(); + provisioning.Steps.ShouldBeEmpty(); + } + + #endregion + + #region SetJobId Tests + + [Fact] + public void SetJobId_Should_SetJobId() + { + // Arrange + var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var jobId = "job-12345"; + + // Act + provisioning.SetJobId(jobId); + + // Assert + provisioning.JobId.ShouldBe(jobId); + } + + [Fact] + public void SetJobId_Should_AllowOverwriting() + { + // Arrange + var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + provisioning.SetJobId("job-1"); + + // Act + provisioning.SetJobId("job-2"); + + // Assert + provisioning.JobId.ShouldBe("job-2"); + } + + #endregion + + #region MarkRunning Tests + + [Fact] + public void MarkRunning_Should_SetStatusToRunning() + { + // Arrange + var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + + // Act + provisioning.MarkRunning("Migration"); + + // Assert + provisioning.Status.ShouldBe(TenantProvisioningStatus.Running); + } + + [Fact] + public void MarkRunning_Should_SetCurrentStep() + { + // Arrange + var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + + // Act + provisioning.MarkRunning("Migration"); + + // Assert + provisioning.CurrentStep.ShouldBe("Migration"); + } + + [Fact] + public void MarkRunning_Should_SetStartedUtc_OnFirstCall() + { + // Arrange + var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var before = DateTime.UtcNow; + + // Act + provisioning.MarkRunning("Migration"); + var after = DateTime.UtcNow; + + // Assert + provisioning.StartedUtc.ShouldNotBeNull(); + provisioning.StartedUtc.Value.ShouldBeGreaterThanOrEqualTo(before); + provisioning.StartedUtc.Value.ShouldBeLessThanOrEqualTo(after); + } + + [Fact] + public void MarkRunning_Should_NotOverwriteStartedUtc_OnSubsequentCalls() + { + // Arrange + var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + provisioning.MarkRunning("Migration"); + var firstStartedUtc = provisioning.StartedUtc; + + // Act - Call again with different step + provisioning.MarkRunning("Seeding"); + + // Assert - StartedUtc should not change + provisioning.StartedUtc.ShouldBe(firstStartedUtc); + provisioning.CurrentStep.ShouldBe("Seeding"); + } + + #endregion + + #region MarkCompleted Tests + + [Fact] + public void MarkCompleted_Should_SetStatusToCompleted() + { + // Arrange + var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + provisioning.MarkRunning("Migration"); + + // Act + provisioning.MarkCompleted(); + + // Assert + provisioning.Status.ShouldBe(TenantProvisioningStatus.Completed); + } + + [Fact] + public void MarkCompleted_Should_SetCompletedUtc() + { + // Arrange + var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var before = DateTime.UtcNow; + + // Act + provisioning.MarkCompleted(); + var after = DateTime.UtcNow; + + // Assert + provisioning.CompletedUtc.ShouldNotBeNull(); + provisioning.CompletedUtc.Value.ShouldBeGreaterThanOrEqualTo(before); + provisioning.CompletedUtc.Value.ShouldBeLessThanOrEqualTo(after); + } + + [Fact] + public void MarkCompleted_Should_ClearCurrentStep() + { + // Arrange + var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + provisioning.MarkRunning("Migration"); + + // Act + provisioning.MarkCompleted(); + + // Assert + provisioning.CurrentStep.ShouldBeNull(); + } + + [Fact] + public void MarkCompleted_Should_ClearError() + { + // Arrange + var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + provisioning.MarkFailed("Migration", "Some error"); + + // Act + provisioning.MarkCompleted(); + + // Assert + provisioning.Error.ShouldBeNull(); + } + + #endregion + + #region MarkFailed Tests + + [Fact] + public void MarkFailed_Should_SetStatusToFailed() + { + // Arrange + var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + + // Act + provisioning.MarkFailed("Migration", "Database connection failed"); + + // Assert + provisioning.Status.ShouldBe(TenantProvisioningStatus.Failed); + } + + [Fact] + public void MarkFailed_Should_SetCurrentStep() + { + // Arrange + var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + + // Act + provisioning.MarkFailed("Migration", "Database connection failed"); + + // Assert + provisioning.CurrentStep.ShouldBe("Migration"); + } + + [Fact] + public void MarkFailed_Should_SetError() + { + // Arrange + var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var error = "Database connection failed"; + + // Act + provisioning.MarkFailed("Migration", error); + + // Assert + provisioning.Error.ShouldBe(error); + } + + [Fact] + public void MarkFailed_Should_SetCompletedUtc() + { + // Arrange + var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var before = DateTime.UtcNow; + + // Act + provisioning.MarkFailed("Migration", "Error"); + var after = DateTime.UtcNow; + + // Assert + provisioning.CompletedUtc.ShouldNotBeNull(); + provisioning.CompletedUtc.Value.ShouldBeGreaterThanOrEqualTo(before); + provisioning.CompletedUtc.Value.ShouldBeLessThanOrEqualTo(after); + } + + #endregion + + #region State Transition Tests + + [Fact] + public void Provisioning_Should_SupportFullLifecycle() + { + // Arrange + var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + provisioning.Status.ShouldBe(TenantProvisioningStatus.Pending); + + // Act & Assert - Running + provisioning.MarkRunning("Step1"); + provisioning.Status.ShouldBe(TenantProvisioningStatus.Running); + + // Act & Assert - Different step + provisioning.MarkRunning("Step2"); + provisioning.Status.ShouldBe(TenantProvisioningStatus.Running); + provisioning.CurrentStep.ShouldBe("Step2"); + + // Act & Assert - Completed + provisioning.MarkCompleted(); + provisioning.Status.ShouldBe(TenantProvisioningStatus.Completed); + } + + [Fact] + public void Provisioning_Should_SupportFailureFromRunning() + { + // Arrange + var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + provisioning.MarkRunning("Migration"); + + // Act + provisioning.MarkFailed("Migration", "Connection timeout"); + + // Assert + provisioning.Status.ShouldBe(TenantProvisioningStatus.Failed); + provisioning.CurrentStep.ShouldBe("Migration"); + provisioning.Error.ShouldBe("Connection timeout"); + } + + #endregion +} diff --git a/src/Tests/Multitenacy.Tests/TenantLifecycleTests.cs b/src/Tests/Multitenacy.Tests/TenantLifecycleTests.cs new file mode 100644 index 0000000000..21dea1a4be --- /dev/null +++ b/src/Tests/Multitenacy.Tests/TenantLifecycleTests.cs @@ -0,0 +1,30 @@ +namespace Multitenancy.Tests; + +//public class TenantLifecycleTests : IClassFixture> +//{ +// private readonly WebApplicationFactory _factory; + +// public TenantLifecycleTests(WebApplicationFactory factory) +// { +// _factory = factory; +// } + +// [Fact(Skip = "Requires fully configured authentication and test tenant environment.")] +// public async Task ChangeActivation_Should_ReturnLifecycleResult() +// { +// var client = _factory.CreateClient(); + +// // This is a placeholder test structure to validate wiring once auth and tenants are seeded for tests. +// var tenantId = "root"; + +// var response = await client.PostAsJsonAsync( +// $"/api/v1/tenants/{tenantId}/activation", +// new { tenantId, isActive = true }); + +// response.StatusCode.ShouldBe(HttpStatusCode.OK); + +// var result = await response.Content.ReadFromJsonAsync(); +// result.ShouldNotBeNull(); +// result.TenantId.ShouldBe(tenantId); +// } +//} \ No newline at end of file diff --git a/src/Tests/README.md b/src/Tests/README.md new file mode 100644 index 0000000000..9d29079bae --- /dev/null +++ b/src/Tests/README.md @@ -0,0 +1,25 @@ +# Architecture Tests + +This folder contains solution-wide architecture tests for the FullStackHero .NET 10 Starter Kit. The goal is to automatically enforce layering, dependency, and naming rules as the codebase evolves. + +## Project + +- `Architecture.Tests` targets `net10.0` and lives under `src/Tests/Architecture.Tests`. +- Test dependencies are limited to `xunit`, `Shouldly`, and `AutoFixture`, with versions defined in `src/Directory.Packages.props`. + +## What Is Covered + +- **Module dependencies**: module runtime projects (`Modules.*`) cannot reference other modules' runtime projects directly; only their own runtime, contracts, and building blocks are allowed (validated via csproj inspection). +- **Feature layering**: feature types under `Modules.*.Features.v{version}` are checked with NetArchTest to depend only on allowed layers (System/Microsoft, `FSH.Framework.*`, their module, and module contracts). +- **Playground boundaries**: module code must not depend on Playground hosts, and hosts must not depend directly on module feature or data internals. +- **Namespace conventions**: selected areas (for example, `BuildingBlocks/Core/Domain`) must declare namespaces that reflect the folder structure. + +## Running the Tests + +- Run all tests (including architecture tests): `dotnet test src/FSH.Framework.slnx`. +- Architecture tests are lightweight and rely only on project and file structure; they do not require any external services or databases. + +## Extending the Rules + +- Add new rules as additional test classes inside `Architecture.Tests`, following the existing patterns (using NetArchTest for type-level rules and reflection or project file inspection where appropriate). +- Keep rules fast and deterministic; avoid environment-specific assumptions so the tests remain stable in CI. diff --git a/src/Tools/CLI/Commands/NewCommand.cs b/src/Tools/CLI/Commands/NewCommand.cs new file mode 100644 index 0000000000..b8dfd4fc75 --- /dev/null +++ b/src/Tools/CLI/Commands/NewCommand.cs @@ -0,0 +1,217 @@ +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using FSH.CLI.Models; +using FSH.CLI.Prompts; +using FSH.CLI.Scaffolding; +using FSH.CLI.UI; +using FSH.CLI.Validation; +using Spectre.Console.Cli; + +namespace FSH.CLI.Commands; + +[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by Spectre.Console.Cli via reflection")] +internal sealed class NewCommand : AsyncCommand +{ + [SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by Spectre.Console.Cli via reflection")] + internal sealed class Settings : CommandSettings + { + [CommandArgument(0, "[name]")] + [Description("The name of the project")] + public string? Name { get; set; } + + [CommandOption("-t|--type")] + [Description("Project type: api, api-blazor")] + [DefaultValue(null)] + public string? Type { get; set; } + + [CommandOption("-a|--arch")] + [Description("Architecture style: monolith, microservices, serverless")] + [DefaultValue(null)] + public string? Architecture { get; set; } + + [CommandOption("-d|--db")] + [Description("Database provider: postgres, sqlserver, sqlite")] + [DefaultValue(null)] + public string? Database { get; set; } + + [CommandOption("-p|--preset")] + [Description("Use a preset: quickstart, production, microservices, serverless")] + [DefaultValue(null)] + public string? Preset { get; set; } + + [CommandOption("-o|--output")] + [Description("Output directory")] + [DefaultValue(".")] + public string Output { get; set; } = "."; + + [CommandOption("--docker")] + [Description("Include Docker Compose")] + [DefaultValue(null)] + public bool? Docker { get; set; } + + [CommandOption("--aspire")] + [Description("Include Aspire AppHost")] + [DefaultValue(null)] + public bool? Aspire { get; set; } + + [CommandOption("--sample")] + [Description("Include sample module")] + [DefaultValue(null)] + public bool? Sample { get; set; } + + [CommandOption("--terraform")] + [Description("Include Terraform (AWS)")] + [DefaultValue(null)] + public bool? Terraform { get; set; } + + [CommandOption("--ci")] + [Description("Include GitHub Actions CI")] + [DefaultValue(null)] + public bool? CI { get; set; } + + [CommandOption("--git")] + [Description("Initialize git repository")] + [DefaultValue(null)] + public bool? Git { get; set; } + + [CommandOption("-v|--fsh-version")] + [Description("FullStackHero package version (e.g., 10.0.0 or 10.0.0-rc.1)")] + [DefaultValue(null)] + public string? FshVersion { get; set; } + + [CommandOption("--no-interactive")] + [Description("Disable interactive mode")] + [DefaultValue(false)] + public bool NoInteractive { get; set; } + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) + { + try + { + ProjectOptions options; + + if (settings.NoInteractive || HasExplicitOptions(settings)) + { + options = BuildOptionsFromSettings(settings); + + var validation = OptionValidator.Validate(options); + if (!validation.IsValid) + { + foreach (var error in validation.Errors) + { + ConsoleTheme.WriteError(error); + } + return 1; + } + } + else + { + options = ProjectWizard.Run(settings.Name, settings.FshVersion); + } + + await SolutionGenerator.GenerateAsync(options, cancellationToken); + + return 0; + } + catch (ArgumentException ex) + { + ConsoleTheme.WriteError(ex.Message); + return 1; + } + catch (InvalidOperationException ex) + { + ConsoleTheme.WriteError(ex.Message); + return 1; + } + catch (IOException ex) + { + ConsoleTheme.WriteError($"File operation failed: {ex.Message}"); + return 1; + } + } + + private static bool HasExplicitOptions(Settings settings) => + !string.IsNullOrEmpty(settings.Preset) || + !string.IsNullOrEmpty(settings.Type) || + !string.IsNullOrEmpty(settings.Architecture) || + !string.IsNullOrEmpty(settings.Database); + + private static ProjectOptions BuildOptionsFromSettings(Settings settings) + { + // If preset is specified, use it as base + if (!string.IsNullOrEmpty(settings.Preset)) + { + var preset = settings.Preset.ToUpperInvariant() switch + { + "QUICKSTART" or "QUICK" => Presets.QuickStart, + "PRODUCTION" or "PROD" => Presets.ProductionReady, + "MICROSERVICES" or "MICRO" => Presets.MicroservicesStarter, + "SERVERLESS" or "LAMBDA" => Presets.ServerlessApi, + _ => throw new ArgumentException($"Unknown preset: {settings.Preset}") + }; + + var name = settings.Name ?? throw new ArgumentException("Project name is required"); + var options = preset.ToProjectOptions(name, settings.Output); + + // Allow overrides + if (settings.Docker.HasValue) options.IncludeDocker = settings.Docker.Value; + if (settings.Aspire.HasValue) options.IncludeAspire = settings.Aspire.Value; + if (settings.Sample.HasValue) options.IncludeSampleModule = settings.Sample.Value; + if (settings.Terraform.HasValue) options.IncludeTerraform = settings.Terraform.Value; + if (settings.CI.HasValue) options.IncludeGitHubActions = settings.CI.Value; + if (settings.Git.HasValue) options.InitializeGit = settings.Git.Value; + if (!string.IsNullOrEmpty(settings.FshVersion)) options.FrameworkVersion = settings.FshVersion; + + return options; + } + + // Build from individual options + var projectName = settings.Name ?? throw new ArgumentException("Project name is required in non-interactive mode"); + + return new ProjectOptions + { + Name = projectName, + OutputPath = settings.Output, + Type = ParseProjectType(settings.Type), + Architecture = ParseArchitecture(settings.Architecture), + Database = ParseDatabase(settings.Database), + InitializeGit = settings.Git ?? true, + IncludeDocker = settings.Docker ?? true, + IncludeAspire = settings.Aspire ?? true, + IncludeSampleModule = settings.Sample ?? false, + IncludeTerraform = settings.Terraform ?? false, + IncludeGitHubActions = settings.CI ?? false, + FrameworkVersion = settings.FshVersion + }; + } + + private static ProjectType ParseProjectType(string? type) => + type?.ToUpperInvariant() switch + { + "API" => ProjectType.Api, + "API-BLAZOR" or "APIBLAZOR" or "BLAZOR" or "FULLSTACK" => ProjectType.ApiBlazor, + null => ProjectType.Api, + _ => throw new ArgumentException($"Unknown project type: {type}") + }; + + private static ArchitectureStyle ParseArchitecture(string? arch) => + arch?.ToUpperInvariant() switch + { + "MONOLITH" or "MONO" => ArchitectureStyle.Monolith, + "MICROSERVICES" or "MICRO" => ArchitectureStyle.Microservices, + "SERVERLESS" or "LAMBDA" => ArchitectureStyle.Serverless, + null => ArchitectureStyle.Monolith, + _ => throw new ArgumentException($"Unknown architecture: {arch}") + }; + + private static DatabaseProvider ParseDatabase(string? db) => + db?.ToUpperInvariant() switch + { + "POSTGRES" or "POSTGRESQL" or "PG" => DatabaseProvider.PostgreSQL, + "SQLSERVER" or "MSSQL" or "SQL" => DatabaseProvider.SqlServer, + "SQLITE" => DatabaseProvider.SQLite, + null => DatabaseProvider.PostgreSQL, + _ => throw new ArgumentException($"Unknown database provider: {db}") + }; +} diff --git a/src/Tools/CLI/FSH.CLI.csproj b/src/Tools/CLI/FSH.CLI.csproj new file mode 100644 index 0000000000..2dac6585de --- /dev/null +++ b/src/Tools/CLI/FSH.CLI.csproj @@ -0,0 +1,39 @@ + + + + Exe + net10.0 + enable + enable + + + true + fsh + FullStackHero.CLI + ./nupkg + + + FullStackHero CLI - Create and manage FullStackHero .NET projects + FSH;FullStackHero;CLI;dotnet;template;scaffold + README.md + MIT + https://github.com/fullstackhero/dotnet-starter-kit + + + + + + + + + + + + + + + + + + + diff --git a/src/Tools/CLI/Models/Preset.cs b/src/Tools/CLI/Models/Preset.cs new file mode 100644 index 0000000000..de20e970ae --- /dev/null +++ b/src/Tools/CLI/Models/Preset.cs @@ -0,0 +1,100 @@ +namespace FSH.CLI.Models; + +internal sealed class Preset +{ + public required string Name { get; init; } + public required string Description { get; init; } + public required ProjectType Type { get; init; } + public required ArchitectureStyle Architecture { get; init; } + public required DatabaseProvider Database { get; init; } + public required bool IncludeDocker { get; init; } + public required bool IncludeAspire { get; init; } + public required bool IncludeSampleModule { get; init; } + public required bool IncludeTerraform { get; init; } + public required bool IncludeGitHubActions { get; init; } + + public ProjectOptions ToProjectOptions(string projectName, string outputPath) => new() + { + Name = projectName, + Type = Type, + Architecture = Architecture, + Database = Database, + IncludeDocker = IncludeDocker, + IncludeAspire = IncludeAspire, + IncludeSampleModule = IncludeSampleModule, + IncludeTerraform = IncludeTerraform, + IncludeGitHubActions = IncludeGitHubActions, + OutputPath = outputPath + }; +} + +internal static class Presets +{ + public static Preset QuickStart { get; } = new() + { + Name = "Quick Start", + Description = "API + Monolith + PostgreSQL + Docker + Sample Module", + Type = ProjectType.Api, + Architecture = ArchitectureStyle.Monolith, + Database = DatabaseProvider.PostgreSQL, + IncludeDocker = true, + IncludeAspire = false, + IncludeSampleModule = true, + IncludeTerraform = false, + IncludeGitHubActions = false + }; + + public static Preset ProductionReady { get; } = new() + { + Name = "Production Ready", + Description = "API + Blazor + Monolith + PostgreSQL + Aspire + Terraform + CI", + Type = ProjectType.ApiBlazor, + Architecture = ArchitectureStyle.Monolith, + Database = DatabaseProvider.PostgreSQL, + IncludeDocker = true, + IncludeAspire = true, + IncludeSampleModule = false, + IncludeTerraform = true, + IncludeGitHubActions = true + }; + + public static Preset MicroservicesStarter { get; } = new() + { + Name = "Microservices Starter", + Description = "API + Microservices + PostgreSQL + Docker + Aspire", + Type = ProjectType.Api, + Architecture = ArchitectureStyle.Microservices, + Database = DatabaseProvider.PostgreSQL, + IncludeDocker = true, + IncludeAspire = true, + IncludeSampleModule = false, + IncludeTerraform = false, + IncludeGitHubActions = false + }; + + public static Preset ServerlessApi { get; } = new() + { + Name = "Serverless API", + Description = "API + Serverless (AWS Lambda) + PostgreSQL + Terraform", + Type = ProjectType.Api, + Architecture = ArchitectureStyle.Serverless, + Database = DatabaseProvider.PostgreSQL, + IncludeDocker = false, + IncludeAspire = false, + IncludeSampleModule = false, + IncludeTerraform = true, + IncludeGitHubActions = false + }; + + public static IReadOnlyList All { get; } = + [ + QuickStart, + ProductionReady, + MicroservicesStarter, + ServerlessApi + ]; + + public static Preset? GetByName(string name) => + All.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase) || + p.Name.Replace(" ", string.Empty, StringComparison.Ordinal).Equals(name, StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/Tools/CLI/Models/ProjectOptions.cs b/src/Tools/CLI/Models/ProjectOptions.cs new file mode 100644 index 0000000000..3e709f9ae2 --- /dev/null +++ b/src/Tools/CLI/Models/ProjectOptions.cs @@ -0,0 +1,41 @@ +namespace FSH.CLI.Models; + +internal sealed class ProjectOptions +{ + public required string Name { get; set; } + public ProjectType Type { get; set; } = ProjectType.Api; + public ArchitectureStyle Architecture { get; set; } = ArchitectureStyle.Monolith; + public DatabaseProvider Database { get; set; } = DatabaseProvider.PostgreSQL; + public bool IncludeDocker { get; set; } = true; + public bool IncludeAspire { get; set; } = true; + public bool IncludeSampleModule { get; set; } + public bool IncludeTerraform { get; set; } + public bool IncludeGitHubActions { get; set; } + public bool InitializeGit { get; set; } = true; + public string OutputPath { get; set; } = "."; + + /// + /// Version of FullStackHero packages to use. If null, uses the CLI's version. + /// + public string? FrameworkVersion { get; set; } +} + +internal enum ProjectType +{ + Api, + ApiBlazor +} + +internal enum ArchitectureStyle +{ + Monolith, + Microservices, + Serverless +} + +internal enum DatabaseProvider +{ + PostgreSQL, + SqlServer, + SQLite +} diff --git a/src/Tools/CLI/Program.cs b/src/Tools/CLI/Program.cs new file mode 100644 index 0000000000..c3214e8c26 --- /dev/null +++ b/src/Tools/CLI/Program.cs @@ -0,0 +1,26 @@ +using FSH.CLI.Commands; +using Spectre.Console.Cli; + +var app = new CommandApp(); + +app.Configure(config => +{ + config.SetApplicationName("fsh"); + config.SetApplicationVersion(GetVersion()); + + config.AddCommand("new") + .WithDescription("Create a new FullStackHero project") + .WithExample("new") + .WithExample("new", "MyApp") + .WithExample("new", "MyApp", "--preset", "quickstart") + .WithExample("new", "MyApp", "--type", "api-blazor", "--arch", "monolith", "--db", "postgres"); +}); + +return await app.RunAsync(args); + +static string GetVersion() +{ + var assembly = typeof(Program).Assembly; + var version = assembly.GetName().Version; + return version?.ToString(3) ?? "1.0.0"; +} diff --git a/src/Tools/CLI/Prompts/ProjectWizard.cs b/src/Tools/CLI/Prompts/ProjectWizard.cs new file mode 100644 index 0000000000..d3f6e19b2d --- /dev/null +++ b/src/Tools/CLI/Prompts/ProjectWizard.cs @@ -0,0 +1,329 @@ +using System.Reflection; +using FSH.CLI.Models; +using FSH.CLI.UI; +using FSH.CLI.Validation; +using Spectre.Console; + +namespace FSH.CLI.Prompts; + +internal static class ProjectWizard +{ + public static ProjectOptions Run(string? initialName = null, string? initialVersion = null) + { + ConsoleTheme.WriteBanner(); + + // Step 1: Choose preset or custom + var startChoice = PromptStartChoice(); + + if (startChoice != "Custom") + { + var preset = Presets.All.First(p => p.Name == startChoice); + var presetName = PromptProjectName(initialName); + var presetPath = PromptOutputPath(); + var presetVersion = PromptFrameworkVersion(initialVersion); + + var presetOptions = preset.ToProjectOptions(presetName, presetPath); + presetOptions.FrameworkVersion = presetVersion; + + ShowSummary(presetOptions); + return presetOptions; + } + + // Custom flow + var name = PromptProjectName(initialName); + var type = PromptProjectType(); + var architecture = PromptArchitecture(type); + var database = PromptDatabase(architecture); + var features = PromptFeatures(architecture); + var outputPath = PromptOutputPath(); + var frameworkVersion = PromptFrameworkVersion(initialVersion); + + var options = new ProjectOptions + { + Name = name, + Type = type, + Architecture = architecture, + Database = database, + InitializeGit = features.Contains("Git Repository"), + IncludeDocker = features.Contains("Docker Compose"), + IncludeAspire = features.Contains("Aspire AppHost"), + IncludeSampleModule = features.Contains("Sample Module (Catalog)"), + IncludeTerraform = features.Contains("Terraform (AWS)"), + IncludeGitHubActions = features.Contains("GitHub Actions CI"), + OutputPath = outputPath, + FrameworkVersion = frameworkVersion + }; + + ShowSummary(options); + return options; + } + + private static string PromptStartChoice() + { + var choices = new List { "Custom" }; + choices.AddRange(Presets.All.Select(p => p.Name)); + + var choice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("[dim]Select template[/]") + .PageSize(10) + .HighlightStyle(ConsoleTheme.PrimaryStyle) + .AddChoices(choices) + .UseConverter(c => + { + if (c == "Custom") + return "Custom [dim]- configure manually[/]"; + + var preset = Presets.All.First(p => p.Name == c); + return $"{preset.Name} [dim]- {preset.Description}[/]"; + })); + + return choice; + } + + private static string PromptProjectName(string? initialName) + { + if (!string.IsNullOrWhiteSpace(initialName) && OptionValidator.IsValidProjectName(initialName)) + { + return initialName; + } + + return AnsiConsole.Prompt( + new TextPrompt("[dim]Project name:[/]") + .PromptStyle(ConsoleTheme.PrimaryStyle) + .ValidationErrorMessage("[red]Invalid name[/]") + .Validate(name => + { + if (string.IsNullOrWhiteSpace(name)) + return Spectre.Console.ValidationResult.Error("Required"); + + if (!char.IsLetter(name[0])) + return Spectre.Console.ValidationResult.Error("Must start with a letter"); + + if (!name.All(c => char.IsLetterOrDigit(c) || c == '_' || c == '-' || c == '.')) + return Spectre.Console.ValidationResult.Error("Only letters, numbers, _, -, or ."); + + return Spectre.Console.ValidationResult.Success(); + })); + } + + private static ProjectType PromptProjectType() + { + var choice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("[dim]Project type[/]") + .HighlightStyle(ConsoleTheme.PrimaryStyle) + .AddChoices("API", "API + Blazor")); + + return choice == "API" ? ProjectType.Api : ProjectType.ApiBlazor; + } + + private static ArchitectureStyle PromptArchitecture(ProjectType projectType) + { + var choices = new List + { + "Monolith", + "Microservices" + }; + + // Serverless not available with Blazor + if (projectType == ProjectType.Api) + { + choices.Add("Serverless"); + } + + var choice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("[dim]Architecture[/]") + .HighlightStyle(ConsoleTheme.PrimaryStyle) + .AddChoices(choices)); + + return choice switch + { + "Monolith" => ArchitectureStyle.Monolith, + "Microservices" => ArchitectureStyle.Microservices, + "Serverless" => ArchitectureStyle.Serverless, + _ => ArchitectureStyle.Monolith + }; + } + + private static DatabaseProvider PromptDatabase(ArchitectureStyle architecture) + { + var choices = new List + { + "PostgreSQL", + "SQL Server" + }; + + // SQLite not available with Microservices + if (architecture != ArchitectureStyle.Microservices) + { + choices.Add("SQLite"); + } + + var choice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("[dim]Database[/]") + .HighlightStyle(ConsoleTheme.PrimaryStyle) + .AddChoices(choices)); + + return choice switch + { + "PostgreSQL" => DatabaseProvider.PostgreSQL, + "SQL Server" => DatabaseProvider.SqlServer, + "SQLite" => DatabaseProvider.SQLite, + _ => DatabaseProvider.PostgreSQL + }; + } + + private static List PromptFeatures(ArchitectureStyle architecture) + { + var choices = new List + { + "Git Repository", + "Docker Compose", + "Sample Module (Catalog)", + "Terraform (AWS)", + "GitHub Actions CI" + }; + + // Aspire not available with Serverless + if (architecture != ArchitectureStyle.Serverless) + { + choices.Insert(2, "Aspire AppHost"); + } + + var defaults = new List { "Git Repository", "Docker Compose" }; + if (architecture != ArchitectureStyle.Serverless) + { + defaults.Add("Aspire AppHost"); + } + + var prompt = new MultiSelectionPrompt() + .Title("[dim]Features[/] [dim italic](space to toggle)[/]") + .HighlightStyle(ConsoleTheme.PrimaryStyle) + .InstructionsText("") + .AddChoices(choices); + + foreach (var item in defaults) + { + prompt.Select(item); + } + + return AnsiConsole.Prompt(prompt); + } + + private static string PromptOutputPath() + { + var useCurrentDir = AnsiConsole.Confirm("[dim]Create in current directory?[/]", true); + + if (useCurrentDir) + { + return "."; + } + + return AnsiConsole.Prompt( + new TextPrompt("[dim]Output path:[/]") + .PromptStyle(ConsoleTheme.PrimaryStyle) + .DefaultValue(".") + .ValidationErrorMessage("[red]Invalid path[/]") + .Validate(path => + { + if (string.IsNullOrWhiteSpace(path)) + return Spectre.Console.ValidationResult.Error("Required"); + + return Spectre.Console.ValidationResult.Success(); + })); + } + + private static string? PromptFrameworkVersion(string? initialVersion) + { + // If a version was provided via CLI, use it + if (!string.IsNullOrWhiteSpace(initialVersion)) + { + return initialVersion; + } + + var defaultVersion = GetDefaultFrameworkVersion(); + + var useDefault = AnsiConsole.Confirm( + $"[dim]Use default FSH version[/] [cyan]{defaultVersion}[/][dim]?[/]", + true); + + if (useDefault) + { + return null; // null means use CLI's version + } + + return AnsiConsole.Prompt( + new TextPrompt("[dim]FSH version:[/]") + .PromptStyle(ConsoleTheme.PrimaryStyle) + .DefaultValue(defaultVersion) + .ValidationErrorMessage("[red]Invalid version[/]") + .Validate(version => + { + if (string.IsNullOrWhiteSpace(version)) + return Spectre.Console.ValidationResult.Error("Required"); + + // Basic semver validation + if (!System.Text.RegularExpressions.Regex.IsMatch(version, @"^\d+\.\d+\.\d+(-[\w\d\.]+)?$")) + return Spectre.Console.ValidationResult.Error("Use semver format (e.g., 10.0.0)"); + + return Spectre.Console.ValidationResult.Success(); + })); + } + + private static string GetDefaultFrameworkVersion() + { + var assembly = Assembly.GetExecutingAssembly(); + var version = assembly.GetCustomAttribute()?.InformationalVersion + ?? assembly.GetName().Version?.ToString() + ?? "10.0.0"; + + // Remove any +buildmetadata suffix + var plusIndex = version.IndexOf('+', StringComparison.Ordinal); + return plusIndex > 0 ? version[..plusIndex] : version; + } + + private static void ShowSummary(ProjectOptions options) + { + ConsoleTheme.WriteHeader("Configuration"); + + ConsoleTheme.WriteKeyValue("Name", options.Name, highlight: true); + ConsoleTheme.WriteKeyValue("Type", FormatEnum(options.Type)); + ConsoleTheme.WriteKeyValue("Architecture", FormatEnum(options.Architecture)); + ConsoleTheme.WriteKeyValue("Database", FormatEnum(options.Database)); + ConsoleTheme.WriteKeyValue("Version", options.FrameworkVersion ?? GetDefaultFrameworkVersion()); + ConsoleTheme.WriteKeyValue("Output", options.OutputPath); + + // Build features list + var features = new List(); + if (options.InitializeGit) features.Add("Git"); + if (options.IncludeDocker) features.Add("Docker"); + if (options.IncludeAspire) features.Add("Aspire"); + if (options.IncludeSampleModule) features.Add("Sample"); + if (options.IncludeTerraform) features.Add("Terraform"); + if (options.IncludeGitHubActions) features.Add("CI"); + + ConsoleTheme.WriteKeyValue("Features", features.Count > 0 ? string.Join(", ", features) : "none"); + + AnsiConsole.WriteLine(); + + if (!AnsiConsole.Confirm("Create project?", true)) + { + AnsiConsole.MarkupLine("[dim]Cancelled.[/]"); + Environment.Exit(0); + } + } + + private static string FormatEnum(T value) where T : Enum => + value.ToString() switch + { + "Api" => "API", + "ApiBlazor" => "API + Blazor", + "PostgreSQL" => "PostgreSQL", + "SqlServer" => "SQL Server", + "SQLite" => "SQLite", + _ => value.ToString() + }; +} diff --git a/src/Tools/CLI/README.md b/src/Tools/CLI/README.md new file mode 100644 index 0000000000..d1cd0cbf9e --- /dev/null +++ b/src/Tools/CLI/README.md @@ -0,0 +1,59 @@ +# FSH.CLI - FullStackHero Command Line Interface + +A powerful CLI tool for creating and managing FullStackHero .NET projects. + +## Installation + +```bash +dotnet tool install -g FSH.CLI +``` + +## Usage + +### Create a new project + +```bash +# Interactive wizard +fsh new + +# Using a preset +fsh new MyApp --preset quickstart + +# Full customization (non-interactive) +fsh new MyApp --type api-blazor --arch monolith --db postgres +``` + +### Presets + +| Preset | Description | +|--------|-------------| +| `quickstart` | API + Monolith + PostgreSQL + Docker + Sample Module | +| `production` | API + Blazor + Monolith + PostgreSQL + Aspire + Terraform + CI | +| `microservices` | API + Microservices + PostgreSQL + Docker + Aspire | +| `serverless` | API + Serverless (AWS Lambda) + PostgreSQL + Terraform | + +### Options + +| Option | Values | Default | +|--------|--------|---------| +| `--type` | `api`, `api-blazor` | `api` | +| `--arch` | `monolith`, `microservices`, `serverless` | `monolith` | +| `--db` | `postgres`, `sqlserver`, `sqlite` | `postgres` | +| `--docker` | `true`, `false` | `true` | +| `--aspire` | `true`, `false` | `true` | +| `--sample` | `true`, `false` | `false` | +| `--terraform` | `true`, `false` | `false` | +| `--ci` | `true`, `false` | `false` | + +## Features + +- Interactive wizard with rich TUI +- Multiple architecture styles (Monolith, Microservices, Serverless) +- Database provider selection +- Docker and Aspire support +- Terraform infrastructure templates +- GitHub Actions CI/CD + +## License + +MIT diff --git a/src/Tools/CLI/Scaffolding/SolutionGenerator.cs b/src/Tools/CLI/Scaffolding/SolutionGenerator.cs new file mode 100644 index 0000000000..15184bd49f --- /dev/null +++ b/src/Tools/CLI/Scaffolding/SolutionGenerator.cs @@ -0,0 +1,445 @@ +using System.Diagnostics; +using FSH.CLI.Models; +using FSH.CLI.UI; +using Spectre.Console; + +namespace FSH.CLI.Scaffolding; + +internal static class SolutionGenerator +{ + public static async Task GenerateAsync(ProjectOptions options, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + + var projectPath = Path.Combine(options.OutputPath, options.Name); + + if (Directory.Exists(projectPath) && + Directory.EnumerateFileSystemEntries(projectPath).Any() && + !await AnsiConsole.ConfirmAsync($"[dim]Directory[/] [yellow]{projectPath}[/] [dim]exists. Overwrite?[/]", false, cancellationToken)) + { + AnsiConsole.MarkupLine("[dim]Cancelled.[/]"); + return; + } + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[dim]Creating project...[/]"); + + // Create directory structure + ConsoleTheme.WriteStep("Directory structure"); + await CreateDirectoryStructureAsync(projectPath, options); + + // Create solution file + ConsoleTheme.WriteStep("Solution file"); + await CreateSolutionFileAsync(projectPath, options); + + // Create API project + ConsoleTheme.WriteStep("API project"); + await CreateApiProjectAsync(projectPath, options); + + // Create Blazor project if needed + if (options.Type == ProjectType.ApiBlazor) + { + ConsoleTheme.WriteStep("Blazor project"); + await CreateBlazorProjectAsync(projectPath, options); + } + + // Create migrations project + ConsoleTheme.WriteStep("Migrations project"); + await CreateMigrationsProjectAsync(projectPath, options); + + // Create AppHost if Aspire enabled + if (options.IncludeAspire) + { + ConsoleTheme.WriteStep("Aspire AppHost"); + await CreateAspireAppHostAsync(projectPath, options); + } + + // Create Docker Compose if enabled + if (options.IncludeDocker) + { + ConsoleTheme.WriteStep("Docker Compose"); + await CreateDockerComposeAsync(projectPath, options); + } + + // Create sample module if enabled + if (options.IncludeSampleModule) + { + ConsoleTheme.WriteStep("Sample module"); + await CreateSampleModuleAsync(projectPath, options); + } + + // Create Terraform if enabled + if (options.IncludeTerraform) + { + ConsoleTheme.WriteStep("Terraform"); + await CreateTerraformAsync(projectPath, options); + } + + // Create GitHub Actions if enabled + if (options.IncludeGitHubActions) + { + ConsoleTheme.WriteStep("GitHub Actions"); + await CreateGitHubActionsAsync(projectPath, options); + } + + // Create common files + ConsoleTheme.WriteStep("Common files"); + await CreateCommonFilesAsync(projectPath, options); + + // Initialize git repository if enabled + if (options.InitializeGit) + { + ConsoleTheme.WriteStep("Git repository"); + await InitializeGitRepositoryAsync(projectPath); + } + + // Run dotnet restore + await RunDotnetRestoreAsync(projectPath, options); + + // Show next steps + ShowNextSteps(options); + } + + private static Task CreateDirectoryStructureAsync(string projectPath, ProjectOptions options) + { + var directories = new List + { + "src", + $"src/{options.Name}.Api", + $"src/{options.Name}.Api/Properties", + $"src/{options.Name}.Migrations" + }; + + if (options.Type == ProjectType.ApiBlazor) + { + directories.Add($"src/{options.Name}.Blazor"); + directories.Add($"src/{options.Name}.Blazor/Pages"); + directories.Add($"src/{options.Name}.Blazor/Shared"); + directories.Add($"src/{options.Name}.Blazor/wwwroot"); + } + + if (options.IncludeAspire) + { + directories.Add($"src/{options.Name}.AppHost"); + directories.Add($"src/{options.Name}.AppHost/Properties"); + } + + if (options.IncludeSampleModule) + { + directories.Add($"src/Modules/{options.Name}.Catalog"); + directories.Add($"src/Modules/{options.Name}.Catalog.Contracts"); + } + + if (options.IncludeTerraform) + { + directories.Add("terraform"); + } + + if (options.IncludeGitHubActions) + { + directories.Add(".github/workflows"); + } + + foreach (var dir in directories) + { + Directory.CreateDirectory(Path.Combine(projectPath, dir)); + } + + return Task.CompletedTask; + } + + private static async Task CreateSolutionFileAsync(string projectPath, ProjectOptions options) + { + var slnContent = TemplateEngine.GenerateSolution(options); + await File.WriteAllTextAsync(Path.Combine(projectPath, "src", $"{options.Name}.slnx"), slnContent); + } + + private static async Task CreateApiProjectAsync(string projectPath, ProjectOptions options) + { + var apiPath = Path.Combine(projectPath, "src", $"{options.Name}.Api"); + + // Create .csproj + var csproj = TemplateEngine.GenerateApiCsproj(options); + await File.WriteAllTextAsync(Path.Combine(apiPath, $"{options.Name}.Api.csproj"), csproj); + + // Create Program.cs + var program = TemplateEngine.GenerateApiProgram(options); + await File.WriteAllTextAsync(Path.Combine(apiPath, "Program.cs"), program); + + // Create appsettings.json + var appsettings = TemplateEngine.GenerateAppSettings(options); + await File.WriteAllTextAsync(Path.Combine(apiPath, "appsettings.json"), appsettings); + + // Create appsettings.Development.json + var appsettingsDev = TemplateEngine.GenerateAppSettingsDevelopment(); + await File.WriteAllTextAsync(Path.Combine(apiPath, "appsettings.Development.json"), appsettingsDev); + + // Create Properties directory and launchSettings.json + Directory.CreateDirectory(Path.Combine(apiPath, "Properties")); + var launchSettings = TemplateEngine.GenerateApiLaunchSettings(options); + await File.WriteAllTextAsync(Path.Combine(apiPath, "Properties", "launchSettings.json"), launchSettings); + + // Create Dockerfile + var dockerfile = TemplateEngine.GenerateDockerfile(options); + await File.WriteAllTextAsync(Path.Combine(apiPath, "Dockerfile"), dockerfile); + } + + private static async Task CreateBlazorProjectAsync(string projectPath, ProjectOptions options) + { + var blazorPath = Path.Combine(projectPath, "src", $"{options.Name}.Blazor"); + + // Create .csproj + var csproj = TemplateEngine.GenerateBlazorCsproj(); + await File.WriteAllTextAsync(Path.Combine(blazorPath, $"{options.Name}.Blazor.csproj"), csproj); + + // Create Program.cs + var program = TemplateEngine.GenerateBlazorProgram(options); + await File.WriteAllTextAsync(Path.Combine(blazorPath, "Program.cs"), program); + + // Create _Imports.razor + var imports = TemplateEngine.GenerateBlazorImports(options); + await File.WriteAllTextAsync(Path.Combine(blazorPath, "_Imports.razor"), imports); + + // Create App.razor + var app = TemplateEngine.GenerateBlazorApp(); + await File.WriteAllTextAsync(Path.Combine(blazorPath, "App.razor"), app); + + // Create wwwroot directory + Directory.CreateDirectory(Path.Combine(blazorPath, "wwwroot")); + + // Create Shared directory and MainLayout.razor + Directory.CreateDirectory(Path.Combine(blazorPath, "Shared")); + var mainLayout = TemplateEngine.GenerateBlazorMainLayout(options); + await File.WriteAllTextAsync(Path.Combine(blazorPath, "Shared", "MainLayout.razor"), mainLayout); + + // Create Pages directory + Directory.CreateDirectory(Path.Combine(blazorPath, "Pages")); + + // Create Index.razor + var index = TemplateEngine.GenerateBlazorIndexPage(options); + await File.WriteAllTextAsync(Path.Combine(blazorPath, "Pages", "Index.razor"), index); + } + + private static async Task CreateMigrationsProjectAsync(string projectPath, ProjectOptions options) + { + var migrationsPath = Path.Combine(projectPath, "src", $"{options.Name}.Migrations"); + + // Create .csproj + var csproj = TemplateEngine.GenerateMigrationsCsproj(options); + await File.WriteAllTextAsync(Path.Combine(migrationsPath, $"{options.Name}.Migrations.csproj"), csproj); + } + + private static async Task CreateAspireAppHostAsync(string projectPath, ProjectOptions options) + { + var appHostPath = Path.Combine(projectPath, "src", $"{options.Name}.AppHost"); + + // Create .csproj + var csproj = TemplateEngine.GenerateAppHostCsproj(options); + await File.WriteAllTextAsync(Path.Combine(appHostPath, $"{options.Name}.AppHost.csproj"), csproj); + + // Create Program.cs + var program = TemplateEngine.GenerateAppHostProgram(options); + await File.WriteAllTextAsync(Path.Combine(appHostPath, "Program.cs"), program); + + // Create Properties directory and launchSettings.json + Directory.CreateDirectory(Path.Combine(appHostPath, "Properties")); + var launchSettings = TemplateEngine.GenerateAppHostLaunchSettings(options); + await File.WriteAllTextAsync(Path.Combine(appHostPath, "Properties", "launchSettings.json"), launchSettings); + } + + private static async Task CreateDockerComposeAsync(string projectPath, ProjectOptions options) + { + var dockerCompose = TemplateEngine.GenerateDockerCompose(options); + await File.WriteAllTextAsync(Path.Combine(projectPath, "docker-compose.yml"), dockerCompose); + + var dockerComposeOverride = TemplateEngine.GenerateDockerComposeOverride(); + await File.WriteAllTextAsync(Path.Combine(projectPath, "docker-compose.override.yml"), dockerComposeOverride); + } + + private static async Task CreateSampleModuleAsync(string projectPath, ProjectOptions options) + { + var modulePath = Path.Combine(projectPath, "src", "Modules", $"{options.Name}.Catalog"); + var contractsPath = Path.Combine(projectPath, "src", "Modules", $"{options.Name}.Catalog.Contracts"); + + // Create Contracts project + var contractsCsproj = TemplateEngine.GenerateCatalogContractsCsproj(); + await File.WriteAllTextAsync(Path.Combine(contractsPath, $"{options.Name}.Catalog.Contracts.csproj"), contractsCsproj); + + // Create Module project + var moduleCsproj = TemplateEngine.GenerateCatalogModuleCsproj(options); + await File.WriteAllTextAsync(Path.Combine(modulePath, $"{options.Name}.Catalog.csproj"), moduleCsproj); + + // Create CatalogModule.cs + var catalogModule = TemplateEngine.GenerateCatalogModule(options); + Directory.CreateDirectory(modulePath); + await File.WriteAllTextAsync(Path.Combine(modulePath, "CatalogModule.cs"), catalogModule); + + // Create Features directory with sample endpoint + var featuresPath = Path.Combine(modulePath, "Features", "v1", "Products"); + Directory.CreateDirectory(featuresPath); + + var getProducts = TemplateEngine.GenerateGetProductsEndpoint(options); + await File.WriteAllTextAsync(Path.Combine(featuresPath, "GetProductsEndpoint.cs"), getProducts); + } + + private static async Task CreateTerraformAsync(string projectPath, ProjectOptions options) + { + var terraformPath = Path.Combine(projectPath, "terraform"); + + var mainTf = TemplateEngine.GenerateTerraformMain(options); + await File.WriteAllTextAsync(Path.Combine(terraformPath, "main.tf"), mainTf); + + var variablesTf = TemplateEngine.GenerateTerraformVariables(options); + await File.WriteAllTextAsync(Path.Combine(terraformPath, "variables.tf"), variablesTf); + + var outputsTf = TemplateEngine.GenerateTerraformOutputs(options); + await File.WriteAllTextAsync(Path.Combine(terraformPath, "outputs.tf"), outputsTf); + } + + private static async Task CreateGitHubActionsAsync(string projectPath, ProjectOptions options) + { + var workflowsPath = Path.Combine(projectPath, ".github", "workflows"); + + var ciYaml = TemplateEngine.GenerateGitHubActionsCI(options); + await File.WriteAllTextAsync(Path.Combine(workflowsPath, "ci.yml"), ciYaml); + } + + private static async Task CreateCommonFilesAsync(string projectPath, ProjectOptions options) + { + // Create .gitignore + var gitignore = TemplateEngine.GenerateGitignore(); + await File.WriteAllTextAsync(Path.Combine(projectPath, ".gitignore"), gitignore); + + // Create .editorconfig + var editorconfig = TemplateEngine.GenerateEditorConfig(); + await File.WriteAllTextAsync(Path.Combine(projectPath, ".editorconfig"), editorconfig); + + // Create global.json + var globalJson = TemplateEngine.GenerateGlobalJson(); + await File.WriteAllTextAsync(Path.Combine(projectPath, "global.json"), globalJson); + + // Create Directory.Build.props + var buildProps = TemplateEngine.GenerateDirectoryBuildProps(options); + await File.WriteAllTextAsync(Path.Combine(projectPath, "src", "Directory.Build.props"), buildProps); + + // Create Directory.Packages.props + var packagesProps = TemplateEngine.GenerateDirectoryPackagesProps(options); + await File.WriteAllTextAsync(Path.Combine(projectPath, "src", "Directory.Packages.props"), packagesProps); + + // Create README.md + var readme = TemplateEngine.GenerateReadme(options); + await File.WriteAllTextAsync(Path.Combine(projectPath, "README.md"), readme); + } + + private static async Task InitializeGitRepositoryAsync(string projectPath) + { + // Run git init + using var initProcess = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "git", + Arguments = "init", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = projectPath + } + }; + + try + { + initProcess.Start(); + await initProcess.WaitForExitAsync(); + + if (initProcess.ExitCode != 0) + { + var error = await initProcess.StandardError.ReadToEndAsync(); + ConsoleTheme.WriteWarning($"git init failed: {error}"); + return; + } + + // Run dotnet new gitignore to get a comprehensive .NET gitignore + using var gitignoreProcess = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = "new gitignore --force", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = projectPath + } + }; + + gitignoreProcess.Start(); + await gitignoreProcess.WaitForExitAsync(); + + // If dotnet new gitignore fails, we already have our basic .gitignore so it's fine + } + catch (System.ComponentModel.Win32Exception ex) + { + // Git not installed or not in PATH + ConsoleTheme.WriteWarning($"Could not initialize git repository (is git installed?): {ex.Message}"); + } + catch (InvalidOperationException ex) + { + ConsoleTheme.WriteWarning($"Could not initialize git repository: {ex.Message}"); + } + } + + private static async Task RunDotnetRestoreAsync(string projectPath, ProjectOptions options) + { + ConsoleTheme.WriteStep("Restoring packages"); + + var slnPath = Path.Combine(projectPath, "src", $"{options.Name}.slnx"); + + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"restore \"{slnPath}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = projectPath + } + }; + + process.Start(); + await process.WaitForExitAsync(); + + if (process.ExitCode != 0) + { + var error = await process.StandardError.ReadToEndAsync(); + ConsoleTheme.WriteWarning($"Restore warnings: {error}"); + } + } + + private static void ShowNextSteps(ProjectOptions options) + { + ConsoleTheme.WriteDone($"Created [bold]{options.Name}[/]"); + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[dim]Get started:[/]"); + AnsiConsole.MarkupLine($" cd {options.Name}"); + + if (options.IncludeAspire) + { + AnsiConsole.MarkupLine($" dotnet run --project src/{options.Name}.AppHost"); + } + else + { + AnsiConsole.MarkupLine($" dotnet run --project src/{options.Name}.Api"); + } + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[dim]Docs:[/] https://fullstackhero.net"); + AnsiConsole.WriteLine(); + } +} diff --git a/src/Tools/CLI/Scaffolding/TemplateEngine.cs b/src/Tools/CLI/Scaffolding/TemplateEngine.cs new file mode 100644 index 0000000000..51e7a55a5f --- /dev/null +++ b/src/Tools/CLI/Scaffolding/TemplateEngine.cs @@ -0,0 +1,1645 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using FSH.CLI.Models; + +namespace FSH.CLI.Scaffolding; + +[SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "Lowercase is required for Docker, Terraform, and GitHub Actions naming conventions")] +internal static class TemplateEngine +{ + private static readonly string FrameworkVersion = GetFrameworkVersion(); + + private static string GetFrameworkVersion() + { + var assembly = Assembly.GetExecutingAssembly(); + var version = assembly.GetCustomAttribute()?.InformationalVersion + ?? assembly.GetName().Version?.ToString() + ?? "10.0.0"; + + // Remove any +buildmetadata suffix (e.g., "10.0.0-rc.1+abc123" -> "10.0.0-rc.1") + var plusIndex = version.IndexOf('+', StringComparison.Ordinal); + return plusIndex > 0 ? version[..plusIndex] : version; + } + + public static string GenerateSolution(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var projects = new List + { + $""" """, + $""" """ + }; + + if (options.Type == ProjectType.ApiBlazor) + { + projects.Add($""" """); + } + + if (options.IncludeAspire) + { + projects.Add($""" """); + } + + if (options.IncludeSampleModule) + { + projects.Add($""" """); + projects.Add($""" """); + } + + return $$""" + + + {{string.Join(Environment.NewLine, projects)}} + + + + + + + + """; + } + + public static string GenerateApiCsproj(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var serverless = options.Architecture == ArchitectureStyle.Serverless; + + var sampleModuleRef = options.IncludeSampleModule + ? $""" + + + + + + """ + : string.Empty; + + return $$""" + + + + net10.0 + enable + enable + {{(serverless ? " Library" : "")}} + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + {{(serverless ? """ + + + + + + """ : "")}}{{sampleModuleRef}} + + """; + } + + public static string GenerateApiProgram(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var serverless = options.Architecture == ArchitectureStyle.Serverless; + + if (serverless) + { + var serverlessModuleUsing = options.IncludeSampleModule + ? $"using {options.Name}.Catalog;\n" + : string.Empty; + + var serverlessModuleAssembly = options.IncludeSampleModule + ? $",\n typeof(CatalogModule).Assembly" + : string.Empty; + + return $$""" + {{serverlessModuleUsing}}using FSH.Framework.Web; + using FSH.Framework.Web.Modules; + using System.Reflection; + + var builder = WebApplication.CreateBuilder(args); + + // Add AWS Lambda hosting + builder.Services.AddAWSLambdaHosting(LambdaEventSource.RestApi); + + // Add FSH Platform + builder.AddHeroPlatform(platform => + { + platform.EnableOpenApi = true; + platform.EnableCaching = true; + }); + + // Add modules + var moduleAssemblies = new Assembly[] + { + typeof(Program).Assembly{{serverlessModuleAssembly}} + }; + builder.AddModules(moduleAssemblies); + + var app = builder.Build(); + + // Use FSH Platform + app.UseHeroPlatform(platform => + { + platform.MapModules = true; + }); + + await app.RunAsync(); + """; + } + + var sampleModuleUsing = options.IncludeSampleModule + ? $"using {options.Name}.Catalog;\n" + : string.Empty; + + var sampleModuleAssembly = options.IncludeSampleModule + ? ",\n typeof(CatalogModule).Assembly" + : string.Empty; + + return $$""" + {{sampleModuleUsing}}using FSH.Framework.Web; + using FSH.Framework.Web.Modules; + using FSH.Modules.Auditing; + using FSH.Modules.Identity; + using FSH.Modules.Identity.Contracts.v1.Tokens.TokenGeneration; + using FSH.Modules.Identity.Features.v1.Tokens.TokenGeneration; + using FSH.Modules.Multitenancy; + using FSH.Modules.Multitenancy.Contracts.v1.GetTenantStatus; + using FSH.Modules.Multitenancy.Features.v1.GetTenantStatus; + using System.Reflection; + + var builder = WebApplication.CreateBuilder(args); + + // Configure Mediator with required assemblies + builder.Services.AddMediator(o => + { + o.ServiceLifetime = ServiceLifetime.Scoped; + o.Assemblies = [ + typeof(GenerateTokenCommand), + typeof(GenerateTokenCommandHandler), + typeof(GetTenantStatusQuery), + typeof(GetTenantStatusQueryHandler), + typeof(FSH.Modules.Auditing.Contracts.AuditEnvelope), + typeof(FSH.Modules.Auditing.Persistence.AuditDbContext)]; + }); + + // FSH Module assemblies + var moduleAssemblies = new Assembly[] + { + typeof(IdentityModule).Assembly, + typeof(MultitenancyModule).Assembly, + typeof(AuditingModule).Assembly{{sampleModuleAssembly}} + }; + + // Add FSH Platform + builder.AddHeroPlatform(platform => + { + platform.EnableOpenApi = true; + platform.EnableCaching = true; + platform.EnableJobs = true; + platform.EnableMailing = true; + }); + + // Add modules + builder.AddModules(moduleAssemblies); + + var app = builder.Build(); + + // Apply tenant database migrations + app.UseHeroMultiTenantDatabases(); + + // Use FSH Platform + app.UseHeroPlatform(platform => + { + platform.MapModules = true; + }); + + await app.RunAsync(); + """; + } + + public static string GenerateAppSettings(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var connectionString = options.Database switch + { + DatabaseProvider.PostgreSQL => $"Server=localhost;Database={options.Name.ToLowerInvariant()};User Id=postgres;Password=password", + DatabaseProvider.SqlServer => $"Server=localhost;Database={options.Name};Trusted_Connection=True;TrustServerCertificate=True", + DatabaseProvider.SQLite => $"Data Source={options.Name}.db", + _ => string.Empty + }; + + var dbProvider = options.Database switch + { + DatabaseProvider.PostgreSQL => "POSTGRESQL", + DatabaseProvider.SqlServer => "MSSQL", + DatabaseProvider.SQLite => "SQLITE", + _ => "POSTGRESQL" + }; + + var migrationsAssembly = $"{options.Name}.Migrations"; + var projectNameLower = options.Name.ToLowerInvariant(); + + return $$""" + { + "OpenTelemetryOptions": { + "Enabled": true, + "Tracing": { + "Enabled": true + }, + "Metrics": { + "Enabled": true, + "MeterNames": [] + }, + "Exporter": { + "Otlp": { + "Enabled": true, + "Endpoint": "http://localhost:4317", + "Protocol": "grpc" + } + }, + "Jobs": { "Enabled": true }, + "Mediator": { "Enabled": true }, + "Http": { + "Histograms": { + "Enabled": true + } + }, + "Data": { + "FilterEfStatements": true, + "FilterRedisCommands": true + } + }, + "Serilog": { + "Using": [ + "Serilog.Sinks.Console", + "Serilog.Sinks.OpenTelemetry" + ], + "Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId", "WithCorrelationId", "WithProcessId", "WithProcessName" ], + "MinimumLevel": { + "Default": "Debug" + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "restrictedToMinimumLevel": "Information" + } + }, + { + "Name": "OpenTelemetry", + "Args": { + "endpoint": "http://localhost:4317", + "protocol": "grpc", + "resourceAttributes": { + "service.name": "{{options.Name}}.Api" + } + } + } + ] + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Hangfire": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "DatabaseOptions": { + "Provider": "{{dbProvider}}", + "ConnectionString": "{{connectionString}}", + "MigrationsAssembly": "{{migrationsAssembly}}" + }, + "OriginOptions": { + "OriginUrl": "https://localhost:7030" + }, + "CachingOptions": { + "Redis": "" + }, + "HangfireOptions": { + "Username": "admin", + "Password": "Secure1234!Me", + "Route": "/jobs" + }, + "AllowedHosts": "*", + "OpenApiOptions": { + "Enabled": true, + "Title": "{{options.Name}} API", + "Version": "v1", + "Description": "{{options.Name}} API built with FullStackHero .NET Starter Kit.", + "Contact": { + "Name": "Your Name", + "Url": "https://yourwebsite.com", + "Email": "your@email.com" + }, + "License": { + "Name": "MIT License", + "Url": "https://opensource.org/licenses/MIT" + } + }, + "CorsOptions": { + "AllowAll": false, + "AllowedOrigins": [ + "https://localhost:4200", + "https://localhost:7140" + ], + "AllowedHeaders": [ "content-type", "authorization" ], + "AllowedMethods": [ "GET", "POST", "PUT", "DELETE" ] + }, + "JwtOptions": { + "Issuer": "{{projectNameLower}}.local", + "Audience": "{{projectNameLower}}.clients", + "SigningKey": "replace-with-256-bit-secret-min-32-chars", + "AccessTokenMinutes": 2, + "RefreshTokenDays": 7 + }, + "SecurityHeadersOptions": { + "Enabled": true, + "ExcludedPaths": [ "/scalar", "/openapi" ], + "AllowInlineStyles": true, + "ScriptSources": [], + "StyleSources": [] + }, + "MailOptions": { + "From": "noreply@{{projectNameLower}}.com", + "Host": "smtp.ethereal.email", + "Port": 587, + "UserName": "your-smtp-user", + "Password": "your-smtp-password", + "DisplayName": "{{options.Name}}" + }, + "RateLimitingOptions": { + "Enabled": false, + "Global": { + "PermitLimit": 100, + "WindowSeconds": 60, + "QueueLimit": 0 + }, + "Auth": { + "PermitLimit": 10, + "WindowSeconds": 60, + "QueueLimit": 0 + } + }, + "MultitenancyOptions": { + "RunTenantMigrationsOnStartup": true + }, + "Storage": { + "Provider": "local" + } + } + """; + } + + private const string AppSettingsDevelopmentTemplate = """ + { + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information", + "Microsoft.EntityFrameworkCore": "Warning" + } + } + } + """; + + private const string BlazorCsprojTemplate = """ + + + + net10.0 + enable + enable + + + + + + + + + + + """; + + public static string GenerateAppSettingsDevelopment() => AppSettingsDevelopmentTemplate; + + public static string GenerateBlazorCsproj() => BlazorCsprojTemplate; + + public static string GenerateBlazorProgram(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return $$""" + using Microsoft.AspNetCore.Components.Web; + using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + using MudBlazor.Services; + using {{options.Name}}.Blazor; + + var builder = WebAssemblyHostBuilder.CreateDefault(args); + builder.RootComponents.Add("#app"); + builder.RootComponents.Add("head::after"); + + builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); + builder.Services.AddMudServices(); + + await builder.Build().RunAsync(); + """; + } + + public static string GenerateBlazorImports(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return $$""" + @using System.Net.Http + @using System.Net.Http.Json + @using Microsoft.AspNetCore.Components.Forms + @using Microsoft.AspNetCore.Components.Routing + @using Microsoft.AspNetCore.Components.Web + @using Microsoft.AspNetCore.Components.Web.Virtualization + @using Microsoft.AspNetCore.Components.WebAssembly.Http + @using Microsoft.JSInterop + @using MudBlazor + @using {{options.Name}}.Blazor + """; + } + + private const string BlazorAppTemplate = """ + + + + + + + + + + + + Not found + + Sorry, there's nothing at this address. + + + + """; + + public static string GenerateBlazorApp() => BlazorAppTemplate; + + public static string GenerateBlazorIndexPage(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return $$""" + @page "/" + + {{options.Name}} + + + Welcome to {{options.Name}} + + Built with FullStackHero .NET Starter Kit + + + """; + } + + public static string GenerateMigrationsCsproj(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var dbPackage = options.Database switch + { + DatabaseProvider.PostgreSQL => "", + DatabaseProvider.SqlServer => "", + DatabaseProvider.SQLite => "", + _ => string.Empty + }; + + return $$""" + + + + net10.0 + enable + enable + + + + + {{dbPackage}} + + + + + + + + """; + } + + public static string GenerateAppHostCsproj(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var dbPackage = options.Database switch + { + DatabaseProvider.PostgreSQL => "", + DatabaseProvider.SqlServer => "", + _ => string.Empty // SQLite doesn't need a hosting package + }; + + return $$""" + + + + Exe + net10.0 + enable + enable + false + + + + {{dbPackage}} + + + + + + {{(options.Type == ProjectType.ApiBlazor ? $" " : "")}} + + + + """; + } + + public static string GenerateAppHostProgram(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var projectNameLower = options.Name.ToLowerInvariant(); + var projectNameSafe = options.Name.Replace(".", "_", StringComparison.Ordinal); + + var (dbSetup, dbProvider, dbRef, dbWait, migrationsAssembly) = options.Database switch + { + DatabaseProvider.PostgreSQL => ( + $""" + // Postgres container + database + var postgres = builder.AddPostgres("postgres").WithDataVolume("{projectNameLower}-postgres-data").AddDatabase("{projectNameLower}"); + """, + "POSTGRESQL", + ".WithReference(postgres)", + ".WaitFor(postgres)", + $"{options.Name}.Migrations"), + DatabaseProvider.SqlServer => ( + $""" + // SQL Server container + database + var sqlserver = builder.AddSqlServer("sqlserver").WithDataVolume("{projectNameLower}-sqlserver-data").AddDatabase("{projectNameLower}"); + """, + "MSSQL", + ".WithReference(sqlserver)", + ".WaitFor(sqlserver)", + $"{options.Name}.Migrations"), + DatabaseProvider.SQLite => ( + "// SQLite runs embedded - no container needed", + "SQLITE", + string.Empty, + string.Empty, + $"{options.Name}.Migrations"), + _ => ("// Database configured externally", "POSTGRESQL", string.Empty, string.Empty, $"{options.Name}.Migrations") + }; + + var redisSetup = $""" + var redis = builder.AddRedis("redis").WithDataVolume("{projectNameLower}-redis-data"); + """; + + // Build database environment variables + var dbResourceName = options.Database == DatabaseProvider.PostgreSQL ? "postgres" : "sqlserver"; + var dbEnvVars = options.Database != DatabaseProvider.SQLite + ? $$""" + .WithEnvironment("DatabaseOptions__Provider", "{{dbProvider}}") + .WithEnvironment("DatabaseOptions__ConnectionString", {{dbResourceName}}.Resource.ConnectionStringExpression) + .WithEnvironment("DatabaseOptions__MigrationsAssembly", "{{migrationsAssembly}}") + {{dbWait}} + """ + : """ + .WithEnvironment("DatabaseOptions__Provider", "SQLITE") + """; + + // When Blazor is included, api variable is referenced; otherwise suppress unused warning + var (apiDeclaration, blazorProject) = options.Type == ProjectType.ApiBlazor + ? ($"var api = builder.AddProject(\"{projectNameLower}-api\")", + $""" + + builder.AddProject("{projectNameLower}-blazor"); + """) + : ($"builder.AddProject(\"{projectNameLower}-api\")", string.Empty); + + return $$""" + var builder = DistributedApplication.CreateBuilder(args); + + {{dbSetup}} + + {{redisSetup}} + + {{apiDeclaration}} + {{dbRef}} + .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development") + {{dbEnvVars}} + .WithReference(redis) + .WithEnvironment("CachingOptions__Redis", redis.Resource.ConnectionStringExpression) + .WaitFor(redis); + {{blazorProject}} + + await builder.Build().RunAsync(); + """; + } + + public static string GenerateDockerCompose(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var projectNameLower = options.Name.ToUpperInvariant().ToLowerInvariant(); + + var dbService = options.Database switch + { + DatabaseProvider.PostgreSQL => $""" + postgres: + image: postgres:16-alpine + container_name: postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: {projectNameLower} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + """, + DatabaseProvider.SqlServer => """ + sqlserver: + image: mcr.microsoft.com/mssql/server:2022-latest + container_name: sqlserver + environment: + ACCEPT_EULA: "Y" + SA_PASSWORD: "Your_password123" + ports: + - "1433:1433" + volumes: + - sqlserver_data:/var/opt/mssql + """, + _ => string.Empty + }; + + var volumes = options.Database switch + { + DatabaseProvider.PostgreSQL => """ + volumes: + postgres_data: + redis_data: + """, + DatabaseProvider.SqlServer => """ + volumes: + sqlserver_data: + redis_data: + """, + _ => """ + volumes: + redis_data: + """ + }; + + return $$""" + version: '3.8' + + services: + {{dbService}} + + redis: + image: redis:7-alpine + container_name: redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + {{volumes}} + """; + } + + private const string DockerComposeOverrideTemplate = """ + version: '3.8' + + # Development overrides + services: + redis: + command: redis-server --appendonly yes + """; + + private const string CatalogContractsCsprojTemplate = """ + + + + net10.0 + enable + enable + + + + """; + + public static string GenerateDockerComposeOverride() => DockerComposeOverrideTemplate; + + public static string GenerateCatalogContractsCsproj() => CatalogContractsCsprojTemplate; + + public static string GenerateCatalogModuleCsproj(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return $$""" + + + + net10.0 + enable + enable + + + + + + + + + + + + + + """; + } + + public static string GenerateCatalogModule(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return $$""" + using {{options.Name}}.Catalog.Features.v1.Products; + using FSH.Framework.Web.Modules; + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Routing; + using Microsoft.Extensions.Hosting; + + namespace {{options.Name}}.Catalog; + + public sealed class CatalogModule : IModule + { + public void ConfigureServices(IHostApplicationBuilder builder) + { + // Register services + } + + public void MapEndpoints(IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("/api/v1/catalog") + .WithTags("Catalog"); + + group.MapGetProductsEndpoint(); + } + } + """; + } + + public static string GenerateGetProductsEndpoint(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return $$""" + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Routing; + + namespace {{options.Name}}.Catalog.Features.v1.Products; + + public static class GetProductsEndpoint + { + public static RouteHandlerBuilder MapGetProductsEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/products", () => + { + var products = new[] + { + new { Id = 1, Name = "Product 1", Price = 9.99m }, + new { Id = 2, Name = "Product 2", Price = 19.99m }, + new { Id = 3, Name = "Product 3", Price = 29.99m } + }; + + return TypedResults.Ok(products); + }) + .WithName("GetProducts") + .WithSummary("Get all products") + .Produces(StatusCodes.Status200OK); + } + } + """; + } + + public static string GenerateTerraformMain(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var serverless = options.Architecture == ArchitectureStyle.Serverless; + var projectNameLower = options.Name.ToUpperInvariant().ToLowerInvariant(); + + if (serverless) + { + return $$""" + terraform { + required_version = ">= 1.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } + + backend "s3" { + bucket = "{{projectNameLower}}-terraform-state" + key = "state/terraform.tfstate" + region = var.aws_region + } + } + + provider "aws" { + region = var.aws_region + } + + # Lambda function + resource "aws_lambda_function" "api" { + function_name = "${var.project_name}-api" + runtime = "dotnet8" + handler = "{{options.Name}}.Api" + memory_size = 512 + timeout = 30 + + filename = var.lambda_zip_path + source_code_hash = filebase64sha256(var.lambda_zip_path) + + role = aws_iam_role.lambda_role.arn + + environment { + variables = { + ASPNETCORE_ENVIRONMENT = var.environment + } + } + } + + # API Gateway + resource "aws_apigatewayv2_api" "api" { + name = "${var.project_name}-api" + protocol_type = "HTTP" + } + + resource "aws_apigatewayv2_integration" "lambda" { + api_id = aws_apigatewayv2_api.api.id + integration_type = "AWS_PROXY" + integration_uri = aws_lambda_function.api.invoke_arn + integration_method = "POST" + } + + resource "aws_apigatewayv2_route" "default" { + api_id = aws_apigatewayv2_api.api.id + route_key = "$default" + target = "integrations/${aws_apigatewayv2_integration.lambda.id}" + } + + resource "aws_apigatewayv2_stage" "default" { + api_id = aws_apigatewayv2_api.api.id + name = "$default" + auto_deploy = true + } + + # Lambda IAM role + resource "aws_iam_role" "lambda_role" { + name = "${var.project_name}-lambda-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "lambda.amazonaws.com" + } + }] + }) + } + + resource "aws_iam_role_policy_attachment" "lambda_basic" { + role = aws_iam_role.lambda_role.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + } + """; + } + + return $$""" + terraform { + required_version = ">= 1.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } + + backend "s3" { + bucket = "{{projectNameLower}}-terraform-state" + key = "state/terraform.tfstate" + region = var.aws_region + } + } + + provider "aws" { + region = var.aws_region + } + + # VPC + module "vpc" { + source = "terraform-aws-modules/vpc/aws" + + name = "${var.project_name}-vpc" + cidr = "10.0.0.0/16" + + azs = ["${var.aws_region}a", "${var.aws_region}b"] + private_subnets = ["10.0.1.0/24", "10.0.2.0/24"] + public_subnets = ["10.0.101.0/24", "10.0.102.0/24"] + + enable_nat_gateway = true + single_nat_gateway = var.environment != "prod" + } + + # RDS PostgreSQL + module "rds" { + source = "terraform-aws-modules/rds/aws" + + identifier = "${var.project_name}-db" + + engine = "postgres" + engine_version = "16" + instance_class = var.db_instance_class + allocated_storage = 20 + + db_name = var.project_name + username = "postgres" + port = 5432 + + vpc_security_group_ids = [module.vpc.default_security_group_id] + subnet_ids = module.vpc.private_subnets + + family = "postgres16" + } + + # ElastiCache Redis + module "elasticache" { + source = "terraform-aws-modules/elasticache/aws" + + cluster_id = "${var.project_name}-redis" + engine = "redis" + node_type = var.redis_node_type + num_cache_nodes = 1 + parameter_group_name = "default.redis7" + + subnet_ids = module.vpc.private_subnets + security_group_ids = [module.vpc.default_security_group_id] + } + + # ECS Cluster + module "ecs" { + source = "terraform-aws-modules/ecs/aws" + + cluster_name = "${var.project_name}-cluster" + + fargate_capacity_providers = { + FARGATE = { + default_capacity_provider_strategy = { + weight = 100 + } + } + } + } + """; + } + + public static string GenerateTerraformVariables(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var projectNameLower = options.Name.ToUpperInvariant().ToLowerInvariant(); + + return $$""" + variable "aws_region" { + description = "AWS region" + type = string + default = "us-east-1" + } + + variable "project_name" { + description = "Project name" + type = string + default = "{{projectNameLower}}" + } + + variable "environment" { + description = "Environment (dev, staging, prod)" + type = string + default = "dev" + } + + variable "db_instance_class" { + description = "RDS instance class" + type = string + default = "db.t3.micro" + } + + variable "redis_node_type" { + description = "ElastiCache node type" + type = string + default = "cache.t3.micro" + } + {{(options.Architecture == ArchitectureStyle.Serverless ? """ + + variable "lambda_zip_path" { + description = "Path to Lambda deployment package" + type = string + default = "../publish/api.zip" + } + """ : "")}} + """; + } + + public static string GenerateTerraformOutputs(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (options.Architecture == ArchitectureStyle.Serverless) + { + return """ + output "api_endpoint" { + description = "API Gateway endpoint URL" + value = aws_apigatewayv2_api.api.api_endpoint + } + + output "lambda_function_name" { + description = "Lambda function name" + value = aws_lambda_function.api.function_name + } + """; + } + + return """ + output "vpc_id" { + description = "VPC ID" + value = module.vpc.vpc_id + } + + output "rds_endpoint" { + description = "RDS endpoint" + value = module.rds.db_instance_endpoint + } + + output "redis_endpoint" { + description = "ElastiCache endpoint" + value = module.elasticache.cluster_address + } + + output "ecs_cluster_name" { + description = "ECS cluster name" + value = module.ecs.cluster_name + } + """; + } + + public static string GenerateGitHubActionsCI(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var projectNameLower = options.Name.ToUpperInvariant().ToLowerInvariant(); + + return $@"name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +env: + DOTNET_VERSION: '10.0.x' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{{{ env.DOTNET_VERSION }}}} + + - name: Restore dependencies + run: dotnet restore src/{options.Name}.slnx + + - name: Build + run: dotnet build src/{options.Name}.slnx --no-restore --configuration Release + + - name: Test + run: dotnet test src/{options.Name}.slnx --no-build --configuration Release --verbosity normal + + docker: + runs-on: ubuntu-latest + needs: build + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@v4 + + - name: Build Docker image + run: | + docker build -t {projectNameLower}:${{{{ github.sha }}}} -f src/{options.Name}.Api/Dockerfile . +"; + } + + private const string GitignoreTemplate = """ + ## .NET + bin/ + obj/ + *.user + *.userosscache + *.suo + *.cache + *.nupkg + + ## IDE + .vs/ + .vscode/ + .idea/ + *.swp + *.swo + + ## Build + publish/ + artifacts/ + TestResults/ + + ## Secrets + appsettings.*.json + !appsettings.json + !appsettings.Development.json + *.pfx + *.p12 + + ## Terraform + .terraform/ + *.tfstate + *.tfstate.* + .terraform.lock.hcl + + ## OS + .DS_Store + Thumbs.db + + ## Logs + *.log + logs/ + """; + + public static string GenerateGitignore() => GitignoreTemplate; + + public static string GenerateDirectoryBuildProps(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return $$""" + + + net10.0 + latest + enable + enable + false + true + + + + {{options.Name}} + {{options.Name}} + 1.0.0 + + + + + all + runtime; build; native; contentfiles; analyzers + + + + """; + } + + public static string GenerateDirectoryPackagesProps(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + // Use custom version from options, or fall back to CLI's version + var version = options.FrameworkVersion ?? FrameworkVersion; + + return $$""" + + + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """; + } + + public static string GenerateReadme(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var archDescription = options.Architecture switch + { + ArchitectureStyle.Monolith => "monolithic", + ArchitectureStyle.Microservices => "microservices", + ArchitectureStyle.Serverless => "serverless (AWS Lambda)", + _ => string.Empty + }; + + return $$""" + # {{options.Name}} + + A {{archDescription}} application built with [FullStackHero .NET Starter Kit](https://fullstackhero.net). + + ## Getting Started + + ### Prerequisites + + - [.NET 10 SDK](https://dotnet.microsoft.com/download) + - [Docker](https://www.docker.com/) (optional, for infrastructure) + {{(options.Database == DatabaseProvider.PostgreSQL ? "- PostgreSQL 16+" : "")}} + {{(options.Database == DatabaseProvider.SqlServer ? "- SQL Server 2022+" : "")}} + - Redis + + ### Running the Application + + {{(options.IncludeDocker ? """ + #### Start Infrastructure (Docker) + + ```bash + docker-compose up -d + ``` + """ : "")}} + + {{(options.IncludeAspire ? $""" + #### Run with Aspire + + ```bash + dotnet run --project src/{options.Name}.AppHost + ``` + """ : $""" + #### Run the API + + ```bash + dotnet run --project src/{options.Name}.Api + ``` + """)}} + + ### Project Structure + + ``` + src/ + ├── {{options.Name}}.Api/ # Web API project + ├── {{options.Name}}.Migrations/ # Database migrations + {{(options.Type == ProjectType.ApiBlazor ? $"├── {options.Name}.Blazor/ # Blazor WebAssembly UI" : "")}} + {{(options.IncludeAspire ? $"├── {options.Name}.AppHost/ # Aspire orchestrator" : "")}} + {{(options.IncludeSampleModule ? "└── Modules/ # Feature modules" : "")}} + ``` + + ## Configuration + + Update `appsettings.json` with your settings: + + - `DatabaseOptions:ConnectionString` - Database connection + - `CachingOptions:Redis` - Redis connection + - `JwtOptions:SigningKey` - JWT signing key (change in production!) + + ## License + + MIT + """; + } + + public static string GenerateBlazorMainLayout(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return $$""" + @inherits LayoutComponentBase + + + + + + + + + + {{options.Name}} + + + + + + Home + + + + @Body + + + + @code { + private bool _drawerOpen = true; + + private void ToggleDrawer() + { + _drawerOpen = !_drawerOpen; + } + } + """; + } + + public static string GenerateDockerfile(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return $$""" + FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS base + WORKDIR /app + EXPOSE 8080 + EXPOSE 8081 + + FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build + ARG BUILD_CONFIGURATION=Release + WORKDIR /src + COPY ["src/{{options.Name}}.Api/{{options.Name}}.Api.csproj", "{{options.Name}}.Api/"] + RUN dotnet restore "{{options.Name}}.Api/{{options.Name}}.Api.csproj" + COPY src/ . + WORKDIR "/src/{{options.Name}}.Api" + RUN dotnet build "{{options.Name}}.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build + + FROM build AS publish + ARG BUILD_CONFIGURATION=Release + RUN dotnet publish "{{options.Name}}.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + + FROM base AS final + WORKDIR /app + COPY --from=publish /app/publish . + ENTRYPOINT ["dotnet", "{{options.Name}}.Api.dll"] + """; + } + + public static string GenerateApiLaunchSettings(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return $$""" + { + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "openapi", + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "openapi", + "applicationUrl": "https://localhost:7000;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } + """; + } + + public static string GenerateAppHostLaunchSettings(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return $$""" + { + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17000;http://localhost:15000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21000", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22000" + } + } + } + } + """; + } + + private const string GlobalJsonTemplate = """ + { + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + } + } + """; + + public static string GenerateGlobalJson() => GlobalJsonTemplate; + + private const string EditorConfigTemplate = """ + # EditorConfig is awesome: https://EditorConfig.org + + root = true + + [*] + indent_style = space + indent_size = 4 + end_of_line = lf + charset = utf-8 + trim_trailing_whitespace = true + insert_final_newline = true + + [*.{cs,csx}] + indent_size = 4 + + [*.{json,yml,yaml}] + indent_size = 2 + + [*.md] + trim_trailing_whitespace = false + + [*.razor] + indent_size = 4 + + # C# files + [*.cs] + + # Sort using and Import directives with System.* appearing first + dotnet_sort_system_directives_first = true + dotnet_separate_import_directive_groups = false + + # Avoid "this." for fields, properties, methods, events + dotnet_style_qualification_for_field = false:suggestion + dotnet_style_qualification_for_property = false:suggestion + dotnet_style_qualification_for_method = false:suggestion + dotnet_style_qualification_for_event = false:suggestion + + # Use language keywords instead of framework type names + dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion + dotnet_style_predefined_type_for_member_access = true:suggestion + + # Prefer var + csharp_style_var_for_built_in_types = true:suggestion + csharp_style_var_when_type_is_apparent = true:suggestion + csharp_style_var_elsewhere = true:suggestion + + # Prefer expression-bodied members + csharp_style_expression_bodied_methods = when_on_single_line:suggestion + csharp_style_expression_bodied_constructors = when_on_single_line:suggestion + csharp_style_expression_bodied_properties = when_on_single_line:suggestion + + # Prefer pattern matching + csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion + csharp_style_pattern_matching_over_as_with_null_check = true:suggestion + + # Namespace preferences + csharp_style_namespace_declarations = file_scoped:suggestion + + # Newline preferences + csharp_new_line_before_open_brace = all + csharp_new_line_before_else = true + csharp_new_line_before_catch = true + csharp_new_line_before_finally = true + """; + + public static string GenerateEditorConfig() => EditorConfigTemplate; +} diff --git a/src/Tools/CLI/UI/ConsoleTheme.cs b/src/Tools/CLI/UI/ConsoleTheme.cs new file mode 100644 index 0000000000..eb2444ca8e --- /dev/null +++ b/src/Tools/CLI/UI/ConsoleTheme.cs @@ -0,0 +1,56 @@ +using Spectre.Console; + +namespace FSH.CLI.UI; + +internal static class ConsoleTheme +{ + // FullStackHero brand color + public static Color Primary { get; } = new(62, 175, 124); // #3eaf7c + public static Color Secondary { get; } = Color.White; + public static Color Success { get; } = Color.Green; + public static Color Warning { get; } = Color.Yellow; + public static Color Error { get; } = Color.Red; + public static Color Muted { get; } = Color.Grey; + public static Color Dim { get; } = new(128, 128, 128); + + public static Style PrimaryStyle { get; } = new(Primary); + public static Style SecondaryStyle { get; } = new(Secondary); + public static Style SuccessStyle { get; } = new(Success); + public static Style WarningStyle { get; } = new(Warning); + public static Style ErrorStyle { get; } = new(Error); + public static Style MutedStyle { get; } = new(Muted); + public static Style DimStyle { get; } = new(Dim); + + public static void WriteBanner() + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[bold {Primary.ToMarkup()}]FSH[/] [dim]•[/] FullStackHero .NET Starter Kit"); + AnsiConsole.WriteLine(); + } + + public static void WriteSuccess(string message) => + AnsiConsole.MarkupLine($"[green]✓[/] {message}"); + + public static void WriteError(string message) => + AnsiConsole.MarkupLine($"[red]✗[/] {message}"); + + public static void WriteWarning(string message) => + AnsiConsole.MarkupLine($"[yellow]![/] {message}"); + + public static void WriteInfo(string message) => + AnsiConsole.MarkupLine($"[blue]ℹ[/] {message}"); + + public static void WriteStep(string message) => + AnsiConsole.MarkupLine($" [dim]→[/] {message}"); + + public static void WriteDone(string message) => + AnsiConsole.MarkupLine($"\n[green]Done![/] {message}"); + + public static void WriteHeader(string message) => + AnsiConsole.MarkupLine($"\n[bold]{message}[/]"); + + public static void WriteKeyValue(string key, string value, bool highlight = false) => + AnsiConsole.MarkupLine(highlight + ? $" [dim]{key}:[/] [{Primary.ToMarkup()}]{value}[/]" + : $" [dim]{key}:[/] {value}"); +} diff --git a/src/Tools/CLI/Validation/OptionValidator.cs b/src/Tools/CLI/Validation/OptionValidator.cs new file mode 100644 index 0000000000..b7bc624072 --- /dev/null +++ b/src/Tools/CLI/Validation/OptionValidator.cs @@ -0,0 +1,74 @@ +using FSH.CLI.Models; + +namespace FSH.CLI.Validation; + +internal static class OptionValidator +{ + public static OptionValidationResult Validate(ProjectOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var errors = new List(); + + // Serverless + Blazor is not supported + if (options.Architecture == ArchitectureStyle.Serverless && options.Type == ProjectType.ApiBlazor) + { + errors.Add("Serverless architecture does not support Blazor. Please choose API only."); + } + + // Microservices + SQLite is not supported + if (options.Architecture == ArchitectureStyle.Microservices && options.Database == DatabaseProvider.SQLite) + { + errors.Add("Microservices architecture does not support SQLite. Please choose PostgreSQL or SQL Server."); + } + + // Serverless typically doesn't use Aspire + if (options.Architecture == ArchitectureStyle.Serverless && options.IncludeAspire) + { + errors.Add("Serverless architecture does not support Aspire AppHost."); + } + + // Project name validation + if (string.IsNullOrWhiteSpace(options.Name)) + { + errors.Add("Project name is required."); + } + else if (!IsValidProjectName(options.Name)) + { + errors.Add("Project name must start with a letter and contain only letters, numbers, underscores, or hyphens."); + } + + return errors.Count == 0 + ? OptionValidationResult.Success() + : OptionValidationResult.Failure(errors); + } + + public static bool IsValidCombination(ArchitectureStyle architecture, ProjectType type) => + !(architecture == ArchitectureStyle.Serverless && type == ProjectType.ApiBlazor); + + public static bool IsValidCombination(ArchitectureStyle architecture, DatabaseProvider database) => + !(architecture == ArchitectureStyle.Microservices && database == DatabaseProvider.SQLite); + + public static bool IsValidCombination(ArchitectureStyle architecture, bool includeAspire) => + !(architecture == ArchitectureStyle.Serverless && includeAspire); + + public static bool IsValidProjectName(string name) => + !string.IsNullOrWhiteSpace(name) && + char.IsLetter(name[0]) && + name.All(c => char.IsLetterOrDigit(c) || c == '_' || c == '-' || c == '.'); +} + +internal sealed class OptionValidationResult +{ + public bool IsValid { get; } + public IReadOnlyList Errors { get; } + + private OptionValidationResult(bool isValid, IReadOnlyList errors) + { + IsValid = isValid; + Errors = errors; + } + + public static OptionValidationResult Success() => new(true, []); + public static OptionValidationResult Failure(IEnumerable errors) => new(false, errors.ToList()); +} diff --git a/src/api/framework/Core/Audit/AuditTrail.cs b/src/api/framework/Core/Audit/AuditTrail.cs deleted file mode 100644 index 97448ac39f..0000000000 --- a/src/api/framework/Core/Audit/AuditTrail.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace FSH.Framework.Core.Audit; -public class AuditTrail -{ - public Guid Id { get; set; } - public Guid UserId { get; set; } - public string? Operation { get; set; } - public string? Entity { get; set; } - public DateTimeOffset DateTime { get; set; } - public string? PreviousValues { get; set; } - public string? NewValues { get; set; } - public string? ModifiedProperties { get; set; } - public string? PrimaryKey { get; set; } -} diff --git a/src/api/framework/Core/Audit/IAuditService.cs b/src/api/framework/Core/Audit/IAuditService.cs deleted file mode 100644 index 9c62f4d0db..0000000000 --- a/src/api/framework/Core/Audit/IAuditService.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace FSH.Framework.Core.Audit; -public interface IAuditService -{ - Task> GetUserTrailsAsync(Guid userId); -} diff --git a/src/api/framework/Core/Audit/TrailDto.cs b/src/api/framework/Core/Audit/TrailDto.cs deleted file mode 100644 index 8268e4b172..0000000000 --- a/src/api/framework/Core/Audit/TrailDto.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Collections.ObjectModel; -using System.Text.Json; - -namespace FSH.Framework.Core.Audit; -public class TrailDto() -{ - public Guid Id { get; set; } - public DateTimeOffset DateTime { get; set; } - public Guid UserId { get; set; } - public Dictionary KeyValues { get; } = []; - public Dictionary OldValues { get; } = []; - public Dictionary NewValues { get; } = []; - public Collection ModifiedProperties { get; } = []; - public TrailType Type { get; set; } - public string? TableName { get; set; } - - private static readonly JsonSerializerOptions SerializerOptions = new() - { - WriteIndented = false, - }; - - public AuditTrail ToAuditTrail() - { - return new() - { - Id = Guid.NewGuid(), - UserId = UserId, - Operation = Type.ToString(), - Entity = TableName, - DateTime = DateTime, - PrimaryKey = JsonSerializer.Serialize(KeyValues, SerializerOptions), - PreviousValues = OldValues.Count == 0 ? null : JsonSerializer.Serialize(OldValues, SerializerOptions), - NewValues = NewValues.Count == 0 ? null : JsonSerializer.Serialize(NewValues, SerializerOptions), - ModifiedProperties = ModifiedProperties.Count == 0 ? null : JsonSerializer.Serialize(ModifiedProperties, SerializerOptions) - }; - } -} diff --git a/src/api/framework/Core/Audit/TrailType.cs b/src/api/framework/Core/Audit/TrailType.cs deleted file mode 100644 index a98bfa29b6..0000000000 --- a/src/api/framework/Core/Audit/TrailType.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace FSH.Framework.Core.Audit; -public enum TrailType -{ - None = 0, - Create = 1, - Update = 2, - Delete = 3 -} diff --git a/src/api/framework/Core/Auth/Jwt/JwtOptions.cs b/src/api/framework/Core/Auth/Jwt/JwtOptions.cs deleted file mode 100644 index 5d99d6702f..0000000000 --- a/src/api/framework/Core/Auth/Jwt/JwtOptions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace FSH.Framework.Core.Auth.Jwt; -public class JwtOptions : IValidatableObject -{ - public string Key { get; set; } = string.Empty; - - public int TokenExpirationInMinutes { get; set; } = 60; - - public int RefreshTokenExpirationInDays { get; set; } = 7; - - public IEnumerable Validate(ValidationContext validationContext) - { - if (string.IsNullOrEmpty(Key)) - { - yield return new ValidationResult("No Key defined in JwtSettings config", [nameof(Key)]); - } - } -} diff --git a/src/api/framework/Core/Caching/CacheOptions.cs b/src/api/framework/Core/Caching/CacheOptions.cs deleted file mode 100644 index b861c2e06a..0000000000 --- a/src/api/framework/Core/Caching/CacheOptions.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace FSH.Framework.Core.Caching; - -public class CacheOptions -{ - public string Redis { get; set; } = string.Empty; -} diff --git a/src/api/framework/Core/Caching/CacheServiceExtensions.cs b/src/api/framework/Core/Caching/CacheServiceExtensions.cs deleted file mode 100644 index c03f94cc1f..0000000000 --- a/src/api/framework/Core/Caching/CacheServiceExtensions.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace FSH.Framework.Core.Caching; - -public static class CacheServiceExtensions -{ - public static T? GetOrSet(this ICacheService cache, string key, Func getItemCallback, TimeSpan? slidingExpiration = null) - { - T? value = cache.Get(key); - - if (value is not null) - { - return value; - } - - value = getItemCallback(); - - if (value is not null) - { - cache.Set(key, value, slidingExpiration); - } - - return value; - } - - public static async Task GetOrSetAsync(this ICacheService cache, string key, Func> task, TimeSpan? slidingExpiration = null, CancellationToken cancellationToken = default) - { - T? value = await cache.GetAsync(key, cancellationToken); - - if (value is not null) - { - return value; - } - - value = await task(); - - if (value is not null) - { - await cache.SetAsync(key, value, slidingExpiration, cancellationToken); - } - - return value; - } -} diff --git a/src/api/framework/Core/Caching/ICacheService.cs b/src/api/framework/Core/Caching/ICacheService.cs deleted file mode 100644 index 54f3c09048..0000000000 --- a/src/api/framework/Core/Caching/ICacheService.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace FSH.Framework.Core.Caching; - -public interface ICacheService -{ - T? Get(string key); - Task GetAsync(string key, CancellationToken token = default); - - void Refresh(string key); - Task RefreshAsync(string key, CancellationToken token = default); - - void Remove(string key); - Task RemoveAsync(string key, CancellationToken token = default); - - void Set(string key, T value, TimeSpan? slidingExpiration = null); - Task SetAsync(string key, T value, TimeSpan? slidingExpiration = null, CancellationToken cancellationToken = default); -} \ No newline at end of file diff --git a/src/api/framework/Core/Core.csproj b/src/api/framework/Core/Core.csproj deleted file mode 100644 index 13d0f1dbc8..0000000000 --- a/src/api/framework/Core/Core.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - FSH.Framework.Core - FSH.Framework.Core - - - - - - - - - - - - - - - diff --git a/src/api/framework/Core/Domain/AuditableEntity.cs b/src/api/framework/Core/Domain/AuditableEntity.cs deleted file mode 100644 index 6639a02156..0000000000 --- a/src/api/framework/Core/Domain/AuditableEntity.cs +++ /dev/null @@ -1,18 +0,0 @@ -using FSH.Framework.Core.Domain.Contracts; - -namespace FSH.Framework.Core.Domain; - -public class AuditableEntity : BaseEntity, IAuditable, ISoftDeletable -{ - public DateTimeOffset Created { get; set; } - public Guid CreatedBy { get; set; } - public DateTimeOffset LastModified { get; set; } - public Guid? LastModifiedBy { get; set; } - public DateTimeOffset? Deleted { get; set; } - public Guid? DeletedBy { get; set; } -} - -public abstract class AuditableEntity : AuditableEntity -{ - protected AuditableEntity() => Id = Guid.NewGuid(); -} diff --git a/src/api/framework/Core/Domain/BaseEntity.cs b/src/api/framework/Core/Domain/BaseEntity.cs deleted file mode 100644 index 1c2e98daaf..0000000000 --- a/src/api/framework/Core/Domain/BaseEntity.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Collections.ObjectModel; -using System.ComponentModel.DataAnnotations.Schema; -using FSH.Framework.Core.Domain.Contracts; -using FSH.Framework.Core.Domain.Events; - -namespace FSH.Framework.Core.Domain; - -public abstract class BaseEntity : IEntity -{ - public TId Id { get; protected init; } = default!; - [NotMapped] - public Collection DomainEvents { get; } = new Collection(); - public void QueueDomainEvent(DomainEvent @event) - { - if (!DomainEvents.Contains(@event)) - DomainEvents.Add(@event); - } -} - -public abstract class BaseEntity : BaseEntity -{ - protected BaseEntity() => Id = Guid.NewGuid(); -} diff --git a/src/api/framework/Core/Domain/Contracts/IAggregateRoot.cs b/src/api/framework/Core/Domain/Contracts/IAggregateRoot.cs deleted file mode 100644 index cc98c00dba..0000000000 --- a/src/api/framework/Core/Domain/Contracts/IAggregateRoot.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace FSH.Framework.Core.Domain.Contracts; - -// Apply this marker interface only to aggregate root entities -// Repositories will only work with aggregate roots, not their children -public interface IAggregateRoot : IEntity -{ -} diff --git a/src/api/framework/Core/Domain/Contracts/IAuditable.cs b/src/api/framework/Core/Domain/Contracts/IAuditable.cs deleted file mode 100644 index edfa8ab9f3..0000000000 --- a/src/api/framework/Core/Domain/Contracts/IAuditable.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace FSH.Framework.Core.Domain.Contracts; - -public interface IAuditable -{ - DateTimeOffset Created { get; } - Guid CreatedBy { get; } - DateTimeOffset LastModified { get; } - Guid? LastModifiedBy { get; } -} diff --git a/src/api/framework/Core/Domain/Contracts/IEntity.cs b/src/api/framework/Core/Domain/Contracts/IEntity.cs deleted file mode 100644 index 1d48d306d6..0000000000 --- a/src/api/framework/Core/Domain/Contracts/IEntity.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Collections.ObjectModel; -using FSH.Framework.Core.Domain.Events; - -namespace FSH.Framework.Core.Domain.Contracts; - -public interface IEntity -{ - Collection DomainEvents { get; } -} - -public interface IEntity : IEntity -{ - TId Id { get; } -} diff --git a/src/api/framework/Core/Domain/Contracts/ISoftDeletable.cs b/src/api/framework/Core/Domain/Contracts/ISoftDeletable.cs deleted file mode 100644 index d129d02e4a..0000000000 --- a/src/api/framework/Core/Domain/Contracts/ISoftDeletable.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace FSH.Framework.Core.Domain.Contracts; - -public interface ISoftDeletable -{ - DateTimeOffset? Deleted { get; set; } - Guid? DeletedBy { get; set; } -} diff --git a/src/api/framework/Core/Domain/Events/DomainEvent.cs b/src/api/framework/Core/Domain/Events/DomainEvent.cs deleted file mode 100644 index 5350854602..0000000000 --- a/src/api/framework/Core/Domain/Events/DomainEvent.cs +++ /dev/null @@ -1,7 +0,0 @@ -using MediatR; - -namespace FSH.Framework.Core.Domain.Events; -public abstract record DomainEvent : IDomainEvent, INotification -{ - public DateTime RaisedOn { get; protected set; } = DateTime.UtcNow; -} diff --git a/src/api/framework/Core/Domain/Events/IDomainEvent.cs b/src/api/framework/Core/Domain/Events/IDomainEvent.cs deleted file mode 100644 index 68d4c8f6c2..0000000000 --- a/src/api/framework/Core/Domain/Events/IDomainEvent.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace FSH.Framework.Core.Domain.Events; -public interface IDomainEvent -{ -} diff --git a/src/api/framework/Core/Exceptions/CustomException.cs b/src/api/framework/Core/Exceptions/CustomException.cs deleted file mode 100644 index 4d1af9af97..0000000000 --- a/src/api/framework/Core/Exceptions/CustomException.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Net; - -namespace FSH.Framework.Core.Exceptions; - -public class CustomException : Exception -{ - public List? ErrorMessages { get; } - - public HttpStatusCode StatusCode { get; } - - public CustomException(string message, List? errors = default, HttpStatusCode statusCode = HttpStatusCode.InternalServerError) - : base(message) - { - ErrorMessages = errors; - StatusCode = statusCode; - } -} diff --git a/src/api/framework/Core/Exceptions/ForbiddenException.cs b/src/api/framework/Core/Exceptions/ForbiddenException.cs deleted file mode 100644 index fdafead902..0000000000 --- a/src/api/framework/Core/Exceptions/ForbiddenException.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Net; - -namespace FSH.Framework.Core.Exceptions; -public class ForbiddenException : FshException -{ - public ForbiddenException() - : base("unauthorized", [], HttpStatusCode.Forbidden) - { - } - public ForbiddenException(string message) - : base(message, [], HttpStatusCode.Forbidden) - { - } -} diff --git a/src/api/framework/Core/Exceptions/FshException.cs b/src/api/framework/Core/Exceptions/FshException.cs deleted file mode 100644 index 28597c5297..0000000000 --- a/src/api/framework/Core/Exceptions/FshException.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Net; - -namespace FSH.Framework.Core.Exceptions; -public class FshException : Exception -{ - public IEnumerable ErrorMessages { get; } - - public HttpStatusCode StatusCode { get; } - - public FshException(string message, IEnumerable errors, HttpStatusCode statusCode = HttpStatusCode.InternalServerError) - : base(message) - { - ErrorMessages = errors; - StatusCode = statusCode; - } - - public FshException(string message) : base(message) - { - ErrorMessages = new List(); - } -} diff --git a/src/api/framework/Core/Exceptions/NotFoundException.cs b/src/api/framework/Core/Exceptions/NotFoundException.cs deleted file mode 100644 index 351e25cfc7..0000000000 --- a/src/api/framework/Core/Exceptions/NotFoundException.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Collections.ObjectModel; -using System.Net; - -namespace FSH.Framework.Core.Exceptions; -public class NotFoundException : FshException -{ - public NotFoundException(string message) - : base(message, new Collection(), HttpStatusCode.NotFound) - { - } -} diff --git a/src/api/framework/Core/Exceptions/UnauthorizedException.cs b/src/api/framework/Core/Exceptions/UnauthorizedException.cs deleted file mode 100644 index 559eb060c8..0000000000 --- a/src/api/framework/Core/Exceptions/UnauthorizedException.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Collections.ObjectModel; -using System.Net; - -namespace FSH.Framework.Core.Exceptions; -public class UnauthorizedException : FshException -{ - public UnauthorizedException() - : base("authentication failed", new Collection(), HttpStatusCode.Unauthorized) - { - } - public UnauthorizedException(string message) - : base(message, new Collection(), HttpStatusCode.Unauthorized) - { - } -} diff --git a/src/api/framework/Core/FshCore.cs b/src/api/framework/Core/FshCore.cs deleted file mode 100644 index 1891dc8d21..0000000000 --- a/src/api/framework/Core/FshCore.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace FSH.Framework.Core; -public static class FshCore -{ - public static string Name { get; set; } = "FshCore"; -} diff --git a/src/api/framework/Core/Identity/Roles/Features/CreateOrUpdateRole/CreateOrUpdateRoleCommand.cs b/src/api/framework/Core/Identity/Roles/Features/CreateOrUpdateRole/CreateOrUpdateRoleCommand.cs deleted file mode 100644 index 5774c40ae9..0000000000 --- a/src/api/framework/Core/Identity/Roles/Features/CreateOrUpdateRole/CreateOrUpdateRoleCommand.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace FSH.Framework.Core.Identity.Roles.Features.CreateOrUpdateRole; - -public class CreateOrUpdateRoleCommand -{ - public string Id { get; set; } = default!; - public string Name { get; set; } = default!; - public string? Description { get; set; } -} diff --git a/src/api/framework/Core/Identity/Roles/Features/CreateOrUpdateRole/CreateOrUpdateRoleValidator.cs b/src/api/framework/Core/Identity/Roles/Features/CreateOrUpdateRole/CreateOrUpdateRoleValidator.cs deleted file mode 100644 index 68f4526661..0000000000 --- a/src/api/framework/Core/Identity/Roles/Features/CreateOrUpdateRole/CreateOrUpdateRoleValidator.cs +++ /dev/null @@ -1,11 +0,0 @@ -using FluentValidation; - -namespace FSH.Framework.Core.Identity.Roles.Features.CreateOrUpdateRole; - -public class CreateOrUpdateRoleValidator : AbstractValidator -{ - public CreateOrUpdateRoleValidator() - { - RuleFor(x => x.Name).NotEmpty().WithMessage("Role name is required."); - } -} diff --git a/src/api/framework/Core/Identity/Roles/Features/UpdatePermissions/UpdatePermissionsCommand.cs b/src/api/framework/Core/Identity/Roles/Features/UpdatePermissions/UpdatePermissionsCommand.cs deleted file mode 100644 index 900c153956..0000000000 --- a/src/api/framework/Core/Identity/Roles/Features/UpdatePermissions/UpdatePermissionsCommand.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace FSH.Framework.Core.Identity.Roles.Features.UpdatePermissions; -public class UpdatePermissionsCommand -{ - public string RoleId { get; set; } = default!; - public List Permissions { get; set; } = default!; -} diff --git a/src/api/framework/Core/Identity/Roles/Features/UpdatePermissions/UpdatePermissionsValidator.cs b/src/api/framework/Core/Identity/Roles/Features/UpdatePermissions/UpdatePermissionsValidator.cs deleted file mode 100644 index 34b0b7f01c..0000000000 --- a/src/api/framework/Core/Identity/Roles/Features/UpdatePermissions/UpdatePermissionsValidator.cs +++ /dev/null @@ -1,13 +0,0 @@ -using FluentValidation; - -namespace FSH.Framework.Core.Identity.Roles.Features.UpdatePermissions; -public class UpdatePermissionsValidator : AbstractValidator -{ - public UpdatePermissionsValidator() - { - RuleFor(r => r.RoleId) - .NotEmpty(); - RuleFor(r => r.Permissions) - .NotNull(); - } -} diff --git a/src/api/framework/Core/Identity/Roles/IRoleService.cs b/src/api/framework/Core/Identity/Roles/IRoleService.cs deleted file mode 100644 index dca61839af..0000000000 --- a/src/api/framework/Core/Identity/Roles/IRoleService.cs +++ /dev/null @@ -1,16 +0,0 @@ -using FSH.Framework.Core.Identity.Roles.Features.CreateOrUpdateRole; -using FSH.Framework.Core.Identity.Roles.Features.UpdatePermissions; - -namespace FSH.Framework.Core.Identity.Roles; - -public interface IRoleService -{ - Task> GetRolesAsync(); - Task GetRoleAsync(string id); - Task CreateOrUpdateRoleAsync(CreateOrUpdateRoleCommand command); - Task DeleteRoleAsync(string id); - Task GetWithPermissionsAsync(string id, CancellationToken cancellationToken); - - Task UpdatePermissionsAsync(UpdatePermissionsCommand request); -} - diff --git a/src/api/framework/Core/Identity/Roles/RoleDto.cs b/src/api/framework/Core/Identity/Roles/RoleDto.cs deleted file mode 100644 index 0a0fc7559b..0000000000 --- a/src/api/framework/Core/Identity/Roles/RoleDto.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace FSH.Framework.Core.Identity.Roles; - -public class RoleDto -{ - public string Id { get; set; } = default!; - public string Name { get; set; } = default!; - public string? Description { get; set; } - public List? Permissions { get; set; } -} diff --git a/src/api/framework/Core/Identity/Tokens/Features/Generate/TokenGenerationCommand.cs b/src/api/framework/Core/Identity/Tokens/Features/Generate/TokenGenerationCommand.cs deleted file mode 100644 index dccc1e15d7..0000000000 --- a/src/api/framework/Core/Identity/Tokens/Features/Generate/TokenGenerationCommand.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.ComponentModel; -using FluentValidation; -using FSH.Starter.Shared.Authorization; - -namespace FSH.Framework.Core.Identity.Tokens.Features.Generate; -public record TokenGenerationCommand( - [property: DefaultValue(TenantConstants.Root.EmailAddress)] string Email, - [property: DefaultValue(TenantConstants.DefaultPassword)] string Password); - -public class GenerateTokenValidator : AbstractValidator -{ - public GenerateTokenValidator() - { - RuleFor(p => p.Email).Cascade(CascadeMode.Stop).NotEmpty().EmailAddress(); - - RuleFor(p => p.Password).Cascade(CascadeMode.Stop).NotEmpty(); - } -} diff --git a/src/api/framework/Core/Identity/Tokens/Features/Refresh/RefreshTokenCommand.cs b/src/api/framework/Core/Identity/Tokens/Features/Refresh/RefreshTokenCommand.cs deleted file mode 100644 index 8fc45b8d24..0000000000 --- a/src/api/framework/Core/Identity/Tokens/Features/Refresh/RefreshTokenCommand.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentValidation; - -namespace FSH.Framework.Core.Identity.Tokens.Features.Refresh; -public record RefreshTokenCommand(string Token, string RefreshToken); - -public class RefreshTokenValidator : AbstractValidator -{ - public RefreshTokenValidator() - { - RuleFor(p => p.Token).Cascade(CascadeMode.Stop).NotEmpty(); - - RuleFor(p => p.RefreshToken).Cascade(CascadeMode.Stop).NotEmpty(); - } -} diff --git a/src/api/framework/Core/Identity/Tokens/ITokenService.cs b/src/api/framework/Core/Identity/Tokens/ITokenService.cs deleted file mode 100644 index 86665ec818..0000000000 --- a/src/api/framework/Core/Identity/Tokens/ITokenService.cs +++ /dev/null @@ -1,11 +0,0 @@ -using FSH.Framework.Core.Identity.Tokens.Features.Generate; -using FSH.Framework.Core.Identity.Tokens.Features.Refresh; -using FSH.Framework.Core.Identity.Tokens.Models; - -namespace FSH.Framework.Core.Identity.Tokens; -public interface ITokenService -{ - Task GenerateTokenAsync(TokenGenerationCommand request, string ipAddress, CancellationToken cancellationToken); - Task RefreshTokenAsync(RefreshTokenCommand request, string ipAddress, CancellationToken cancellationToken); - -} diff --git a/src/api/framework/Core/Identity/Tokens/Models/TokenResponse.cs b/src/api/framework/Core/Identity/Tokens/Models/TokenResponse.cs deleted file mode 100644 index fc56f00d89..0000000000 --- a/src/api/framework/Core/Identity/Tokens/Models/TokenResponse.cs +++ /dev/null @@ -1,2 +0,0 @@ -namespace FSH.Framework.Core.Identity.Tokens.Models; -public record TokenResponse(string Token, string RefreshToken, DateTime RefreshTokenExpiryTime); diff --git a/src/api/framework/Core/Identity/Users/Abstractions/ICurrentUserInitializer.cs b/src/api/framework/Core/Identity/Users/Abstractions/ICurrentUserInitializer.cs deleted file mode 100644 index 2342d75b8d..0000000000 --- a/src/api/framework/Core/Identity/Users/Abstractions/ICurrentUserInitializer.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Security.Claims; - -namespace FSH.Framework.Core.Identity.Users.Abstractions; -public interface ICurrentUserInitializer -{ - void SetCurrentUser(ClaimsPrincipal user); - - void SetCurrentUserId(string userId); -} diff --git a/src/api/framework/Core/Identity/Users/Abstractions/IUserService.cs b/src/api/framework/Core/Identity/Users/Abstractions/IUserService.cs deleted file mode 100644 index 95fbf9f577..0000000000 --- a/src/api/framework/Core/Identity/Users/Abstractions/IUserService.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Security.Claims; -using FSH.Framework.Core.Identity.Users.Dtos; -using FSH.Framework.Core.Identity.Users.Features.AssignUserRole; -using FSH.Framework.Core.Identity.Users.Features.ChangePassword; -using FSH.Framework.Core.Identity.Users.Features.ForgotPassword; -using FSH.Framework.Core.Identity.Users.Features.RegisterUser; -using FSH.Framework.Core.Identity.Users.Features.ResetPassword; -using FSH.Framework.Core.Identity.Users.Features.ToggleUserStatus; -using FSH.Framework.Core.Identity.Users.Features.UpdateUser; - -namespace FSH.Framework.Core.Identity.Users.Abstractions; -public interface IUserService -{ - Task ExistsWithNameAsync(string name); - Task ExistsWithEmailAsync(string email, string? exceptId = null); - Task ExistsWithPhoneNumberAsync(string phoneNumber, string? exceptId = null); - Task> GetListAsync(CancellationToken cancellationToken); - Task GetCountAsync(CancellationToken cancellationToken); - Task GetAsync(string userId, CancellationToken cancellationToken); - Task ToggleStatusAsync(ToggleUserStatusCommand request, CancellationToken cancellationToken); - Task GetOrCreateFromPrincipalAsync(ClaimsPrincipal principal); - Task RegisterAsync(RegisterUserCommand request, string origin, CancellationToken cancellationToken); - Task UpdateAsync(UpdateUserCommand request, string userId); - Task DeleteAsync(string userId); - Task ConfirmEmailAsync(string userId, string code, string tenant, CancellationToken cancellationToken); - Task ConfirmPhoneNumberAsync(string userId, string code); - - // permisions - Task HasPermissionAsync(string userId, string permission, CancellationToken cancellationToken = default); - - // passwords - Task ForgotPasswordAsync(ForgotPasswordCommand request, string origin, CancellationToken cancellationToken); - Task ResetPasswordAsync(ResetPasswordCommand request, CancellationToken cancellationToken); - Task?> GetPermissionsAsync(string userId, CancellationToken cancellationToken); - - Task ChangePasswordAsync(ChangePasswordCommand request, string userId); - Task AssignRolesAsync(string userId, AssignUserRoleCommand request, CancellationToken cancellationToken); - Task> GetUserRolesAsync(string userId, CancellationToken cancellationToken); -} diff --git a/src/api/framework/Core/Identity/Users/Dtos/UserDetail.cs b/src/api/framework/Core/Identity/Users/Dtos/UserDetail.cs deleted file mode 100644 index 23941ad86c..0000000000 --- a/src/api/framework/Core/Identity/Users/Dtos/UserDetail.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace FSH.Framework.Core.Identity.Users.Dtos; -public class UserDetail -{ - public Guid Id { get; set; } - - public string? UserName { get; set; } - - public string? FirstName { get; set; } - - public string? LastName { get; set; } - - public string? Email { get; set; } - - public bool IsActive { get; set; } = true; - - public bool EmailConfirmed { get; set; } - - public string? PhoneNumber { get; set; } - - public Uri? ImageUrl { get; set; } -} diff --git a/src/api/framework/Core/Identity/Users/Dtos/UserRoleDetail.cs b/src/api/framework/Core/Identity/Users/Dtos/UserRoleDetail.cs deleted file mode 100644 index 935fd79b6e..0000000000 --- a/src/api/framework/Core/Identity/Users/Dtos/UserRoleDetail.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace FSH.Framework.Core.Identity.Users.Dtos; -public class UserRoleDetail -{ - public string? RoleId { get; set; } - public string? RoleName { get; set; } - public string? Description { get; set; } - public bool Enabled { get; set; } -} diff --git a/src/api/framework/Core/Identity/Users/Features/AssignUserRole/AssignUserRoleCommand.cs b/src/api/framework/Core/Identity/Users/Features/AssignUserRole/AssignUserRoleCommand.cs deleted file mode 100644 index 34f3fadb89..0000000000 --- a/src/api/framework/Core/Identity/Users/Features/AssignUserRole/AssignUserRoleCommand.cs +++ /dev/null @@ -1,7 +0,0 @@ -using FSH.Framework.Core.Identity.Users.Dtos; - -namespace FSH.Framework.Core.Identity.Users.Features.AssignUserRole; -public class AssignUserRoleCommand -{ - public List UserRoles { get; set; } = new(); -} diff --git a/src/api/framework/Core/Identity/Users/Features/ChangePassword/ChangePasswordCommand.cs b/src/api/framework/Core/Identity/Users/Features/ChangePassword/ChangePasswordCommand.cs deleted file mode 100644 index 82abe1323c..0000000000 --- a/src/api/framework/Core/Identity/Users/Features/ChangePassword/ChangePasswordCommand.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace FSH.Framework.Core.Identity.Users.Features.ChangePassword; -public class ChangePasswordCommand -{ - public string Password { get; set; } = default!; - public string NewPassword { get; set; } = default!; - public string ConfirmNewPassword { get; set; } = default!; -} diff --git a/src/api/framework/Core/Identity/Users/Features/ChangePassword/ChangePasswordValidator.cs b/src/api/framework/Core/Identity/Users/Features/ChangePassword/ChangePasswordValidator.cs deleted file mode 100644 index 9d52f78856..0000000000 --- a/src/api/framework/Core/Identity/Users/Features/ChangePassword/ChangePasswordValidator.cs +++ /dev/null @@ -1,18 +0,0 @@ -using FluentValidation; - -namespace FSH.Framework.Core.Identity.Users.Features.ChangePassword; -public class ChangePasswordValidator : AbstractValidator -{ - public ChangePasswordValidator() - { - RuleFor(p => p.Password) - .NotEmpty(); - - RuleFor(p => p.NewPassword) - .NotEmpty(); - - RuleFor(p => p.ConfirmNewPassword) - .Equal(p => p.NewPassword) - .WithMessage("passwords do not match."); - } -} diff --git a/src/api/framework/Core/Identity/Users/Features/ForgotPassword/ForgotPasswordCommand.cs b/src/api/framework/Core/Identity/Users/Features/ForgotPassword/ForgotPasswordCommand.cs deleted file mode 100644 index 5419a554fb..0000000000 --- a/src/api/framework/Core/Identity/Users/Features/ForgotPassword/ForgotPasswordCommand.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace FSH.Framework.Core.Identity.Users.Features.ForgotPassword; -public class ForgotPasswordCommand -{ - public string Email { get; set; } = default!; -} diff --git a/src/api/framework/Core/Identity/Users/Features/ForgotPassword/ForgotPasswordValidator.cs b/src/api/framework/Core/Identity/Users/Features/ForgotPassword/ForgotPasswordValidator.cs deleted file mode 100644 index 2df57f5be4..0000000000 --- a/src/api/framework/Core/Identity/Users/Features/ForgotPassword/ForgotPasswordValidator.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FluentValidation; - -namespace FSH.Framework.Core.Identity.Users.Features.ForgotPassword; -public class ForgotPasswordValidator : AbstractValidator -{ - public ForgotPasswordValidator() - { - RuleFor(p => p.Email).Cascade(CascadeMode.Stop) - .NotEmpty() - .EmailAddress(); - } -} diff --git a/src/api/framework/Core/Identity/Users/Features/RegisterUser/RegisterUserCommand.cs b/src/api/framework/Core/Identity/Users/Features/RegisterUser/RegisterUserCommand.cs deleted file mode 100644 index 34089d0470..0000000000 --- a/src/api/framework/Core/Identity/Users/Features/RegisterUser/RegisterUserCommand.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Text.Json.Serialization; -using MediatR; - -namespace FSH.Framework.Core.Identity.Users.Features.RegisterUser; -public class RegisterUserCommand : IRequest -{ - public string FirstName { get; set; } = default!; - public string LastName { get; set; } = default!; - public string Email { get; set; } = default!; - public string UserName { get; set; } = default!; - public string Password { get; set; } = default!; - public string ConfirmPassword { get; set; } = default!; - public string? PhoneNumber { get; set; } - - [JsonIgnore] - public string? Origin { get; set; } -} diff --git a/src/api/framework/Core/Identity/Users/Features/RegisterUser/RegisterUserResponse.cs b/src/api/framework/Core/Identity/Users/Features/RegisterUser/RegisterUserResponse.cs deleted file mode 100644 index 967539ae78..0000000000 --- a/src/api/framework/Core/Identity/Users/Features/RegisterUser/RegisterUserResponse.cs +++ /dev/null @@ -1,2 +0,0 @@ -namespace FSH.Framework.Core.Identity.Users.Features.RegisterUser; -public record RegisterUserResponse(string UserId); diff --git a/src/api/framework/Core/Identity/Users/Features/ResetPassword/ResetPasswordCommand.cs b/src/api/framework/Core/Identity/Users/Features/ResetPassword/ResetPasswordCommand.cs deleted file mode 100644 index 244aff2e93..0000000000 --- a/src/api/framework/Core/Identity/Users/Features/ResetPassword/ResetPasswordCommand.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace FSH.Framework.Core.Identity.Users.Features.ResetPassword; -public class ResetPasswordCommand -{ - public string Email { get; set; } = default!; - - public string Password { get; set; } = default!; - - public string Token { get; set; } = default!; -} diff --git a/src/api/framework/Core/Identity/Users/Features/ResetPassword/ResetPasswordValidator.cs b/src/api/framework/Core/Identity/Users/Features/ResetPassword/ResetPasswordValidator.cs deleted file mode 100644 index 4141905651..0000000000 --- a/src/api/framework/Core/Identity/Users/Features/ResetPassword/ResetPasswordValidator.cs +++ /dev/null @@ -1,13 +0,0 @@ -using FluentValidation; - -namespace FSH.Framework.Core.Identity.Users.Features.ResetPassword; - -public class ResetPasswordValidator : AbstractValidator -{ - public ResetPasswordValidator() - { - RuleFor(x => x.Email).NotEmpty().EmailAddress(); - RuleFor(x => x.Password).NotEmpty(); - RuleFor(x => x.Token).NotEmpty(); - } -} diff --git a/src/api/framework/Core/Identity/Users/Features/ToggleUserStatus/ToggleUserStatusCommand.cs b/src/api/framework/Core/Identity/Users/Features/ToggleUserStatus/ToggleUserStatusCommand.cs deleted file mode 100644 index 8b3697293e..0000000000 --- a/src/api/framework/Core/Identity/Users/Features/ToggleUserStatus/ToggleUserStatusCommand.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace FSH.Framework.Core.Identity.Users.Features.ToggleUserStatus; -public class ToggleUserStatusCommand -{ - public bool ActivateUser { get; set; } - public string? UserId { get; set; } -} diff --git a/src/api/framework/Core/Identity/Users/Features/UpdateUser/UpdateUserCommand.cs b/src/api/framework/Core/Identity/Users/Features/UpdateUser/UpdateUserCommand.cs deleted file mode 100644 index 470516218e..0000000000 --- a/src/api/framework/Core/Identity/Users/Features/UpdateUser/UpdateUserCommand.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FSH.Framework.Core.Storage.File.Features; -using MediatR; - -namespace FSH.Framework.Core.Identity.Users.Features.UpdateUser; -public class UpdateUserCommand : IRequest -{ - public string Id { get; set; } = default!; - public string? FirstName { get; set; } - public string? LastName { get; set; } - public string? PhoneNumber { get; set; } - public string? Email { get; set; } - public FileUploadCommand? Image { get; set; } - public bool DeleteCurrentImage { get; set; } -} diff --git a/src/api/framework/Core/Mail/IMailService.cs b/src/api/framework/Core/Mail/IMailService.cs deleted file mode 100644 index c5e000951b..0000000000 --- a/src/api/framework/Core/Mail/IMailService.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace FSH.Framework.Core.Mail; -public interface IMailService -{ - Task SendAsync(MailRequest request, CancellationToken ct); -} diff --git a/src/api/framework/Core/Origin/OriginOptions.cs b/src/api/framework/Core/Origin/OriginOptions.cs deleted file mode 100644 index 97e1c35423..0000000000 --- a/src/api/framework/Core/Origin/OriginOptions.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace FSH.Framework.Core.Origin; - -public class OriginOptions -{ - public Uri? OriginUrl { get; set; } -} diff --git a/src/api/framework/Core/Paging/BaseFilter.cs b/src/api/framework/Core/Paging/BaseFilter.cs deleted file mode 100644 index 2bb5b099be..0000000000 --- a/src/api/framework/Core/Paging/BaseFilter.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace FSH.Framework.Core.Paging; - -public class BaseFilter -{ - /// - /// Column Wise Search is Supported. - /// - public Search? AdvancedSearch { get; set; } - - /// - /// Keyword to Search in All the available columns of the Resource. - /// - public string? Keyword { get; set; } - - /// - /// Advanced column filtering with logical operators and query operators is supported. - /// - public Filter? AdvancedFilter { get; set; } -} diff --git a/src/api/framework/Core/Paging/Extensions.cs b/src/api/framework/Core/Paging/Extensions.cs deleted file mode 100644 index a9c5544eb9..0000000000 --- a/src/api/framework/Core/Paging/Extensions.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Ardalis.Specification; - -namespace FSH.Framework.Core.Paging; -public static class Extensions -{ - public static async Task> PaginatedListAsync( - this IReadRepositoryBase repository, ISpecification spec, PaginationFilter filter, CancellationToken cancellationToken = default) - where T : class - where TDestination : class - { - ArgumentNullException.ThrowIfNull(repository); - - var items = await repository.ListAsync(spec, cancellationToken).ConfigureAwait(false); - int totalCount = await repository.CountAsync(spec, cancellationToken).ConfigureAwait(false); - - return new PagedList(items, filter.PageNumber, filter.PageSize, totalCount); - } -} diff --git a/src/api/framework/Core/Paging/Filter.cs b/src/api/framework/Core/Paging/Filter.cs deleted file mode 100644 index fdbdc29387..0000000000 --- a/src/api/framework/Core/Paging/Filter.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace FSH.Framework.Core.Paging; - -public static class FilterOperator -{ - public const string EQ = "eq"; - public const string NEQ = "neq"; - public const string LT = "lt"; - public const string LTE = "lte"; - public const string GT = "gt"; - public const string GTE = "gte"; - public const string STARTSWITH = "startswith"; - public const string ENDSWITH = "endswith"; - public const string CONTAINS = "contains"; -} - -public static class FilterLogic -{ - public const string AND = "and"; - public const string OR = "or"; - public const string XOR = "xor"; -} - -public class Filter -{ - public string? Logic { get; set; } - - public IEnumerable? Filters { get; set; } - - public string? Field { get; set; } - - public string? Operator { get; set; } - - public object? Value { get; set; } -} diff --git a/src/api/framework/Core/Paging/IPageRequest.cs b/src/api/framework/Core/Paging/IPageRequest.cs deleted file mode 100644 index c4a2a7f147..0000000000 --- a/src/api/framework/Core/Paging/IPageRequest.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace FSH.Framework.Core.Paging; - -public interface IPageRequest -{ - int PageNumber { get; init; } - int PageSize { get; init; } - string? Filters { get; init; } - string? SortOrder { get; init; } -} diff --git a/src/api/framework/Core/Paging/IPagedList.cs b/src/api/framework/Core/Paging/IPagedList.cs deleted file mode 100644 index e2950b3984..0000000000 --- a/src/api/framework/Core/Paging/IPagedList.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace FSH.Framework.Core.Paging; - -public interface IPagedList - where T : class -{ - int TotalPages { get; } - bool HasPrevious { get; } - bool HasNext { get; } - IReadOnlyList Items { get; init; } - int TotalCount { get; init; } - int PageNumber { get; init; } - int PageSize { get; init; } - - IPagedList MapTo(Func map) - where TR : class; - IPagedList MapTo() - where TR : class; -} diff --git a/src/api/framework/Core/Paging/PagedList.cs b/src/api/framework/Core/Paging/PagedList.cs deleted file mode 100644 index 7f48292f7c..0000000000 --- a/src/api/framework/Core/Paging/PagedList.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Mapster; - -namespace FSH.Framework.Core.Paging; - -public record PagedList(IReadOnlyList Items, int PageNumber, int PageSize, int TotalCount) : IPagedList - where T : class -{ - public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); - public bool HasPrevious => PageNumber > 1; - public bool HasNext => PageNumber < TotalPages; - public IPagedList MapTo(Func map) - where TR : class - { - return new PagedList(Items.Select(map).ToList(), PageNumber, PageSize, TotalCount); - } - public IPagedList MapTo() - where TR : class - { - return new PagedList(Items.Adapt>(), PageNumber, PageSize, TotalCount); - } -} diff --git a/src/api/framework/Core/Paging/PaginationFilter.cs b/src/api/framework/Core/Paging/PaginationFilter.cs deleted file mode 100644 index 13be4026ee..0000000000 --- a/src/api/framework/Core/Paging/PaginationFilter.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace FSH.Framework.Core.Paging; - -public class PaginationFilter : BaseFilter -{ - public int PageNumber { get; set; } - - public int PageSize { get; set; } = int.MaxValue; - public string[]? OrderBy { get; set; } -} - -public static class PaginationFilterExtensions -{ - public static bool HasOrderBy(this PaginationFilter filter) => - filter.OrderBy?.Any() is true; -} diff --git a/src/api/framework/Core/Paging/Search.cs b/src/api/framework/Core/Paging/Search.cs deleted file mode 100644 index a2f2624980..0000000000 --- a/src/api/framework/Core/Paging/Search.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace FSH.Framework.Core.Paging; - -public class Search -{ - public List Fields { get; set; } = new(); - public string? Keyword { get; set; } -} diff --git a/src/api/framework/Core/Persistence/DatabaseOptions.cs b/src/api/framework/Core/Persistence/DatabaseOptions.cs deleted file mode 100644 index 5be4fb9e02..0000000000 --- a/src/api/framework/Core/Persistence/DatabaseOptions.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace FSH.Framework.Core.Persistence; -public class DatabaseOptions : IValidatableObject -{ - public string Provider { get; set; } = "postgresql"; - public string ConnectionString { get; set; } = string.Empty; - - public IEnumerable Validate(ValidationContext validationContext) - { - if (string.IsNullOrEmpty(ConnectionString)) - { - yield return new ValidationResult("connection string cannot be empty.", new[] { nameof(ConnectionString) }); - } - } -} diff --git a/src/api/framework/Core/Persistence/IConnectionStringValidator.cs b/src/api/framework/Core/Persistence/IConnectionStringValidator.cs deleted file mode 100644 index 413e4fc76c..0000000000 --- a/src/api/framework/Core/Persistence/IConnectionStringValidator.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace FSH.Framework.Core.Persistence; -public interface IConnectionStringValidator -{ - bool TryValidate(string connectionString, string? dbProvider = null); -} diff --git a/src/api/framework/Core/Persistence/IRepository.cs b/src/api/framework/Core/Persistence/IRepository.cs deleted file mode 100644 index 3915bb3caa..0000000000 --- a/src/api/framework/Core/Persistence/IRepository.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Ardalis.Specification; -using FSH.Framework.Core.Domain.Contracts; - -namespace FSH.Framework.Core.Persistence; -public interface IRepository : IRepositoryBase - where T : class, IAggregateRoot -{ -} - -public interface IReadRepository : IReadRepositoryBase - where T : class, IAggregateRoot -{ -} diff --git a/src/api/framework/Core/Specifications/EntitiesByBaseFilterSpec.cs b/src/api/framework/Core/Specifications/EntitiesByBaseFilterSpec.cs deleted file mode 100644 index 643bcb675a..0000000000 --- a/src/api/framework/Core/Specifications/EntitiesByBaseFilterSpec.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Ardalis.Specification; -using FSH.Framework.Core.Paging; - -namespace FSH.Framework.Core.Specifications; - -public class EntitiesByBaseFilterSpec : Specification -{ - public EntitiesByBaseFilterSpec(BaseFilter filter) => - Query.SearchBy(filter); -} - -public class EntitiesByBaseFilterSpec : Specification -{ - public EntitiesByBaseFilterSpec(BaseFilter filter) => - Query.SearchBy(filter); -} diff --git a/src/api/framework/Core/Specifications/EntitiesByPaginationFilterSpec.cs b/src/api/framework/Core/Specifications/EntitiesByPaginationFilterSpec.cs deleted file mode 100644 index abdf49eeb7..0000000000 --- a/src/api/framework/Core/Specifications/EntitiesByPaginationFilterSpec.cs +++ /dev/null @@ -1,17 +0,0 @@ -using FSH.Framework.Core.Paging; - -namespace FSH.Framework.Core.Specifications; - -public class EntitiesByPaginationFilterSpec : EntitiesByBaseFilterSpec -{ - public EntitiesByPaginationFilterSpec(PaginationFilter filter) - : base(filter) => - Query.PaginateBy(filter); -} - -public class EntitiesByPaginationFilterSpec : EntitiesByBaseFilterSpec -{ - public EntitiesByPaginationFilterSpec(PaginationFilter filter) - : base(filter) => - Query.PaginateBy(filter); -} diff --git a/src/api/framework/Core/Specifications/SpecificationBuilderExtensions.cs b/src/api/framework/Core/Specifications/SpecificationBuilderExtensions.cs deleted file mode 100644 index 81b352056d..0000000000 --- a/src/api/framework/Core/Specifications/SpecificationBuilderExtensions.cs +++ /dev/null @@ -1,348 +0,0 @@ -using System.Linq.Expressions; -using System.Reflection; -using System.Text.Json; -using Ardalis.Specification; -using FSH.Framework.Core.Exceptions; -using FSH.Framework.Core.Paging; - -namespace FSH.Framework.Core.Specifications; - -// See https://github.com/ardalis/Specification/issues/53 -public static class SpecificationBuilderExtensions -{ - public static ISpecificationBuilder SearchBy(this ISpecificationBuilder query, BaseFilter filter) => - query - .SearchByKeyword(filter.Keyword) - .AdvancedSearch(filter.AdvancedSearch) - .AdvancedFilter(filter.AdvancedFilter); - - public static ISpecificationBuilder PaginateBy(this ISpecificationBuilder query, PaginationFilter filter) - { - if (filter.PageNumber <= 0) - { - filter.PageNumber = 1; - } - - if (filter.PageSize <= 0) - { - filter.PageSize = 10; - } - - if (filter.PageNumber > 1) - { - query = query.Skip((filter.PageNumber - 1) * filter.PageSize); - } - - return query - .Take(filter.PageSize) - .OrderBy(filter.OrderBy); - } - - public static IOrderedSpecificationBuilder SearchByKeyword( - this ISpecificationBuilder specificationBuilder, - string? keyword) => - specificationBuilder.AdvancedSearch(new Search { Keyword = keyword }); - - public static IOrderedSpecificationBuilder AdvancedSearch( - this ISpecificationBuilder specificationBuilder, - Search? search) - { - if (!string.IsNullOrEmpty(search?.Keyword)) - { - if (search.Fields?.Any() is true) - { - // search seleted fields (can contain deeper nested fields) - foreach (string field in search.Fields) - { - var paramExpr = Expression.Parameter(typeof(T)); - MemberExpression propertyExpr = GetPropertyExpression(field, paramExpr); - - specificationBuilder.AddSearchPropertyByKeyword(propertyExpr, paramExpr, search.Keyword); - } - } - else - { - // search all fields (only first level) - foreach (var property in typeof(T).GetProperties() - .Where(prop => (Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType) is { } propertyType - && !propertyType.IsEnum - && Type.GetTypeCode(propertyType) != TypeCode.Object)) - { - var paramExpr = Expression.Parameter(typeof(T)); - var propertyExpr = Expression.Property(paramExpr, property); - - specificationBuilder.AddSearchPropertyByKeyword(propertyExpr, paramExpr, search.Keyword); - } - } - } - - return new OrderedSpecificationBuilder(specificationBuilder.Specification); - } - - private static void AddSearchPropertyByKeyword( - this ISpecificationBuilder specificationBuilder, - Expression propertyExpr, - ParameterExpression paramExpr, - string keyword, - string operatorSearch = FilterOperator.CONTAINS) - { - if (propertyExpr is not MemberExpression memberExpr || memberExpr.Member is not PropertyInfo property) - { - throw new ArgumentException("propertyExpr must be a property expression.", nameof(propertyExpr)); - } - - string searchTerm = operatorSearch switch - { - FilterOperator.STARTSWITH => $"{keyword.ToLower()}%", - FilterOperator.ENDSWITH => $"%{keyword.ToLower()}", - FilterOperator.CONTAINS => $"%{keyword.ToLower()}%", - _ => throw new ArgumentException("operatorSearch is not valid.", nameof(operatorSearch)) - }; - - // Generate lambda [ x => x.Property ] for string properties - // or [ x => ((object)x.Property) == null ? null : x.Property.ToString() ] for other properties - Expression selectorExpr = - property.PropertyType == typeof(string) - ? propertyExpr - : Expression.Condition( - Expression.Equal(Expression.Convert(propertyExpr, typeof(object)), Expression.Constant(null, typeof(object))), - Expression.Constant(null, typeof(string)), - Expression.Call(propertyExpr, "ToString", null, null)); - - var toLowerMethod = typeof(string).GetMethod("ToLower", Type.EmptyTypes); - Expression callToLowerMethod = Expression.Call(selectorExpr, toLowerMethod!); - - var selector = Expression.Lambda>(callToLowerMethod, paramExpr); - - ((List>)specificationBuilder.Specification.SearchCriterias) - .Add(new SearchExpressionInfo(selector, searchTerm, 1)); - } - - public static IOrderedSpecificationBuilder AdvancedFilter( - this ISpecificationBuilder specificationBuilder, - Filter? filter) - { - if (filter is not null) - { - var parameter = Expression.Parameter(typeof(T)); - - Expression binaryExpresioFilter; - - if (!string.IsNullOrEmpty(filter.Logic)) - { - if (filter.Filters is null) throw new CustomException("The Filters attribute is required when declaring a logic"); - binaryExpresioFilter = CreateFilterExpression(filter.Logic, filter.Filters, parameter); - } - else - { - var filterValid = GetValidFilter(filter); - binaryExpresioFilter = CreateFilterExpression(filterValid.Field!, filterValid.Operator!, filterValid.Value, parameter); - } - - ((List>)specificationBuilder.Specification.WhereExpressions) - .Add(new WhereExpressionInfo(Expression.Lambda>(binaryExpresioFilter, parameter))); - } - - return new OrderedSpecificationBuilder(specificationBuilder.Specification); - } - - private static Expression CreateFilterExpression( - string logic, - IEnumerable filters, - ParameterExpression parameter) - { - Expression filterExpression = default!; - - foreach (var filter in filters) - { - Expression bExpresionFilter; - - if (!string.IsNullOrEmpty(filter.Logic)) - { - if (filter.Filters is null) throw new CustomException("The Filters attribute is required when declaring a logic"); - bExpresionFilter = CreateFilterExpression(filter.Logic, filter.Filters, parameter); - } - else - { - var filterValid = GetValidFilter(filter); - bExpresionFilter = CreateFilterExpression(filterValid.Field!, filterValid.Operator!, filterValid.Value, parameter); - } - - filterExpression = filterExpression is null ? bExpresionFilter : CombineFilter(logic, filterExpression, bExpresionFilter); - } - - return filterExpression; - } - - private static Expression CreateFilterExpression( - string field, - string filterOperator, - object? value, - ParameterExpression parameter) - { - var propertyExpresion = GetPropertyExpression(field, parameter); - var valueExpresion = GeValuetExpression(field, value, propertyExpresion.Type); - return CreateFilterExpression(propertyExpresion, valueExpresion, filterOperator); - } - - private static Expression CreateFilterExpression( - Expression memberExpression, - Expression constantExpression, - string filterOperator) - { - if (memberExpression.Type == typeof(string)) - { - constantExpression = Expression.Call(constantExpression, "ToLower", null); - memberExpression = Expression.Call(memberExpression, "ToLower", null); - } - - return filterOperator switch - { - FilterOperator.EQ => Expression.Equal(memberExpression, constantExpression), - FilterOperator.NEQ => Expression.NotEqual(memberExpression, constantExpression), - FilterOperator.LT => Expression.LessThan(memberExpression, constantExpression), - FilterOperator.LTE => Expression.LessThanOrEqual(memberExpression, constantExpression), - FilterOperator.GT => Expression.GreaterThan(memberExpression, constantExpression), - FilterOperator.GTE => Expression.GreaterThanOrEqual(memberExpression, constantExpression), - FilterOperator.CONTAINS => Expression.Call(memberExpression, "Contains", null, constantExpression), - FilterOperator.STARTSWITH => Expression.Call(memberExpression, "StartsWith", null, constantExpression), - FilterOperator.ENDSWITH => Expression.Call(memberExpression, "EndsWith", null, constantExpression), - _ => throw new CustomException("Filter Operator is not valid."), - }; - } - - private static Expression CombineFilter( - string filterOperator, - Expression bExpresionBase, - Expression bExpresion) => filterOperator switch - { - FilterLogic.AND => Expression.And(bExpresionBase, bExpresion), - FilterLogic.OR => Expression.Or(bExpresionBase, bExpresion), - FilterLogic.XOR => Expression.ExclusiveOr(bExpresionBase, bExpresion), - _ => throw new ArgumentException("FilterLogic is not valid."), - }; - - private static MemberExpression GetPropertyExpression( - string propertyName, - ParameterExpression parameter) - { - Expression propertyExpression = parameter; - foreach (string member in propertyName.Split('.')) - { - propertyExpression = Expression.PropertyOrField(propertyExpression, member); - } - - return (MemberExpression)propertyExpression; - } - - private static string GetStringFromJsonElement(object value) - => ((JsonElement)value).GetString()!; - - private static ConstantExpression GeValuetExpression( - string field, - object? value, - Type propertyType) - { - if (value == null) return Expression.Constant(null, propertyType); - - if (propertyType.IsEnum) - { - string? stringEnum = GetStringFromJsonElement(value); - - if (!Enum.TryParse(propertyType, stringEnum, true, out object? valueparsed)) throw new CustomException(string.Format("Value {0} is not valid for {1}", value, field)); - - return Expression.Constant(valueparsed, propertyType); - } - - if (propertyType == typeof(Guid)) - { - string? stringGuid = GetStringFromJsonElement(value); - - if (!Guid.TryParse(stringGuid, out Guid valueparsed)) throw new CustomException(string.Format("Value {0} is not valid for {1}", value, field)); - - return Expression.Constant(valueparsed, propertyType); - } - - if (propertyType == typeof(string)) - { - string? text = GetStringFromJsonElement(value); - - return Expression.Constant(text, propertyType); - } - - if (propertyType == typeof(DateTime) || propertyType == typeof(DateTime?)) - { - string? text = GetStringFromJsonElement(value); - return Expression.Constant(ChangeType(text, propertyType), propertyType); - } - - return Expression.Constant(ChangeType(((JsonElement)value).GetRawText(), propertyType), propertyType); - } - - public static dynamic? ChangeType(object value, Type conversion) - { - var t = conversion; - - if (t.IsGenericType && t.GetGenericTypeDefinition().Equals(typeof(Nullable<>))) - { - if (value == null) - { - return null; - } - - t = Nullable.GetUnderlyingType(t); - } - - return Convert.ChangeType(value, t!); - } - - private static Filter GetValidFilter(Filter filter) - { - if (string.IsNullOrEmpty(filter.Field)) throw new CustomException("The field attribute is required when declaring a filter"); - if (string.IsNullOrEmpty(filter.Operator)) throw new CustomException("The Operator attribute is required when declaring a filter"); - return filter; - } - - public static IOrderedSpecificationBuilder OrderBy( - this ISpecificationBuilder specificationBuilder, - string[]? orderByFields) - { - if (orderByFields is not null) - { - foreach (var field in ParseOrderBy(orderByFields)) - { - var paramExpr = Expression.Parameter(typeof(T)); - - Expression propertyExpr = paramExpr; - foreach (string member in field.Key.Split('.')) - { - propertyExpr = Expression.PropertyOrField(propertyExpr, member); - } - - var keySelector = Expression.Lambda>( - Expression.Convert(propertyExpr, typeof(object)), - paramExpr); - - ((List>)specificationBuilder.Specification.OrderExpressions) - .Add(new OrderExpressionInfo(keySelector, field.Value)); - } - } - - return new OrderedSpecificationBuilder(specificationBuilder.Specification); - } - - private static Dictionary ParseOrderBy(string[] orderByFields) => - new(orderByFields.Select((orderByfield, index) => - { - string[] fieldParts = orderByfield.Split(' '); - string field = fieldParts[0]; - bool descending = fieldParts.Length > 1 && fieldParts[1].StartsWith("Desc", StringComparison.OrdinalIgnoreCase); - var orderBy = index == 0 - ? descending ? OrderTypeEnum.OrderByDescending - : OrderTypeEnum.OrderBy - : descending ? OrderTypeEnum.ThenByDescending - : OrderTypeEnum.ThenBy; - - return new KeyValuePair(field, orderBy); - })); -} diff --git a/src/api/framework/Core/Storage/File/Features/FileUploadCommand.cs b/src/api/framework/Core/Storage/File/Features/FileUploadCommand.cs deleted file mode 100644 index c4e5cb0e53..0000000000 --- a/src/api/framework/Core/Storage/File/Features/FileUploadCommand.cs +++ /dev/null @@ -1,11 +0,0 @@ -using MediatR; - -namespace FSH.Framework.Core.Storage.File.Features; - -public class FileUploadCommand : IRequest -{ - public string Name { get; set; } = default!; - public string Extension { get; set; } = default!; - public string Data { get; set; } = default!; -} - diff --git a/src/api/framework/Core/Storage/File/Features/FileUploadResponse.cs b/src/api/framework/Core/Storage/File/Features/FileUploadResponse.cs deleted file mode 100644 index f3af35debf..0000000000 --- a/src/api/framework/Core/Storage/File/Features/FileUploadResponse.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace FSH.Framework.Core.Storage.File.Features; - -public class FileUploadResponse -{ - public Uri Url { get; set; } = default!; -} - diff --git a/src/api/framework/Core/Storage/File/Features/FileUploadValidator.cs b/src/api/framework/Core/Storage/File/Features/FileUploadValidator.cs deleted file mode 100644 index c064cf93f3..0000000000 --- a/src/api/framework/Core/Storage/File/Features/FileUploadValidator.cs +++ /dev/null @@ -1,21 +0,0 @@ -using FluentValidation; - -namespace FSH.Framework.Core.Storage.File.Features; - -public class FileUploadRequestValidator : AbstractValidator -{ - public FileUploadRequestValidator() - { - RuleFor(p => p.Name) - .NotEmpty() - .MaximumLength(150); - - RuleFor(p => p.Extension) - .NotEmpty() - .MaximumLength(5); - - RuleFor(p => p.Data) - .NotEmpty(); - } -} - diff --git a/src/api/framework/Core/Storage/File/FileType.cs b/src/api/framework/Core/Storage/File/FileType.cs deleted file mode 100644 index 267968aaa6..0000000000 --- a/src/api/framework/Core/Storage/File/FileType.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.ComponentModel; - -namespace FSH.Framework.Core.Storage.File; - -public enum FileType -{ - [Description(".jpg,.png,.jpeg")] - Image -} diff --git a/src/api/framework/Core/Storage/IStorageService.cs b/src/api/framework/Core/Storage/IStorageService.cs deleted file mode 100644 index 5e13d6ddec..0000000000 --- a/src/api/framework/Core/Storage/IStorageService.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FSH.Framework.Core.Storage.File; -using FSH.Framework.Core.Storage.File.Features; - -namespace FSH.Framework.Core.Storage; - -public interface IStorageService -{ - public Task UploadAsync(FileUploadCommand? request, FileType supportedFileType, CancellationToken cancellationToken = default) - where T : class; - - public void Remove(Uri? path); -} diff --git a/src/api/framework/Core/Tenant/Abstractions/ITenantService.cs b/src/api/framework/Core/Tenant/Abstractions/ITenantService.cs deleted file mode 100644 index d2540a2ff3..0000000000 --- a/src/api/framework/Core/Tenant/Abstractions/ITenantService.cs +++ /dev/null @@ -1,23 +0,0 @@ -using FSH.Framework.Core.Tenant.Dtos; -using FSH.Framework.Core.Tenant.Features.CreateTenant; - -namespace FSH.Framework.Core.Tenant.Abstractions; - -public interface ITenantService -{ - Task> GetAllAsync(); - - Task ExistsWithIdAsync(string id); - - Task ExistsWithNameAsync(string name); - - Task GetByIdAsync(string id); - - Task CreateAsync(CreateTenantCommand request, CancellationToken cancellationToken); - - Task ActivateAsync(string id, CancellationToken cancellationToken); - - Task DeactivateAsync(string id); - - Task UpgradeSubscription(string id, DateTime extendedExpiryDate); -} diff --git a/src/api/framework/Core/Tenant/Dtos/TenantDetail.cs b/src/api/framework/Core/Tenant/Dtos/TenantDetail.cs deleted file mode 100644 index c9e44f8b7d..0000000000 --- a/src/api/framework/Core/Tenant/Dtos/TenantDetail.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace FSH.Framework.Core.Tenant.Dtos; -public class TenantDetail -{ - public string Id { get; set; } = default!; - public string Name { get; set; } = default!; - public string? ConnectionString { get; set; } - public string AdminEmail { get; set; } = default!; - public bool IsActive { get; set; } - public DateTime ValidUpto { get; set; } - public string? Issuer { get; set; } -} diff --git a/src/api/framework/Core/Tenant/Features/ActivateTenant/ActivateTenantCommand.cs b/src/api/framework/Core/Tenant/Features/ActivateTenant/ActivateTenantCommand.cs deleted file mode 100644 index 01f902dfc9..0000000000 --- a/src/api/framework/Core/Tenant/Features/ActivateTenant/ActivateTenantCommand.cs +++ /dev/null @@ -1,4 +0,0 @@ -using MediatR; - -namespace FSH.Framework.Core.Tenant.Features.ActivateTenant; -public record ActivateTenantCommand(string TenantId) : IRequest; diff --git a/src/api/framework/Core/Tenant/Features/ActivateTenant/ActivateTenantHandler.cs b/src/api/framework/Core/Tenant/Features/ActivateTenant/ActivateTenantHandler.cs deleted file mode 100644 index ab018e532e..0000000000 --- a/src/api/framework/Core/Tenant/Features/ActivateTenant/ActivateTenantHandler.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FSH.Framework.Core.Tenant.Abstractions; -using MediatR; - -namespace FSH.Framework.Core.Tenant.Features.ActivateTenant; -public sealed class ActivateTenantHandler(ITenantService service) : IRequestHandler -{ - public async Task Handle(ActivateTenantCommand request, CancellationToken cancellationToken) - { - var status = await service.ActivateAsync(request.TenantId, cancellationToken); - return new ActivateTenantResponse(status); - } -} diff --git a/src/api/framework/Core/Tenant/Features/ActivateTenant/ActivateTenantResponse.cs b/src/api/framework/Core/Tenant/Features/ActivateTenant/ActivateTenantResponse.cs deleted file mode 100644 index bd396891df..0000000000 --- a/src/api/framework/Core/Tenant/Features/ActivateTenant/ActivateTenantResponse.cs +++ /dev/null @@ -1,2 +0,0 @@ -namespace FSH.Framework.Core.Tenant.Features.ActivateTenant; -public record ActivateTenantResponse(string Status); diff --git a/src/api/framework/Core/Tenant/Features/ActivateTenant/ActivateTenantValidator.cs b/src/api/framework/Core/Tenant/Features/ActivateTenant/ActivateTenantValidator.cs deleted file mode 100644 index de9bceb45e..0000000000 --- a/src/api/framework/Core/Tenant/Features/ActivateTenant/ActivateTenantValidator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using FluentValidation; - -namespace FSH.Framework.Core.Tenant.Features.ActivateTenant; -public sealed class ActivateTenantValidator : AbstractValidator -{ - public ActivateTenantValidator() => - RuleFor(t => t.TenantId) - .NotEmpty(); -} diff --git a/src/api/framework/Core/Tenant/Features/CreateTenant/CreateTenantCommand.cs b/src/api/framework/Core/Tenant/Features/CreateTenant/CreateTenantCommand.cs deleted file mode 100644 index a6bec85931..0000000000 --- a/src/api/framework/Core/Tenant/Features/CreateTenant/CreateTenantCommand.cs +++ /dev/null @@ -1,8 +0,0 @@ -using MediatR; - -namespace FSH.Framework.Core.Tenant.Features.CreateTenant; -public sealed record CreateTenantCommand(string Id, - string Name, - string? ConnectionString, - string AdminEmail, - string? Issuer) : IRequest; diff --git a/src/api/framework/Core/Tenant/Features/CreateTenant/CreateTenantHandler.cs b/src/api/framework/Core/Tenant/Features/CreateTenant/CreateTenantHandler.cs deleted file mode 100644 index d948367cb8..0000000000 --- a/src/api/framework/Core/Tenant/Features/CreateTenant/CreateTenantHandler.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FSH.Framework.Core.Tenant.Abstractions; -using MediatR; - -namespace FSH.Framework.Core.Tenant.Features.CreateTenant; -public sealed class CreateTenantHandler(ITenantService service) : IRequestHandler -{ - public async Task Handle(CreateTenantCommand request, CancellationToken cancellationToken) - { - var tenantId = await service.CreateAsync(request, cancellationToken); - return new CreateTenantResponse(tenantId); - } -} diff --git a/src/api/framework/Core/Tenant/Features/CreateTenant/CreateTenantResponse.cs b/src/api/framework/Core/Tenant/Features/CreateTenant/CreateTenantResponse.cs deleted file mode 100644 index 7a778e4f79..0000000000 --- a/src/api/framework/Core/Tenant/Features/CreateTenant/CreateTenantResponse.cs +++ /dev/null @@ -1,2 +0,0 @@ -namespace FSH.Framework.Core.Tenant.Features.CreateTenant; -public record CreateTenantResponse(string Id); diff --git a/src/api/framework/Core/Tenant/Features/CreateTenant/CreateTenantValidator.cs b/src/api/framework/Core/Tenant/Features/CreateTenant/CreateTenantValidator.cs deleted file mode 100644 index 16e9816afb..0000000000 --- a/src/api/framework/Core/Tenant/Features/CreateTenant/CreateTenantValidator.cs +++ /dev/null @@ -1,30 +0,0 @@ -using FluentValidation; -using FSH.Framework.Core.Persistence; -using FSH.Framework.Core.Tenant.Abstractions; - -namespace FSH.Framework.Core.Tenant.Features.CreateTenant; -public class CreateTenantValidator : AbstractValidator -{ - public CreateTenantValidator( - ITenantService tenantService, - IConnectionStringValidator connectionStringValidator) - { - RuleFor(t => t.Id).Cascade(CascadeMode.Stop) - .NotEmpty() - .MustAsync(async (id, _) => !await tenantService.ExistsWithIdAsync(id).ConfigureAwait(false)) - .WithMessage((_, id) => $"Tenant {id} already exists."); - - RuleFor(t => t.Name).Cascade(CascadeMode.Stop) - .NotEmpty() - .MustAsync(async (name, _) => !await tenantService.ExistsWithNameAsync(name!).ConfigureAwait(false)) - .WithMessage((_, name) => $"Tenant {name} already exists."); - - RuleFor(t => t.ConnectionString).Cascade(CascadeMode.Stop) - .Must((_, cs) => string.IsNullOrWhiteSpace(cs) || connectionStringValidator.TryValidate(cs)) - .WithMessage("Connection string invalid."); - - RuleFor(t => t.AdminEmail).Cascade(CascadeMode.Stop) - .NotEmpty() - .EmailAddress(); - } -} diff --git a/src/api/framework/Core/Tenant/Features/DisableTenant/DisableTenantCommand.cs b/src/api/framework/Core/Tenant/Features/DisableTenant/DisableTenantCommand.cs deleted file mode 100644 index bc0dc1fa95..0000000000 --- a/src/api/framework/Core/Tenant/Features/DisableTenant/DisableTenantCommand.cs +++ /dev/null @@ -1,4 +0,0 @@ -using MediatR; - -namespace FSH.Framework.Core.Tenant.Features.DisableTenant; -public record DisableTenantCommand(string TenantId) : IRequest; diff --git a/src/api/framework/Core/Tenant/Features/DisableTenant/DisableTenantHandler.cs b/src/api/framework/Core/Tenant/Features/DisableTenant/DisableTenantHandler.cs deleted file mode 100644 index d9cad8dcbd..0000000000 --- a/src/api/framework/Core/Tenant/Features/DisableTenant/DisableTenantHandler.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FSH.Framework.Core.Tenant.Abstractions; -using MediatR; - -namespace FSH.Framework.Core.Tenant.Features.DisableTenant; -public sealed class DisableTenantHandler(ITenantService service) : IRequestHandler -{ - public async Task Handle(DisableTenantCommand request, CancellationToken cancellationToken) - { - var status = await service.DeactivateAsync(request.TenantId); - return new DisableTenantResponse(status); - } -} diff --git a/src/api/framework/Core/Tenant/Features/DisableTenant/DisableTenantResponse.cs b/src/api/framework/Core/Tenant/Features/DisableTenant/DisableTenantResponse.cs deleted file mode 100644 index 89ce0c0538..0000000000 --- a/src/api/framework/Core/Tenant/Features/DisableTenant/DisableTenantResponse.cs +++ /dev/null @@ -1,2 +0,0 @@ -namespace FSH.Framework.Core.Tenant.Features.DisableTenant; -public record DisableTenantResponse(string Status); diff --git a/src/api/framework/Core/Tenant/Features/DisableTenant/DisableTenantValidator.cs b/src/api/framework/Core/Tenant/Features/DisableTenant/DisableTenantValidator.cs deleted file mode 100644 index 2c0831e209..0000000000 --- a/src/api/framework/Core/Tenant/Features/DisableTenant/DisableTenantValidator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using FluentValidation; - -namespace FSH.Framework.Core.Tenant.Features.DisableTenant; -public sealed class DisableTenantValidator : AbstractValidator -{ - public DisableTenantValidator() => - RuleFor(t => t.TenantId) - .NotEmpty(); -} diff --git a/src/api/framework/Core/Tenant/Features/GetTenantById/GetTenantByIdHandler.cs b/src/api/framework/Core/Tenant/Features/GetTenantById/GetTenantByIdHandler.cs deleted file mode 100644 index ec8e68737c..0000000000 --- a/src/api/framework/Core/Tenant/Features/GetTenantById/GetTenantByIdHandler.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FSH.Framework.Core.Tenant.Abstractions; -using FSH.Framework.Core.Tenant.Dtos; -using MediatR; - -namespace FSH.Framework.Core.Tenant.Features.GetTenantById; -public sealed class GetTenantByIdHandler(ITenantService service) : IRequestHandler -{ - public async Task Handle(GetTenantByIdQuery request, CancellationToken cancellationToken) - { - return await service.GetByIdAsync(request.TenantId); - } -} diff --git a/src/api/framework/Core/Tenant/Features/GetTenantById/GetTenantByIdQuery.cs b/src/api/framework/Core/Tenant/Features/GetTenantById/GetTenantByIdQuery.cs deleted file mode 100644 index 9f75bc68c4..0000000000 --- a/src/api/framework/Core/Tenant/Features/GetTenantById/GetTenantByIdQuery.cs +++ /dev/null @@ -1,5 +0,0 @@ -using FSH.Framework.Core.Tenant.Dtos; -using MediatR; - -namespace FSH.Framework.Core.Tenant.Features.GetTenantById; -public record GetTenantByIdQuery(string TenantId) : IRequest; diff --git a/src/api/framework/Core/Tenant/Features/GetTenants/GetTenantsHandler.cs b/src/api/framework/Core/Tenant/Features/GetTenants/GetTenantsHandler.cs deleted file mode 100644 index 1ccd5f90eb..0000000000 --- a/src/api/framework/Core/Tenant/Features/GetTenants/GetTenantsHandler.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FSH.Framework.Core.Tenant.Abstractions; -using FSH.Framework.Core.Tenant.Dtos; -using MediatR; - -namespace FSH.Framework.Core.Tenant.Features.GetTenants; -public sealed class GetTenantsHandler(ITenantService service) : IRequestHandler> -{ - public Task> Handle(GetTenantsQuery request, CancellationToken cancellationToken) - { - return service.GetAllAsync(); - } -} diff --git a/src/api/framework/Core/Tenant/Features/GetTenants/GetTenantsQuery.cs b/src/api/framework/Core/Tenant/Features/GetTenants/GetTenantsQuery.cs deleted file mode 100644 index dba6bc1896..0000000000 --- a/src/api/framework/Core/Tenant/Features/GetTenants/GetTenantsQuery.cs +++ /dev/null @@ -1,5 +0,0 @@ -using FSH.Framework.Core.Tenant.Dtos; -using MediatR; - -namespace FSH.Framework.Core.Tenant.Features.GetTenants; -public sealed class GetTenantsQuery : IRequest>; diff --git a/src/api/framework/Core/Tenant/Features/UpgradeSubscription/UpgradeSubscriptionCommand.cs b/src/api/framework/Core/Tenant/Features/UpgradeSubscription/UpgradeSubscriptionCommand.cs deleted file mode 100644 index f132f455b7..0000000000 --- a/src/api/framework/Core/Tenant/Features/UpgradeSubscription/UpgradeSubscriptionCommand.cs +++ /dev/null @@ -1,8 +0,0 @@ -using MediatR; - -namespace FSH.Framework.Core.Tenant.Features.UpgradeSubscription; -public class UpgradeSubscriptionCommand : IRequest -{ - public string Tenant { get; set; } = default!; - public DateTime ExtendedExpiryDate { get; set; } -} diff --git a/src/api/framework/Core/Tenant/Features/UpgradeSubscription/UpgradeSubscriptionHandler.cs b/src/api/framework/Core/Tenant/Features/UpgradeSubscription/UpgradeSubscriptionHandler.cs deleted file mode 100644 index e4cbbb4e7a..0000000000 --- a/src/api/framework/Core/Tenant/Features/UpgradeSubscription/UpgradeSubscriptionHandler.cs +++ /dev/null @@ -1,17 +0,0 @@ -using FSH.Framework.Core.Tenant.Abstractions; -using MediatR; - -namespace FSH.Framework.Core.Tenant.Features.UpgradeSubscription; - -public class UpgradeSubscriptionHandler : IRequestHandler -{ - private readonly ITenantService _tenantService; - - public UpgradeSubscriptionHandler(ITenantService tenantService) => _tenantService = tenantService; - - public async Task Handle(UpgradeSubscriptionCommand request, CancellationToken cancellationToken) - { - var validUpto = await _tenantService.UpgradeSubscription(request.Tenant, request.ExtendedExpiryDate); - return new UpgradeSubscriptionResponse(validUpto, request.Tenant); - } -} diff --git a/src/api/framework/Core/Tenant/Features/UpgradeSubscription/UpgradeSubscriptionResponse.cs b/src/api/framework/Core/Tenant/Features/UpgradeSubscription/UpgradeSubscriptionResponse.cs deleted file mode 100644 index ef14487b74..0000000000 --- a/src/api/framework/Core/Tenant/Features/UpgradeSubscription/UpgradeSubscriptionResponse.cs +++ /dev/null @@ -1,2 +0,0 @@ -namespace FSH.Framework.Core.Tenant.Features.UpgradeSubscription; -public record UpgradeSubscriptionResponse(DateTime NewValidity, string Tenant); diff --git a/src/api/framework/Core/Tenant/Features/UpgradeSubscription/UpgradeSubscriptionValidator.cs b/src/api/framework/Core/Tenant/Features/UpgradeSubscription/UpgradeSubscriptionValidator.cs deleted file mode 100644 index daddf1fbf1..0000000000 --- a/src/api/framework/Core/Tenant/Features/UpgradeSubscription/UpgradeSubscriptionValidator.cs +++ /dev/null @@ -1,11 +0,0 @@ -using FluentValidation; - -namespace FSH.Framework.Core.Tenant.Features.UpgradeSubscription; -public class UpgradeSubscriptionValidator : AbstractValidator -{ - public UpgradeSubscriptionValidator() - { - RuleFor(t => t.Tenant).NotEmpty(); - RuleFor(t => t.ExtendedExpiryDate).GreaterThan(DateTime.UtcNow); - } -} diff --git a/src/api/framework/Infrastructure/Auth/CurrentUserMiddleware.cs b/src/api/framework/Infrastructure/Auth/CurrentUserMiddleware.cs deleted file mode 100644 index b4deef0872..0000000000 --- a/src/api/framework/Infrastructure/Auth/CurrentUserMiddleware.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FSH.Framework.Core.Identity.Users.Abstractions; -using Microsoft.AspNetCore.Http; - -namespace FSH.Framework.Infrastructure.Auth; -public class CurrentUserMiddleware(ICurrentUserInitializer currentUserInitializer) : IMiddleware -{ - private readonly ICurrentUserInitializer _currentUserInitializer = currentUserInitializer; - - public async Task InvokeAsync(HttpContext context, RequestDelegate next) - { - _currentUserInitializer.SetCurrentUser(context.User); - await next(context); - } -} diff --git a/src/api/framework/Infrastructure/Auth/Jwt/ConfigureJwtBearerOptions.cs b/src/api/framework/Infrastructure/Auth/Jwt/ConfigureJwtBearerOptions.cs deleted file mode 100644 index 8e495d207b..0000000000 --- a/src/api/framework/Infrastructure/Auth/Jwt/ConfigureJwtBearerOptions.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Security.Claims; -using System.Text; -using FSH.Framework.Core.Auth.Jwt; -using FSH.Framework.Core.Exceptions; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; - -namespace FSH.Framework.Infrastructure.Auth.Jwt; -public class ConfigureJwtBearerOptions : IConfigureNamedOptions -{ - private readonly JwtOptions _options; - - public ConfigureJwtBearerOptions(IOptions options) - { - _options = options.Value; - } - - public void Configure(JwtBearerOptions options) - { - Configure(string.Empty, options); - } - - public void Configure(string? name, JwtBearerOptions options) - { - if (name != JwtBearerDefaults.AuthenticationScheme) - { - return; - } - - byte[] key = Encoding.ASCII.GetBytes(_options.Key); - - options.RequireHttpsMetadata = false; - options.SaveToken = true; - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(key), - ValidIssuer = JwtAuthConstants.Issuer, - ValidateIssuer = true, - ValidateLifetime = true, - ValidAudience = JwtAuthConstants.Audience, - ValidateAudience = true, - RoleClaimType = ClaimTypes.Role, - ClockSkew = TimeSpan.Zero - }; - options.Events = new JwtBearerEvents - { - OnChallenge = context => - { - context.HandleResponse(); - if (!context.Response.HasStarted) - { - throw new UnauthorizedException(); - } - - return Task.CompletedTask; - }, - OnForbidden = _ => throw new ForbiddenException(), - OnMessageReceived = context => - { - var accessToken = context.Request.Query["access_token"]; - - if (!string.IsNullOrEmpty(accessToken) && - context.HttpContext.Request.Path.StartsWithSegments("/notifications", StringComparison.OrdinalIgnoreCase)) - { - // Read the token out of the query string - context.Token = accessToken; - } - - return Task.CompletedTask; - } - }; - } -} diff --git a/src/api/framework/Infrastructure/Auth/Jwt/Extensions.cs b/src/api/framework/Infrastructure/Auth/Jwt/Extensions.cs deleted file mode 100644 index 87a94d3ec1..0000000000 --- a/src/api/framework/Infrastructure/Auth/Jwt/Extensions.cs +++ /dev/null @@ -1,33 +0,0 @@ -using FSH.Framework.Core.Auth.Jwt; -using FSH.Framework.Infrastructure.Auth.Policy; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace FSH.Framework.Infrastructure.Auth.Jwt; -internal static class Extensions -{ - internal static IServiceCollection ConfigureJwtAuth(this IServiceCollection services) - { - services.AddOptions() - .BindConfiguration(nameof(JwtOptions)) - .ValidateDataAnnotations() - .ValidateOnStart(); - - services.AddSingleton, ConfigureJwtBearerOptions>(); - services - .AddAuthentication(authentication => - { - authentication.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; - authentication.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; - }) - .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, null!); - - services.AddAuthorizationBuilder().AddRequiredPermissionPolicy(); - services.AddAuthorization(options => - { - options.FallbackPolicy = options.GetPolicy(RequiredPermissionDefaults.PolicyName); - }); - return services; - } -} diff --git a/src/api/framework/Infrastructure/Auth/Jwt/JwtAuthConstants.cs b/src/api/framework/Infrastructure/Auth/Jwt/JwtAuthConstants.cs deleted file mode 100644 index b766fdf804..0000000000 --- a/src/api/framework/Infrastructure/Auth/Jwt/JwtAuthConstants.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace FSH.Framework.Infrastructure.Auth.Jwt; -internal static class JwtAuthConstants -{ - public const string Issuer = "https://fullstackhero.net"; - public const string Audience = "fullstackhero"; -} diff --git a/src/api/framework/Infrastructure/Auth/Policy/PermissionAuthorizationRequirement.cs b/src/api/framework/Infrastructure/Auth/Policy/PermissionAuthorizationRequirement.cs deleted file mode 100644 index 1286921818..0000000000 --- a/src/api/framework/Infrastructure/Auth/Policy/PermissionAuthorizationRequirement.cs +++ /dev/null @@ -1,4 +0,0 @@ -using Microsoft.AspNetCore.Authorization; - -namespace FSH.Framework.Infrastructure.Auth.Policy; -public class PermissionAuthorizationRequirement : IAuthorizationRequirement; diff --git a/src/api/framework/Infrastructure/Auth/Policy/RequiredPermissionAttribute.cs b/src/api/framework/Infrastructure/Auth/Policy/RequiredPermissionAttribute.cs deleted file mode 100644 index cf7ea3d91e..0000000000 --- a/src/api/framework/Infrastructure/Auth/Policy/RequiredPermissionAttribute.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace FSH.Framework.Infrastructure.Auth.Policy; -public interface IRequiredPermissionMetadata -{ - HashSet RequiredPermissions { get; } -} - -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] -public sealed class RequiredPermissionAttribute(string? requiredPermission, params string[]? additionalRequiredPermissions) - : Attribute, IRequiredPermissionMetadata -{ - public HashSet RequiredPermissions { get; } = [requiredPermission!, .. additionalRequiredPermissions]; - public string? RequiredPermission { get; } - public string[]? AdditionalRequiredPermissions { get; } -} diff --git a/src/api/framework/Infrastructure/Behaviours/ValidationBehavior.cs b/src/api/framework/Infrastructure/Behaviours/ValidationBehavior.cs deleted file mode 100644 index 016652aeb7..0000000000 --- a/src/api/framework/Infrastructure/Behaviours/ValidationBehavior.cs +++ /dev/null @@ -1,23 +0,0 @@ -using FluentValidation; -using MediatR; - -namespace FSH.Framework.Infrastructure.Behaviours; -public class ValidationBehavior(IEnumerable> validators) : IPipelineBehavior - where TRequest : IRequest -{ - private readonly IEnumerable> _validators = validators; - - public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) - { - if (_validators.Any()) - { - var context = new ValidationContext(request); - var validationResults = await Task.WhenAll(_validators.Select(v => v.ValidateAsync(context, cancellationToken))); - var failures = validationResults.SelectMany(r => r.Errors).Where(f => f != null).ToList(); - - if (failures.Count > 0) - throw new ValidationException(failures); - } - return await next(); - } -} diff --git a/src/api/framework/Infrastructure/Caching/DistributedCacheService.cs b/src/api/framework/Infrastructure/Caching/DistributedCacheService.cs deleted file mode 100644 index f4353d69a5..0000000000 --- a/src/api/framework/Infrastructure/Caching/DistributedCacheService.cs +++ /dev/null @@ -1,161 +0,0 @@ -using System.Text; -using System.Text.Json; -using FSH.Framework.Core.Caching; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Logging; - -namespace FSH.Framework.Infrastructure.Caching; - -public class DistributedCacheService : ICacheService -{ - private readonly IDistributedCache _cache; - private readonly ILogger _logger; - - public DistributedCacheService(IDistributedCache cache, ILogger logger) - { - (_cache, _logger) = (cache, logger); - } - - public T? Get(string key) => - Get(key) is { } data - ? Deserialize(data) - : default; - - private byte[]? Get(string key) - { - ArgumentNullException.ThrowIfNull(key); - - try - { - return _cache.Get(key); - } - catch - { - return null; - } - } - - public async Task GetAsync(string key, CancellationToken token = default) => - await GetAsync(key, token) is { } data - ? Deserialize(data) - : default; - - private async Task GetAsync(string key, CancellationToken token = default) - { - try - { - return await _cache.GetAsync(key, token); - } - catch (Exception ex) - { - Console.WriteLine(ex); - return null; - } - } - - public void Refresh(string key) - { - try - { - _cache.Refresh(key); - } - catch - { - // can be ignored - } - } - - public async Task RefreshAsync(string key, CancellationToken token = default) - { - try - { - await _cache.RefreshAsync(key, token); - _logger.LogDebug("refreshed cache with key : {Key}", key); - } - catch - { - // can be ignored - } - } - - public void Remove(string key) - { - try - { - _cache.Remove(key); - } - catch - { - // can be ignored - } - } - - public async Task RemoveAsync(string key, CancellationToken token = default) - { - try - { - await _cache.RemoveAsync(key, token); - } - catch - { - // can be ignored - } - } - - public void Set(string key, T value, TimeSpan? slidingExpiration = null) => - Set(key, Serialize(value), slidingExpiration); - - private void Set(string key, byte[] value, TimeSpan? slidingExpiration = null) - { - try - { - _cache.Set(key, value, GetOptions(slidingExpiration)); - _logger.LogDebug("cached data with key : {Key}", key); - } - catch - { - // can be ignored - } - } - - public Task SetAsync(string key, T value, TimeSpan? slidingExpiration = null, CancellationToken cancellationToken = default) => - SetAsync(key, Serialize(value), slidingExpiration, cancellationToken); - - private async Task SetAsync(string key, byte[] value, TimeSpan? slidingExpiration = null, CancellationToken token = default) - { - try - { - await _cache.SetAsync(key, value, GetOptions(slidingExpiration), token); - _logger.LogDebug("cached data with key : {Key}", key); - } - catch - { - // can be ignored - } - } - - private static byte[] Serialize(T item) - { - return Encoding.Default.GetBytes(JsonSerializer.Serialize(item)); - } - - private static T Deserialize(byte[] cachedData) - { - return JsonSerializer.Deserialize(Encoding.Default.GetString(cachedData))!; - } - - private static DistributedCacheEntryOptions GetOptions(TimeSpan? slidingExpiration) - { - var options = new DistributedCacheEntryOptions(); - if (slidingExpiration.HasValue) - { - options.SetSlidingExpiration(slidingExpiration.Value); - } - else - { - options.SetSlidingExpiration(TimeSpan.FromMinutes(5)); - } - options.SetAbsoluteExpiration(TimeSpan.FromMinutes(15)); - return options; - } -} diff --git a/src/api/framework/Infrastructure/Caching/Extensions.cs b/src/api/framework/Infrastructure/Caching/Extensions.cs deleted file mode 100644 index c389a52a1b..0000000000 --- a/src/api/framework/Infrastructure/Caching/Extensions.cs +++ /dev/null @@ -1,34 +0,0 @@ -using FSH.Framework.Core.Caching; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Serilog; - -namespace FSH.Framework.Infrastructure.Caching; -internal static class Extensions -{ - private static readonly ILogger _logger = Log.ForContext(typeof(Extensions)); - internal static IServiceCollection ConfigureCaching(this IServiceCollection services, IConfiguration configuration) - { - services.AddTransient(); - var cacheOptions = configuration.GetSection(nameof(CacheOptions)).Get(); - if (cacheOptions == null || string.IsNullOrEmpty(cacheOptions.Redis)) - { - _logger.Information("configuring memory cache."); - services.AddDistributedMemoryCache(); - return services; - } - - _logger.Information("configuring redis cache."); - services.AddStackExchangeRedisCache(options => - { - options.Configuration = cacheOptions.Redis; - options.ConfigurationOptions = new StackExchange.Redis.ConfigurationOptions() - { - AbortOnConnectFail = true, - EndPoints = { cacheOptions.Redis! } - }; - }); - - return services; - } -} diff --git a/src/api/framework/Infrastructure/Common/Extensions/EnumExtensions.cs b/src/api/framework/Infrastructure/Common/Extensions/EnumExtensions.cs deleted file mode 100644 index d1f3014aa3..0000000000 --- a/src/api/framework/Infrastructure/Common/Extensions/EnumExtensions.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Collections.ObjectModel; -using System.ComponentModel; -using System.Text.RegularExpressions; - -namespace FSH.Framework.Infrastructure.Common.Extensions; -public static class EnumExtensions -{ - public static string GetDescription(this Enum enumValue) - { - object[] attr = enumValue.GetType().GetField(enumValue.ToString())! - .GetCustomAttributes(typeof(DescriptionAttribute), false); - if (attr.Length > 0) - return ((DescriptionAttribute)attr[0]).Description; - string result = enumValue.ToString(); - result = Regex.Replace(result, "([a-z])([A-Z])", "$1 $2"); - result = Regex.Replace(result, "([A-Za-z])([0-9])", "$1 $2"); - result = Regex.Replace(result, "([0-9])([A-Za-z])", "$1 $2"); - result = Regex.Replace(result, "(? GetDescriptionList(this Enum enumValue) - { - string result = enumValue.GetDescription(); - return new ReadOnlyCollection(result.Split(',').ToList()); - } -} diff --git a/src/api/framework/Infrastructure/Common/Extensions/RegexExtensions.cs b/src/api/framework/Infrastructure/Common/Extensions/RegexExtensions.cs deleted file mode 100644 index 0204acd3c5..0000000000 --- a/src/api/framework/Infrastructure/Common/Extensions/RegexExtensions.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Text.RegularExpressions; - -namespace FSH.Framework.Infrastructure.Common.Extensions; -public static class RegexExtensions -{ - private static readonly Regex Whitespace = new(@"\s+"); - - public static string ReplaceWhitespace(this string input, string replacement) - { - return Whitespace.Replace(input, replacement); - } -} diff --git a/src/api/framework/Infrastructure/Constants/QueryStringKeys.cs b/src/api/framework/Infrastructure/Constants/QueryStringKeys.cs deleted file mode 100644 index 0ea2d30503..0000000000 --- a/src/api/framework/Infrastructure/Constants/QueryStringKeys.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace FSH.Framework.Infrastructure.Constants; -public static class QueryStringKeys -{ - public const string Code = "code"; - public const string UserId = "userId"; -} diff --git a/src/api/framework/Infrastructure/Cors/CorsOptions.cs b/src/api/framework/Infrastructure/Cors/CorsOptions.cs deleted file mode 100644 index 8f2ef1b9eb..0000000000 --- a/src/api/framework/Infrastructure/Cors/CorsOptions.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace FSH.Framework.Infrastructure.Cors; -using System.Collections.ObjectModel; - -public class CorsOptions -{ - public CorsOptions() - { - AllowedOrigins = []; - } - - public Collection AllowedOrigins { get; } -} diff --git a/src/api/framework/Infrastructure/Cors/Extensions.cs b/src/api/framework/Infrastructure/Cors/Extensions.cs deleted file mode 100644 index d559de2067..0000000000 --- a/src/api/framework/Infrastructure/Cors/Extensions.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; - -namespace FSH.Framework.Infrastructure.Cors; -public static class Extensions -{ - private const string CorsPolicy = nameof(CorsPolicy); - internal static IServiceCollection AddCorsPolicy(this IServiceCollection services, IConfiguration config) - { - var corsOptions = config.GetSection(nameof(CorsOptions)).Get(); - if (corsOptions == null) { return services; } - return services.AddCors(opt => - opt.AddPolicy(CorsPolicy, policy => - policy.AllowAnyHeader() - .AllowAnyMethod() - .AllowCredentials() - .WithOrigins(corsOptions.AllowedOrigins.ToArray()))); - } - - internal static IApplicationBuilder UseCorsPolicy(this IApplicationBuilder app) - { - return app.UseCors(CorsPolicy); - } -} diff --git a/src/api/framework/Infrastructure/Exceptions/CustomExceptionHandler.cs b/src/api/framework/Infrastructure/Exceptions/CustomExceptionHandler.cs deleted file mode 100644 index c2d19308f2..0000000000 --- a/src/api/framework/Infrastructure/Exceptions/CustomExceptionHandler.cs +++ /dev/null @@ -1,51 +0,0 @@ -using FSH.Framework.Core.Exceptions; -using Microsoft.AspNetCore.Diagnostics; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Serilog.Context; - -namespace FSH.Framework.Infrastructure.Exceptions; -public class CustomExceptionHandler(ILogger logger) : IExceptionHandler -{ - public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(httpContext); - ArgumentNullException.ThrowIfNull(exception); - var problemDetails = new ProblemDetails(); - problemDetails.Instance = httpContext.Request.Path; - - if (exception is FluentValidation.ValidationException fluentException) - { - problemDetails.Detail = "one or more validation errors occurred"; - problemDetails.Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1"; - httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; - List validationErrors = new List(); - foreach (var error in fluentException.Errors) - { - validationErrors.Add(error.ErrorMessage); - } - problemDetails.Extensions.Add("errors", validationErrors); - } - - else if (exception is FshException e) - { - httpContext.Response.StatusCode = (int)e.StatusCode; - problemDetails.Detail = e.Message; - if (e.ErrorMessages != null && e.ErrorMessages.Any()) - { - problemDetails.Extensions.Add("errors", e.ErrorMessages); - } - } - - else - { - problemDetails.Detail = exception.Message; - } - - LogContext.PushProperty("StackTrace", exception.StackTrace); - logger.LogError("{ProblemDetail}", problemDetails.Detail); - await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken).ConfigureAwait(false); - return true; - } -} diff --git a/src/api/framework/Infrastructure/Extensions.cs b/src/api/framework/Infrastructure/Extensions.cs deleted file mode 100644 index 865bce172d..0000000000 --- a/src/api/framework/Infrastructure/Extensions.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System.Reflection; -using Asp.Versioning.Conventions; -using FluentValidation; -using FSH.Framework.Core; -using FSH.Framework.Core.Origin; -using FSH.Framework.Infrastructure.Auth; -using FSH.Framework.Infrastructure.Auth.Jwt; -using FSH.Framework.Infrastructure.Behaviours; -using FSH.Framework.Infrastructure.Caching; -using FSH.Framework.Infrastructure.Cors; -using FSH.Framework.Infrastructure.Exceptions; -using FSH.Framework.Infrastructure.Identity; -using FSH.Framework.Infrastructure.Jobs; -using FSH.Framework.Infrastructure.Logging.Serilog; -using FSH.Framework.Infrastructure.Mail; -using FSH.Framework.Infrastructure.OpenApi; -using FSH.Framework.Infrastructure.Persistence; -using FSH.Framework.Infrastructure.RateLimit; -using FSH.Framework.Infrastructure.SecurityHeaders; -using FSH.Framework.Infrastructure.Storage.Files; -using FSH.Framework.Infrastructure.Tenant; -using FSH.Framework.Infrastructure.Tenant.Endpoints; -using FSH.Starter.Aspire.ServiceDefaults; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.FileProviders; - -namespace FSH.Framework.Infrastructure; - -public static class Extensions -{ - public static WebApplicationBuilder ConfigureFshFramework(this WebApplicationBuilder builder) - { - ArgumentNullException.ThrowIfNull(builder); - builder.AddServiceDefaults(); - builder.ConfigureSerilog(); - builder.ConfigureDatabase(); - builder.Services.ConfigureMultitenancy(); - builder.Services.ConfigureIdentity(); - builder.Services.AddCorsPolicy(builder.Configuration); - builder.Services.ConfigureFileStorage(); - builder.Services.ConfigureJwtAuth(); - builder.Services.ConfigureOpenApi(); - builder.Services.ConfigureJobs(builder.Configuration); - builder.Services.ConfigureMailing(); - builder.Services.ConfigureCaching(builder.Configuration); - builder.Services.AddExceptionHandler(); - builder.Services.AddProblemDetails(); - builder.Services.AddHealthChecks(); - builder.Services.AddOptions().BindConfiguration(nameof(OriginOptions)); - - // Define module assemblies - var assemblies = new Assembly[] - { - typeof(FshCore).Assembly, - typeof(FshInfrastructure).Assembly - }; - - // Register validators - builder.Services.AddValidatorsFromAssemblies(assemblies); - - // Register MediatR - builder.Services.AddMediatR(cfg => - { - cfg.RegisterServicesFromAssemblies(assemblies); - cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); - }); - - builder.Services.ConfigureRateLimit(builder.Configuration); - builder.Services.ConfigureSecurityHeaders(builder.Configuration); - - return builder; - } - - public static WebApplication UseFshFramework(this WebApplication app) - { - app.MapDefaultEndpoints(); - app.UseRateLimit(); - app.UseSecurityHeaders(); - app.UseMultitenancy(); - app.UseExceptionHandler(); - app.UseCorsPolicy(); - app.UseOpenApi(); - app.UseJobDashboard(app.Configuration); - app.UseRouting(); - app.UseStaticFiles(); - app.UseStaticFiles(new StaticFileOptions() - { - FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "assets")), - RequestPath = new PathString("/assets") - }); - app.UseAuthentication(); - app.UseAuthorization(); - app.MapTenantEndpoints(); - app.MapIdentityEndpoints(); - - // Current user middleware - app.UseMiddleware(); - - // Register API versions - var versions = app.NewApiVersionSet() - .HasApiVersion(1) - .HasApiVersion(2) - .ReportApiVersions() - .Build(); - - // Map versioned endpoint - app.MapGroup("api/v{version:apiVersion}").WithApiVersionSet(versions); - - return app; - } -} diff --git a/src/api/framework/Infrastructure/FshInfrastructure.cs b/src/api/framework/Infrastructure/FshInfrastructure.cs deleted file mode 100644 index d6b702c584..0000000000 --- a/src/api/framework/Infrastructure/FshInfrastructure.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace FSH.Framework.Infrastructure; -public class FshInfrastructure -{ - public static string Name { get; set; } = "FshInfrastructure"; -} diff --git a/src/api/framework/Infrastructure/HealthChecks/HealthCheckEndpoint.cs b/src/api/framework/Infrastructure/HealthChecks/HealthCheckEndpoint.cs deleted file mode 100644 index 785aac77bb..0000000000 --- a/src/api/framework/Infrastructure/HealthChecks/HealthCheckEndpoint.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Text.Json; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; - -namespace FSH.Framework.Infrastructure.HealthChecks; -public static class HealthCheckEndpoint -{ - internal static RouteHandlerBuilder MapCustomHealthCheckEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapGet("/", (HttpContext context) => - { - var healthCheckService = context.RequestServices.GetRequiredService(); - var report = healthCheckService.CheckHealthAsync().Result; - - var response = new - { - status = report.Status.ToString(), - checks = report.Entries.Select(entry => new - { - name = entry.Key, - status = entry.Value.Status.ToString(), - description = entry.Value.Description - }), - - duration = report.TotalDuration - }; - - context.Response.ContentType = "application/json"; - return JsonSerializer.Serialize(response); - }) - .WithName("HealthCheck") - .WithSummary("Checks the health status of the application") - .WithDescription("Provides detailed health information about the application.") - .AllowAnonymous(); - } -} diff --git a/src/api/framework/Infrastructure/HealthChecks/HealthCheckMiddleware.cs b/src/api/framework/Infrastructure/HealthChecks/HealthCheckMiddleware.cs deleted file mode 100644 index 0311568702..0000000000 --- a/src/api/framework/Infrastructure/HealthChecks/HealthCheckMiddleware.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Text.Json; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Diagnostics.HealthChecks; - -namespace FSH.Framework.Infrastructure.HealthChecks; - -public class HealthCheckMiddleware -{ - private readonly HealthCheckService _healthCheckService; - - public HealthCheckMiddleware(HealthCheckService healthCheckService) - { - _healthCheckService = healthCheckService; - } - - public async Task InvokeAsync(HttpContext context) - { - var report = await _healthCheckService.CheckHealthAsync(); - - var response = new - { - status = report.Status.ToString(), - checks = report.Entries.Select(entry => new - { - name = entry.Key, - status = entry.Value.Status.ToString(), - description = entry.Value.Description - }), - duration = report.TotalDuration - }; - - context.Response.ContentType = "application/json"; - await context.Response.WriteAsync(JsonSerializer.Serialize(response)); - } -} - diff --git a/src/api/framework/Infrastructure/Identity/Audit/AuditPublishedEvent.cs b/src/api/framework/Infrastructure/Identity/Audit/AuditPublishedEvent.cs deleted file mode 100644 index 46587882d7..0000000000 --- a/src/api/framework/Infrastructure/Identity/Audit/AuditPublishedEvent.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Collections.ObjectModel; -using FSH.Framework.Core.Audit; -using MediatR; - -namespace FSH.Framework.Infrastructure.Identity.Audit; -public class AuditPublishedEvent : INotification -{ - public AuditPublishedEvent(Collection? trails) - { - Trails = trails; - } - public Collection? Trails { get; } -} diff --git a/src/api/framework/Infrastructure/Identity/Audit/AuditPublishedEventHandler.cs b/src/api/framework/Infrastructure/Identity/Audit/AuditPublishedEventHandler.cs deleted file mode 100644 index cb255f82af..0000000000 --- a/src/api/framework/Infrastructure/Identity/Audit/AuditPublishedEventHandler.cs +++ /dev/null @@ -1,24 +0,0 @@ -using FSH.Framework.Core.Audit; -using FSH.Framework.Infrastructure.Identity.Persistence; -using MediatR; -using Microsoft.Extensions.Logging; - -namespace FSH.Framework.Infrastructure.Identity.Audit; -public class AuditPublishedEventHandler(ILogger logger, IdentityDbContext context) : INotificationHandler -{ - public async Task Handle(AuditPublishedEvent notification, CancellationToken cancellationToken) - { - if (context == null) return; - logger.LogInformation("received audit trails"); - try - { - await context.Set().AddRangeAsync(notification.Trails!, default); - await context.SaveChangesAsync(default); - } - catch - { - logger.LogError("error while saving audit trail"); - } - return; - } -} diff --git a/src/api/framework/Infrastructure/Identity/Audit/AuditService.cs b/src/api/framework/Infrastructure/Identity/Audit/AuditService.cs deleted file mode 100644 index 823cb79576..0000000000 --- a/src/api/framework/Infrastructure/Identity/Audit/AuditService.cs +++ /dev/null @@ -1,17 +0,0 @@ -using FSH.Framework.Core.Audit; -using FSH.Framework.Infrastructure.Identity.Persistence; -using Microsoft.EntityFrameworkCore; - -namespace FSH.Framework.Infrastructure.Identity.Audit; -public class AuditService(IdentityDbContext context) : IAuditService -{ - public async Task> GetUserTrailsAsync(Guid userId) - { - var trails = await context.AuditTrails - .Where(a => a.UserId == userId) - .OrderByDescending(a => a.DateTime) - .Take(250) - .ToListAsync(); - return trails; - } -} diff --git a/src/api/framework/Infrastructure/Identity/Audit/Endpoints/GetUserAuditTrailEndpoint.cs b/src/api/framework/Infrastructure/Identity/Audit/Endpoints/GetUserAuditTrailEndpoint.cs deleted file mode 100644 index 78ca37942a..0000000000 --- a/src/api/framework/Infrastructure/Identity/Audit/Endpoints/GetUserAuditTrailEndpoint.cs +++ /dev/null @@ -1,22 +0,0 @@ -using FSH.Framework.Core.Audit; -using FSH.Framework.Infrastructure.Auth.Policy; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Audit.Endpoints; - -public static class GetUserAuditTrailEndpoint -{ - internal static RouteHandlerBuilder MapGetUserAuditTrailEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapGet("/{id:guid}/audit-trails", (Guid id, IAuditService service) => - { - return service.GetUserTrailsAsync(id); - }) - .WithName(nameof(GetUserAuditTrailEndpoint)) - .WithSummary("Get user's audit trail details") - .RequirePermission("Permissions.AuditTrails.View") - .WithDescription("Get user's audit trail details."); - } -} diff --git a/src/api/framework/Infrastructure/Identity/Extensions.cs b/src/api/framework/Infrastructure/Identity/Extensions.cs deleted file mode 100644 index 4d20559295..0000000000 --- a/src/api/framework/Infrastructure/Identity/Extensions.cs +++ /dev/null @@ -1,66 +0,0 @@ -using FSH.Framework.Core.Audit; -using FSH.Framework.Core.Identity.Roles; -using FSH.Framework.Core.Identity.Tokens; -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Core.Persistence; -using FSH.Framework.Infrastructure.Auth; -using FSH.Framework.Infrastructure.Identity.Audit; -using FSH.Framework.Infrastructure.Identity.Persistence; -using FSH.Framework.Infrastructure.Identity.Roles; -using FSH.Framework.Infrastructure.Identity.Roles.Endpoints; -using FSH.Framework.Infrastructure.Identity.Tokens; -using FSH.Framework.Infrastructure.Identity.Tokens.Endpoints; -using FSH.Framework.Infrastructure.Identity.Users; -using FSH.Framework.Infrastructure.Identity.Users.Endpoints; -using FSH.Framework.Infrastructure.Identity.Users.Services; -using FSH.Framework.Infrastructure.Persistence; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; -using IdentityConstants = FSH.Starter.Shared.Authorization.IdentityConstants; - -namespace FSH.Framework.Infrastructure.Identity; -internal static class Extensions -{ - internal static IServiceCollection ConfigureIdentity(this IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(sp => (ICurrentUserInitializer)sp.GetRequiredService()); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.BindDbContext(); - services.AddScoped(); - services.AddIdentity(options => - { - options.Password.RequiredLength = IdentityConstants.PasswordLength; - options.Password.RequireDigit = false; - options.Password.RequireLowercase = false; - options.Password.RequireNonAlphanumeric = false; - options.Password.RequireUppercase = false; - options.User.RequireUniqueEmail = true; - }) - .AddEntityFrameworkStores() - .AddDefaultTokenProviders(); - return services; - } - - public static IEndpointRouteBuilder MapIdentityEndpoints(this IEndpointRouteBuilder app) - { - var users = app.MapGroup("api/users").WithTags("users"); - users.MapUserEndpoints(); - - var tokens = app.MapGroup("api/token").WithTags("token"); - tokens.MapTokenEndpoints(); - - var roles = app.MapGroup("api/roles").WithTags("roles"); - roles.MapRoleEndpoints(); - - return app; - } -} diff --git a/src/api/framework/Infrastructure/Identity/Persistence/IdentityConfiguration.cs b/src/api/framework/Infrastructure/Identity/Persistence/IdentityConfiguration.cs deleted file mode 100644 index 240b63fffc..0000000000 --- a/src/api/framework/Infrastructure/Identity/Persistence/IdentityConfiguration.cs +++ /dev/null @@ -1,86 +0,0 @@ -using Finbuckle.MultiTenant; -using FSH.Framework.Core.Audit; -using FSH.Framework.Infrastructure.Identity.RoleClaims; -using FSH.Framework.Infrastructure.Identity.Roles; -using FSH.Framework.Infrastructure.Identity.Users; -using Microsoft.AspNetCore.Identity; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using IdentityConstants = FSH.Starter.Shared.Authorization.IdentityConstants; - -namespace FSH.Framework.Infrastructure.Identity.Persistence; - -public class AuditTrailConfig : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder - .ToTable("AuditTrails", IdentityConstants.SchemaName) - .IsMultiTenant(); - - builder.HasKey(a => a.Id); - } -} - -public class ApplicationUserConfig : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder - .ToTable("Users", IdentityConstants.SchemaName) - .IsMultiTenant(); - - builder - .Property(u => u.ObjectId) - .HasMaxLength(256); - } -} - -public class ApplicationRoleConfig : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) => - builder - .ToTable("Roles", IdentityConstants.SchemaName) - .IsMultiTenant() - .AdjustUniqueIndexes(); -} - -public class ApplicationRoleClaimConfig : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) => - builder - .ToTable("RoleClaims", IdentityConstants.SchemaName) - .IsMultiTenant(); -} - -public class IdentityUserRoleConfig : IEntityTypeConfiguration> -{ - public void Configure(EntityTypeBuilder> builder) => - builder - .ToTable("UserRoles", IdentityConstants.SchemaName) - .IsMultiTenant(); -} - -public class IdentityUserClaimConfig : IEntityTypeConfiguration> -{ - public void Configure(EntityTypeBuilder> builder) => - builder - .ToTable("UserClaims", IdentityConstants.SchemaName) - .IsMultiTenant(); -} - -public class IdentityUserLoginConfig : IEntityTypeConfiguration> -{ - public void Configure(EntityTypeBuilder> builder) => - builder - .ToTable("UserLogins", IdentityConstants.SchemaName) - .IsMultiTenant(); -} - -public class IdentityUserTokenConfig : IEntityTypeConfiguration> -{ - public void Configure(EntityTypeBuilder> builder) => - builder - .ToTable("UserTokens", IdentityConstants.SchemaName) - .IsMultiTenant(); -} diff --git a/src/api/framework/Infrastructure/Identity/Persistence/IdentityDbContext.cs b/src/api/framework/Infrastructure/Identity/Persistence/IdentityDbContext.cs deleted file mode 100644 index 5945567c37..0000000000 --- a/src/api/framework/Infrastructure/Identity/Persistence/IdentityDbContext.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Finbuckle.MultiTenant.Abstractions; -using Finbuckle.MultiTenant.EntityFrameworkCore; -using FSH.Framework.Core.Audit; -using FSH.Framework.Core.Persistence; -using FSH.Framework.Infrastructure.Identity.RoleClaims; -using FSH.Framework.Infrastructure.Identity.Roles; -using FSH.Framework.Infrastructure.Identity.Users; -using FSH.Framework.Infrastructure.Persistence; -using FSH.Framework.Infrastructure.Tenant; -using Microsoft.AspNetCore.Identity; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; - -namespace FSH.Framework.Infrastructure.Identity.Persistence; -public class IdentityDbContext : MultiTenantIdentityDbContext, - IdentityUserRole, - IdentityUserLogin, - FshRoleClaim, - IdentityUserToken> -{ - private readonly DatabaseOptions _settings; - private new FshTenantInfo TenantInfo { get; set; } - public IdentityDbContext(IMultiTenantContextAccessor multiTenantContextAccessor, DbContextOptions options, IOptions settings) : base(multiTenantContextAccessor, options) - { - _settings = settings.Value; - TenantInfo = multiTenantContextAccessor.MultiTenantContext.TenantInfo!; - } - - public DbSet AuditTrails { get; set; } - - protected override void OnModelCreating(ModelBuilder builder) - { - base.OnModelCreating(builder); - builder.ApplyConfigurationsFromAssembly(typeof(IdentityDbContext).Assembly); - } - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - if (!string.IsNullOrWhiteSpace(TenantInfo?.ConnectionString)) - { - optionsBuilder.ConfigureDatabase(_settings.Provider, TenantInfo.ConnectionString); - } - } -} diff --git a/src/api/framework/Infrastructure/Identity/Persistence/IdentityDbInitializer.cs b/src/api/framework/Infrastructure/Identity/Persistence/IdentityDbInitializer.cs deleted file mode 100644 index f4759350cf..0000000000 --- a/src/api/framework/Infrastructure/Identity/Persistence/IdentityDbInitializer.cs +++ /dev/null @@ -1,136 +0,0 @@ -using Finbuckle.MultiTenant.Abstractions; -using FSH.Framework.Core.Origin; -using FSH.Framework.Core.Persistence; -using FSH.Framework.Infrastructure.Identity.RoleClaims; -using FSH.Framework.Infrastructure.Identity.Roles; -using FSH.Framework.Infrastructure.Identity.Users; -using FSH.Framework.Infrastructure.Tenant; -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Identity; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using IdentityConstants = FSH.Starter.Shared.Authorization.IdentityConstants; - -namespace FSH.Framework.Infrastructure.Identity.Persistence; -internal sealed class IdentityDbInitializer( - ILogger logger, - IdentityDbContext context, - RoleManager roleManager, - UserManager userManager, - TimeProvider timeProvider, - IMultiTenantContextAccessor multiTenantContextAccessor, - IOptions originSettings) : IDbInitializer -{ - public async Task MigrateAsync(CancellationToken cancellationToken) - { - if ((await context.Database.GetPendingMigrationsAsync(cancellationToken).ConfigureAwait(false)).Any()) - { - await context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false); - logger.LogInformation("[{Tenant}] applied database migrations for identity module", context.TenantInfo?.Identifier); - } - } - - public async Task SeedAsync(CancellationToken cancellationToken) - { - await SeedRolesAsync(); - await SeedAdminUserAsync(); - } - - private async Task SeedRolesAsync() - { - foreach (string roleName in FshRoles.DefaultRoles) - { - if (await roleManager.Roles.SingleOrDefaultAsync(r => r.Name == roleName) - is not FshRole role) - { - // create role - role = new FshRole(roleName, $"{roleName} Role for {multiTenantContextAccessor.MultiTenantContext.TenantInfo?.Id} Tenant"); - await roleManager.CreateAsync(role); - } - - // Assign permissions - if (roleName == FshRoles.Basic) - { - await AssignPermissionsToRoleAsync(context, FshPermissions.Basic, role); - } - else if (roleName == FshRoles.Admin) - { - await AssignPermissionsToRoleAsync(context, FshPermissions.Admin, role); - - if (multiTenantContextAccessor.MultiTenantContext.TenantInfo?.Id == TenantConstants.Root.Id) - { - await AssignPermissionsToRoleAsync(context, FshPermissions.Root, role); - } - } - } - } - - private async Task AssignPermissionsToRoleAsync(IdentityDbContext dbContext, IReadOnlyList permissions, FshRole role) - { - var currentClaims = await roleManager.GetClaimsAsync(role); - var newClaims = permissions - .Where(permission => !currentClaims.Any(c => c.Type == FshClaims.Permission && c.Value == permission.Name)) - .Select(permission => new FshRoleClaim - { - RoleId = role.Id, - ClaimType = FshClaims.Permission, - ClaimValue = permission.Name, - CreatedBy = "application", - CreatedOn = timeProvider.GetUtcNow() - }) - .ToList(); - - foreach (var claim in newClaims) - { - logger.LogInformation("Seeding {Role} Permission '{Permission}' for '{TenantId}' Tenant.", role.Name, claim.ClaimValue, multiTenantContextAccessor.MultiTenantContext.TenantInfo?.Id); - await dbContext.RoleClaims.AddAsync(claim); - } - - // Save changes to the database context - if (newClaims.Count != 0) - { - await dbContext.SaveChangesAsync(); - } - - } - - private async Task SeedAdminUserAsync() - { - if (string.IsNullOrWhiteSpace(multiTenantContextAccessor.MultiTenantContext.TenantInfo?.Id) || string.IsNullOrWhiteSpace(multiTenantContextAccessor.MultiTenantContext.TenantInfo?.AdminEmail)) - { - return; - } - - if (await userManager.Users.FirstOrDefaultAsync(u => u.Email == multiTenantContextAccessor.MultiTenantContext.TenantInfo!.AdminEmail) - is not FshUser adminUser) - { - string adminUserName = $"{multiTenantContextAccessor.MultiTenantContext.TenantInfo?.Id.Trim()}.{FshRoles.Admin}".ToUpperInvariant(); - adminUser = new FshUser - { - FirstName = multiTenantContextAccessor.MultiTenantContext.TenantInfo?.Id.Trim().ToUpperInvariant(), - LastName = FshRoles.Admin, - Email = multiTenantContextAccessor.MultiTenantContext.TenantInfo?.AdminEmail, - UserName = adminUserName, - EmailConfirmed = true, - PhoneNumberConfirmed = true, - NormalizedEmail = multiTenantContextAccessor.MultiTenantContext.TenantInfo?.AdminEmail!.ToUpperInvariant(), - NormalizedUserName = adminUserName.ToUpperInvariant(), - ImageUrl = new Uri(originSettings.Value.OriginUrl! + TenantConstants.Root.DefaultProfilePicture), - IsActive = true - }; - - logger.LogInformation("Seeding Default Admin User for '{TenantId}' Tenant.", multiTenantContextAccessor.MultiTenantContext.TenantInfo?.Id); - var password = new PasswordHasher(); - adminUser.PasswordHash = password.HashPassword(adminUser, TenantConstants.DefaultPassword); - await userManager.CreateAsync(adminUser); - } - - // Assign role to user - if (!await userManager.IsInRoleAsync(adminUser, FshRoles.Admin)) - { - logger.LogInformation("Assigning Admin Role to Admin User for '{TenantId}' Tenant.", multiTenantContextAccessor.MultiTenantContext.TenantInfo?.Id); - await userManager.AddToRoleAsync(adminUser, FshRoles.Admin); - } - } -} diff --git a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/CreateOrUpdateRoleEndpoint.cs b/src/api/framework/Infrastructure/Identity/Roles/Endpoints/CreateOrUpdateRoleEndpoint.cs deleted file mode 100644 index 86234da7ec..0000000000 --- a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/CreateOrUpdateRoleEndpoint.cs +++ /dev/null @@ -1,23 +0,0 @@ -using FSH.Framework.Core.Identity.Roles; -using FSH.Framework.Core.Identity.Roles.Features.CreateOrUpdateRole; -using FSH.Framework.Infrastructure.Auth.Policy; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Roles.Endpoints; - -public static class CreateOrUpdateRoleEndpoint -{ - public static RouteHandlerBuilder MapCreateOrUpdateRoleEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapPost("/", async (CreateOrUpdateRoleCommand request, IRoleService roleService) => - { - return await roleService.CreateOrUpdateRoleAsync(request); - }) - .WithName(nameof(CreateOrUpdateRoleEndpoint)) - .WithSummary("Create or update a role") - .RequirePermission("Permissions.Roles.Create") - .WithDescription("Create a new role or update an existing role."); - } -} diff --git a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/DeleteRoleEndpoint.cs b/src/api/framework/Infrastructure/Identity/Roles/Endpoints/DeleteRoleEndpoint.cs deleted file mode 100644 index 106ea082bf..0000000000 --- a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/DeleteRoleEndpoint.cs +++ /dev/null @@ -1,23 +0,0 @@ -using FSH.Framework.Core.Identity.Roles; -using FSH.Framework.Infrastructure.Auth.Policy; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Roles.Endpoints; - -public static class DeleteRoleEndpoint -{ - public static RouteHandlerBuilder MapDeleteRoleEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapDelete("/{id:guid}", async (string id, IRoleService roleService) => - { - await roleService.DeleteRoleAsync(id); - }) - .WithName(nameof(DeleteRoleEndpoint)) - .WithSummary("Delete a role by ID") - .RequirePermission("Permissions.Roles.Delete") - .WithDescription("Remove a role from the system by its ID."); - } -} - diff --git a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/Extensions.cs b/src/api/framework/Infrastructure/Identity/Roles/Endpoints/Extensions.cs deleted file mode 100644 index b899bb362c..0000000000 --- a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/Extensions.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Roles.Endpoints; - -internal static class Extensions -{ - public static IEndpointRouteBuilder MapRoleEndpoints(this IEndpointRouteBuilder app) - { - app.MapGetRoleEndpoint(); - app.MapGetRolesEndpoint(); - app.MapDeleteRoleEndpoint(); - app.MapCreateOrUpdateRoleEndpoint(); - app.MapGetRolePermissionsEndpoint(); - app.MapUpdateRolePermissionsEndpoint(); - return app; - } -} - diff --git a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/GetRoleEndpoint.cs b/src/api/framework/Infrastructure/Identity/Roles/Endpoints/GetRoleEndpoint.cs deleted file mode 100644 index 6064a33866..0000000000 --- a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/GetRoleEndpoint.cs +++ /dev/null @@ -1,23 +0,0 @@ -using FSH.Framework.Core.Identity.Roles; -using FSH.Framework.Infrastructure.Auth.Policy; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Roles.Endpoints; - -public static class GetRoleByIdEndpoint -{ - public static RouteHandlerBuilder MapGetRoleEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapGet("/{id:guid}", async (string id, IRoleService roleService) => - { - return await roleService.GetRoleAsync(id); - }) - .WithName(nameof(GetRoleByIdEndpoint)) - .WithSummary("Get role details by ID") - .RequirePermission("Permissions.Roles.View") - .WithDescription("Retrieve the details of a role by its ID."); - } -} - diff --git a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/GetRolePermissionsEndpoint.cs b/src/api/framework/Infrastructure/Identity/Roles/Endpoints/GetRolePermissionsEndpoint.cs deleted file mode 100644 index 42eb894263..0000000000 --- a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/GetRolePermissionsEndpoint.cs +++ /dev/null @@ -1,21 +0,0 @@ -using FSH.Framework.Core.Identity.Roles; -using FSH.Framework.Infrastructure.Auth.Policy; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Roles.Endpoints; -public static class GetRolePermissionsEndpoint -{ - public static RouteHandlerBuilder MapGetRolePermissionsEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapGet("/{id:guid}/permissions", async (string id, IRoleService roleService, CancellationToken cancellationToken) => - { - return await roleService.GetWithPermissionsAsync(id, cancellationToken); - }) - .WithName(nameof(GetRolePermissionsEndpoint)) - .WithSummary("get role permissions") - .RequirePermission("Permissions.Roles.View") - .WithDescription("get role permissions"); - } -} diff --git a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/GetRolesEndpoint.cs b/src/api/framework/Infrastructure/Identity/Roles/Endpoints/GetRolesEndpoint.cs deleted file mode 100644 index df3b91cff8..0000000000 --- a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/GetRolesEndpoint.cs +++ /dev/null @@ -1,21 +0,0 @@ -using FSH.Framework.Core.Identity.Roles; -using FSH.Framework.Infrastructure.Auth.Policy; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Roles.Endpoints; -public static class GetRolesEndpoint -{ - public static RouteHandlerBuilder MapGetRolesEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapGet("/", async (IRoleService roleService) => - { - return await roleService.GetRolesAsync(); - }) - .WithName(nameof(GetRolesEndpoint)) - .WithSummary("Get a list of all roles") - .RequirePermission("Permissions.Roles.View") - .WithDescription("Retrieve a list of all roles available in the system."); - } -} diff --git a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/UpdateRolePermissionsEndpoint.cs b/src/api/framework/Infrastructure/Identity/Roles/Endpoints/UpdateRolePermissionsEndpoint.cs deleted file mode 100644 index 71cb44c611..0000000000 --- a/src/api/framework/Infrastructure/Identity/Roles/Endpoints/UpdateRolePermissionsEndpoint.cs +++ /dev/null @@ -1,30 +0,0 @@ -using FluentValidation; -using FSH.Framework.Core.Identity.Roles; -using FSH.Framework.Core.Identity.Roles.Features.UpdatePermissions; -using FSH.Framework.Infrastructure.Auth.Policy; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Roles.Endpoints; -public static class UpdateRolePermissionsEndpoint -{ - public static RouteHandlerBuilder MapUpdateRolePermissionsEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapPut("/{id}/permissions", async ( - UpdatePermissionsCommand request, - IRoleService roleService, - string id, - [FromServices] IValidator validator) => - { - if (id != request.RoleId) return Results.BadRequest(); - var response = await roleService.UpdatePermissionsAsync(request); - return Results.Ok(response); - }) - .WithName(nameof(UpdateRolePermissionsEndpoint)) - .WithSummary("update role permissions") - .RequirePermission("Permissions.Roles.Create") - .WithDescription("update role permissions"); - } -} diff --git a/src/api/framework/Infrastructure/Identity/Roles/RoleService.cs b/src/api/framework/Infrastructure/Identity/Roles/RoleService.cs deleted file mode 100644 index 6930ab0e16..0000000000 --- a/src/api/framework/Infrastructure/Identity/Roles/RoleService.cs +++ /dev/null @@ -1,126 +0,0 @@ -using Finbuckle.MultiTenant.Abstractions; -using FSH.Framework.Core.Exceptions; -using FSH.Framework.Core.Identity.Roles; -using FSH.Framework.Core.Identity.Roles.Features.CreateOrUpdateRole; -using FSH.Framework.Core.Identity.Roles.Features.UpdatePermissions; -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Infrastructure.Identity.Persistence; -using FSH.Framework.Infrastructure.Identity.RoleClaims; -using FSH.Framework.Infrastructure.Tenant; -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Identity; -using Microsoft.EntityFrameworkCore; - -namespace FSH.Framework.Infrastructure.Identity.Roles; - -public class RoleService(RoleManager roleManager, - IdentityDbContext context, - IMultiTenantContextAccessor multiTenantContextAccessor, - ICurrentUser currentUser) : IRoleService -{ - private readonly RoleManager _roleManager = roleManager; - - public async Task> GetRolesAsync() - { - return await Task.Run(() => _roleManager.Roles - .Select(role => new RoleDto { Id = role.Id, Name = role.Name!, Description = role.Description }) - .ToList()); - } - - public async Task GetRoleAsync(string id) - { - FshRole? role = await _roleManager.FindByIdAsync(id); - - _ = role ?? throw new NotFoundException("role not found"); - - return new RoleDto { Id = role.Id, Name = role.Name!, Description = role.Description }; - } - - public async Task CreateOrUpdateRoleAsync(CreateOrUpdateRoleCommand command) - { - FshRole? role = await _roleManager.FindByIdAsync(command.Id); - - if (role != null) - { - role.Name = command.Name; - role.Description = command.Description; - await _roleManager.UpdateAsync(role); - } - else - { - role = new FshRole(command.Name, command.Description); - await _roleManager.CreateAsync(role); - } - - return new RoleDto { Id = role.Id, Name = role.Name!, Description = role.Description }; - } - - public async Task DeleteRoleAsync(string id) - { - FshRole? role = await _roleManager.FindByIdAsync(id); - - _ = role ?? throw new NotFoundException("role not found"); - - await _roleManager.DeleteAsync(role); - } - - public async Task GetWithPermissionsAsync(string id, CancellationToken cancellationToken) - { - var role = await GetRoleAsync(id); - _ = role ?? throw new NotFoundException("role not found"); - - role.Permissions = await context.RoleClaims - .Where(c => c.RoleId == id && c.ClaimType == FshClaims.Permission) - .Select(c => c.ClaimValue!) - .ToListAsync(cancellationToken); - - return role; - } - - public async Task UpdatePermissionsAsync(UpdatePermissionsCommand request) - { - var role = await _roleManager.FindByIdAsync(request.RoleId); - _ = role ?? throw new NotFoundException("role not found"); - if (role.Name == FshRoles.Admin) - { - throw new FshException("operation not permitted"); - } - - if (multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id != TenantConstants.Root.Id) - { - // Remove Root Permissions if the Role is not created for Root Tenant. - request.Permissions.RemoveAll(u => u.StartsWith("Permissions.Root.", StringComparison.InvariantCultureIgnoreCase)); - } - - var currentClaims = await _roleManager.GetClaimsAsync(role); - - // Remove permissions that were previously selected - foreach (var claim in currentClaims.Where(c => !request.Permissions.Exists(p => p == c.Value))) - { - var result = await _roleManager.RemoveClaimAsync(role, claim); - if (!result.Succeeded) - { - var errors = result.Errors.Select(error => error.Description).ToList(); - throw new FshException("operation failed", errors); - } - } - - // Add all permissions that were not previously selected - foreach (string permission in request.Permissions.Where(c => !currentClaims.Any(p => p.Value == c))) - { - if (!string.IsNullOrEmpty(permission)) - { - context.RoleClaims.Add(new FshRoleClaim - { - RoleId = role.Id, - ClaimType = FshClaims.Permission, - ClaimValue = permission, - CreatedBy = currentUser.GetUserId().ToString() - }); - await context.SaveChangesAsync(); - } - } - - return "permissions updated"; - } -} diff --git a/src/api/framework/Infrastructure/Identity/Tokens/Endpoints/Extensions.cs b/src/api/framework/Infrastructure/Identity/Tokens/Endpoints/Extensions.cs deleted file mode 100644 index 3bd70a42c4..0000000000 --- a/src/api/framework/Infrastructure/Identity/Tokens/Endpoints/Extensions.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Tokens.Endpoints; -internal static class Extensions -{ - public static IEndpointRouteBuilder MapTokenEndpoints(this IEndpointRouteBuilder app) - { - app.MapRefreshTokenEndpoint(); - app.MapTokenGenerationEndpoint(); - return app; - } - - public static string GetIpAddress(this HttpContext context) - { - string ip = "N/A"; - if (context.Request.Headers.TryGetValue("X-Forwarded-For", out var ipList)) - { - ip = ipList.FirstOrDefault() ?? "N/A"; - } - else if (context.Connection.RemoteIpAddress != null) - { - ip = context.Connection.RemoteIpAddress.MapToIPv4().ToString(); - } - return ip; - - } -} diff --git a/src/api/framework/Infrastructure/Identity/Tokens/Endpoints/RefreshTokenEndpoint.cs b/src/api/framework/Infrastructure/Identity/Tokens/Endpoints/RefreshTokenEndpoint.cs deleted file mode 100644 index a8f27128ba..0000000000 --- a/src/api/framework/Infrastructure/Identity/Tokens/Endpoints/RefreshTokenEndpoint.cs +++ /dev/null @@ -1,28 +0,0 @@ -using FSH.Framework.Core.Identity.Tokens; -using FSH.Framework.Core.Identity.Tokens.Features.Refresh; -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Tokens.Endpoints; -public static class RefreshTokenEndpoint -{ - internal static RouteHandlerBuilder MapRefreshTokenEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapPost("/refresh", (RefreshTokenCommand request, - [FromHeader(Name = TenantConstants.Identifier)] string tenant, - ITokenService service, - HttpContext context, - CancellationToken cancellationToken) => - { - string ip = context.GetIpAddress(); - return service.RefreshTokenAsync(request, ip!, cancellationToken); - }) - .WithName(nameof(RefreshTokenEndpoint)) - .WithSummary("refresh JWTs") - .WithDescription("refresh JWTs") - .AllowAnonymous(); - } -} diff --git a/src/api/framework/Infrastructure/Identity/Tokens/Endpoints/TokenGenerationEndpoint.cs b/src/api/framework/Infrastructure/Identity/Tokens/Endpoints/TokenGenerationEndpoint.cs deleted file mode 100644 index e0bbc4796e..0000000000 --- a/src/api/framework/Infrastructure/Identity/Tokens/Endpoints/TokenGenerationEndpoint.cs +++ /dev/null @@ -1,28 +0,0 @@ -using FSH.Framework.Core.Identity.Tokens; -using FSH.Framework.Core.Identity.Tokens.Features.Generate; -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Tokens.Endpoints; -public static class TokenGenerationEndpoint -{ - internal static RouteHandlerBuilder MapTokenGenerationEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapPost("/", (TokenGenerationCommand request, - [FromHeader(Name = TenantConstants.Identifier)] string tenant, - ITokenService service, - HttpContext context, - CancellationToken cancellationToken) => - { - string ip = context.GetIpAddress(); - return service.GenerateTokenAsync(request, ip!, cancellationToken); - }) - .WithName(nameof(TokenGenerationEndpoint)) - .WithSummary("generate JWTs") - .WithDescription("generate JWTs") - .AllowAnonymous(); - } -} diff --git a/src/api/framework/Infrastructure/Identity/Tokens/TokenService.cs b/src/api/framework/Infrastructure/Identity/Tokens/TokenService.cs deleted file mode 100644 index 4f0bc60145..0000000000 --- a/src/api/framework/Infrastructure/Identity/Tokens/TokenService.cs +++ /dev/null @@ -1,188 +0,0 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Security.Cryptography; -using System.Text; -using Finbuckle.MultiTenant.Abstractions; -using FSH.Framework.Core.Auth.Jwt; -using FSH.Framework.Core.Exceptions; -using FSH.Framework.Core.Identity.Tokens; -using FSH.Framework.Core.Identity.Tokens.Features.Generate; -using FSH.Framework.Core.Identity.Tokens.Features.Refresh; -using FSH.Framework.Core.Identity.Tokens.Models; -using FSH.Framework.Infrastructure.Auth.Jwt; -using FSH.Framework.Infrastructure.Identity.Audit; -using FSH.Framework.Infrastructure.Identity.Users; -using FSH.Framework.Infrastructure.Tenant; -using FSH.Starter.Shared.Authorization; -using MediatR; -using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; - -namespace FSH.Framework.Infrastructure.Identity.Tokens; -public sealed class TokenService : ITokenService -{ - private readonly UserManager _userManager; - private readonly IMultiTenantContextAccessor? _multiTenantContextAccessor; - private readonly JwtOptions _jwtOptions; - private readonly IPublisher _publisher; - public TokenService(IOptions jwtOptions, UserManager userManager, IMultiTenantContextAccessor? multiTenantContextAccessor, IPublisher publisher) - { - _jwtOptions = jwtOptions.Value; - _userManager = userManager ?? throw new ArgumentNullException(nameof(userManager)); - _multiTenantContextAccessor = multiTenantContextAccessor; - _publisher = publisher; - } - - public async Task GenerateTokenAsync(TokenGenerationCommand request, string ipAddress, CancellationToken cancellationToken) - { - var currentTenant = _multiTenantContextAccessor!.MultiTenantContext.TenantInfo; - if (currentTenant == null) throw new UnauthorizedException(); - if (string.IsNullOrWhiteSpace(currentTenant.Id) - || await _userManager.FindByEmailAsync(request.Email.Trim().Normalize()) is not { } user - || !await _userManager.CheckPasswordAsync(user, request.Password)) - { - throw new UnauthorizedException(); - } - - if (!user.IsActive) - { - throw new UnauthorizedException("user is deactivated"); - } - - if (!user.EmailConfirmed) - { - throw new UnauthorizedException("email not confirmed"); - } - - if (currentTenant.Id != TenantConstants.Root.Id) - { - if (!currentTenant.IsActive) - { - throw new UnauthorizedException($"tenant {currentTenant.Id} is deactivated"); - } - - if (DateTime.UtcNow > currentTenant.ValidUpto) - { - throw new UnauthorizedException($"tenant {currentTenant.Id} validity has expired"); - } - } - - return await GenerateTokensAndUpdateUser(user, ipAddress); - } - - - public async Task RefreshTokenAsync(RefreshTokenCommand request, string ipAddress, CancellationToken cancellationToken) - { - var userPrincipal = GetPrincipalFromExpiredToken(request.Token); - var userId = _userManager.GetUserId(userPrincipal)!; - var user = await _userManager.FindByIdAsync(userId); - if (user is null) - { - throw new UnauthorizedException(); - } - - if (user.RefreshToken != request.RefreshToken || user.RefreshTokenExpiryTime <= DateTime.UtcNow) - { - throw new UnauthorizedException("Invalid Refresh Token"); - } - - return await GenerateTokensAndUpdateUser(user, ipAddress); - } - private async Task GenerateTokensAndUpdateUser(FshUser user, string ipAddress) - { - string token = GenerateJwt(user, ipAddress); - - user.RefreshToken = GenerateRefreshToken(); - user.RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(_jwtOptions.RefreshTokenExpirationInDays); - - await _userManager.UpdateAsync(user); - - await _publisher.Publish(new AuditPublishedEvent(new() - { - new() - { - Id = Guid.NewGuid(), - Operation = "Token Generated", - Entity = "Identity", - UserId = new Guid(user.Id), - DateTime = DateTime.UtcNow, - } - })); - - return new TokenResponse(token, user.RefreshToken, user.RefreshTokenExpiryTime); - } - - private string GenerateJwt(FshUser user, string ipAddress) => - GenerateEncryptedToken(GetSigningCredentials(), GetClaims(user, ipAddress)); - - private SigningCredentials GetSigningCredentials() - { - byte[] secret = Encoding.UTF8.GetBytes(_jwtOptions.Key); - return new SigningCredentials(new SymmetricSecurityKey(secret), SecurityAlgorithms.HmacSha256); - } - - private string GenerateEncryptedToken(SigningCredentials signingCredentials, IEnumerable claims) - { - var token = new JwtSecurityToken( - claims: claims, - expires: DateTime.UtcNow.AddMinutes(_jwtOptions.TokenExpirationInMinutes), - signingCredentials: signingCredentials, - issuer: JwtAuthConstants.Issuer, - audience: JwtAuthConstants.Audience - ); - var tokenHandler = new JwtSecurityTokenHandler(); - return tokenHandler.WriteToken(token); - } - - private List GetClaims(FshUser user, string ipAddress) => - new List - { - new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), - new(ClaimTypes.NameIdentifier, user.Id), - new(ClaimTypes.Email, user.Email!), - new(ClaimTypes.Name, user.FirstName ?? string.Empty), - new(ClaimTypes.MobilePhone, user.PhoneNumber ?? string.Empty), - new(FshClaims.Fullname, $"{user.FirstName} {user.LastName}"), - new(ClaimTypes.Surname, user.LastName ?? string.Empty), - new(FshClaims.IpAddress, ipAddress), - new(FshClaims.Tenant, _multiTenantContextAccessor!.MultiTenantContext.TenantInfo!.Id), - new(FshClaims.ImageUrl, user.ImageUrl == null ? string.Empty : user.ImageUrl.ToString()) - }; - private static string GenerateRefreshToken() - { - byte[] randomNumber = new byte[32]; - using var rng = RandomNumberGenerator.Create(); - rng.GetBytes(randomNumber); - return Convert.ToBase64String(randomNumber); - } - - private ClaimsPrincipal GetPrincipalFromExpiredToken(string token) - { -#pragma warning disable CA5404 // Do not disable token validation checks - var tokenValidationParameters = new TokenValidationParameters - { - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.Key)), - ValidateIssuer = true, - ValidateAudience = true, - ValidAudience = JwtAuthConstants.Audience, - ValidIssuer = JwtAuthConstants.Issuer, - RoleClaimType = ClaimTypes.Role, - ClockSkew = TimeSpan.Zero, - ValidateLifetime = false - }; -#pragma warning restore CA5404 // Do not disable token validation checks - var tokenHandler = new JwtSecurityTokenHandler(); - var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out var securityToken); - if (securityToken is not JwtSecurityToken jwtSecurityToken || - !jwtSecurityToken.Header.Alg.Equals( - SecurityAlgorithms.HmacSha256, - StringComparison.OrdinalIgnoreCase)) - { - throw new UnauthorizedException("invalid token"); - } - - return principal; - } -} diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/AssignRolesToUserEndpoint.cs b/src/api/framework/Infrastructure/Identity/Users/Endpoints/AssignRolesToUserEndpoint.cs deleted file mode 100644 index 161fe18e8f..0000000000 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/AssignRolesToUserEndpoint.cs +++ /dev/null @@ -1,28 +0,0 @@ -using FluentValidation; -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Core.Identity.Users.Features.AssignUserRole; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Users.Endpoints; -public static class AssignRolesToUserEndpoint -{ - internal static RouteHandlerBuilder MapAssignRolesToUserEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapPost("/{id:guid}/roles", async (AssignUserRoleCommand command, - HttpContext context, - string id, - IUserService userService, - CancellationToken cancellationToken) => - { - - var message = await userService.AssignRolesAsync(id, command, cancellationToken); - return Results.Ok(message); - }) - .WithName(nameof(AssignRolesToUserEndpoint)) - .WithSummary("assign roles") - .WithDescription("assign roles"); - } - -} diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/ChangePasswordEndpoint.cs b/src/api/framework/Infrastructure/Identity/Users/Endpoints/ChangePasswordEndpoint.cs deleted file mode 100644 index 7164ac5668..0000000000 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/ChangePasswordEndpoint.cs +++ /dev/null @@ -1,43 +0,0 @@ -using FluentValidation; -using FluentValidation.Results; -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Core.Identity.Users.Features.ChangePassword; -using FSH.Framework.Core.Origin; -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Options; - -namespace FSH.Framework.Infrastructure.Identity.Users.Endpoints; -public static class ChangePasswordEndpoint -{ - internal static RouteHandlerBuilder MapChangePasswordEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapPost("/change-password", async (ChangePasswordCommand command, - HttpContext context, - IOptions settings, - IValidator validator, - IUserService userService, - CancellationToken cancellationToken) => - { - ValidationResult result = await validator.ValidateAsync(command, cancellationToken); - if (!result.IsValid) - { - return Results.ValidationProblem(result.ToDictionary()); - } - - if (context.User.GetUserId() is not { } userId || string.IsNullOrEmpty(userId)) - { - return Results.BadRequest(); - } - - await userService.ChangePasswordAsync(command, userId); - return Results.Ok("password reset email sent"); - }) - .WithName(nameof(ChangePasswordEndpoint)) - .WithSummary("Changes password") - .WithDescription("Change password"); - } - -} diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/ConfirmEmailEndpoint.cs b/src/api/framework/Infrastructure/Identity/Users/Endpoints/ConfirmEmailEndpoint.cs deleted file mode 100644 index c0800e3321..0000000000 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/ConfirmEmailEndpoint.cs +++ /dev/null @@ -1,20 +0,0 @@ -using FSH.Framework.Core.Identity.Users.Abstractions; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Users.Endpoints; -public static class ConfirmEmailEndpoint -{ - internal static RouteHandlerBuilder MapConfirmEmailEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapGet("/confirm-email", (string userId, string code, string tenant, IUserService service) => - { - return service.ConfirmEmailAsync(userId, code, tenant, default); - }) - .WithName(nameof(ConfirmEmailEndpoint)) - .WithSummary("confirm user email") - .WithDescription("confirm user email") - .AllowAnonymous(); - } -} diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/DeleteUserEndpoint.cs b/src/api/framework/Infrastructure/Identity/Users/Endpoints/DeleteUserEndpoint.cs deleted file mode 100644 index 6969b4e124..0000000000 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/DeleteUserEndpoint.cs +++ /dev/null @@ -1,21 +0,0 @@ -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Infrastructure.Auth.Policy; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Users.Endpoints; -public static class DeleteUserEndpoint -{ - internal static RouteHandlerBuilder MapDeleteUserEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapDelete("/{id:guid}", (string id, IUserService service) => - { - return service.DeleteAsync(id); - }) - .WithName(nameof(DeleteUserEndpoint)) - .WithSummary("delete user profile") - .RequirePermission("Permissions.Users.Delete") - .WithDescription("delete user profile"); - } -} diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/Extensions.cs b/src/api/framework/Infrastructure/Identity/Users/Endpoints/Extensions.cs deleted file mode 100644 index cbc311c6be..0000000000 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/Extensions.cs +++ /dev/null @@ -1,27 +0,0 @@ -using FSH.Framework.Infrastructure.Identity.Audit.Endpoints; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Users.Endpoints; -internal static class Extensions -{ - public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder app) - { - app.MapRegisterUserEndpoint(); - app.MapSelfRegisterUserEndpoint(); - app.MapUpdateUserEndpoint(); - app.MapGetUsersListEndpoint(); - app.MapDeleteUserEndpoint(); - app.MapForgotPasswordEndpoint(); - app.MapChangePasswordEndpoint(); - app.MapResetPasswordEndpoint(); - app.MapGetMeEndpoint(); - app.MapGetUserEndpoint(); - app.MapGetCurrentUserPermissionsEndpoint(); - app.ToggleUserStatusEndpointEndpoint(); - app.MapAssignRolesToUserEndpoint(); - app.MapGetUserRolesEndpoint(); - app.MapGetUserAuditTrailEndpoint(); - app.MapConfirmEmailEndpoint(); - return app; - } -} diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/ForgotPasswordEndpoint.cs b/src/api/framework/Infrastructure/Identity/Users/Endpoints/ForgotPasswordEndpoint.cs deleted file mode 100644 index 9483571e7a..0000000000 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/ForgotPasswordEndpoint.cs +++ /dev/null @@ -1,45 +0,0 @@ -using FluentValidation; -using FluentValidation.Results; -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Core.Identity.Users.Features.ForgotPassword; -using FSH.Framework.Core.Origin; -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Options; - -namespace FSH.Framework.Infrastructure.Identity.Users.Endpoints; - -public static class ForgotPasswordEndpoint -{ - internal static RouteHandlerBuilder MapForgotPasswordEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapPost("/forgot-password", async (HttpRequest request, [FromHeader(Name = TenantConstants.Identifier)] string tenant, ForgotPasswordCommand command, IOptions settings, IValidator validator, IUserService userService, CancellationToken cancellationToken) => - { - ValidationResult result = await validator.ValidateAsync(command, cancellationToken); - if (!result.IsValid) - { - return Results.ValidationProblem(result.ToDictionary()); - } - - // Obtain origin from appsettings - var origin = settings.Value; - - if (origin?.OriginUrl == null) - { - // Handle the case where OriginUrl is null - return Results.BadRequest("Origin URL is not configured."); - } - - await userService.ForgotPasswordAsync(command, origin.OriginUrl.ToString(), cancellationToken); - return Results.Ok("Password reset email sent."); - }) - .WithName(nameof(ForgotPasswordEndpoint)) - .WithSummary("Forgot password") - .WithDescription("Generates a password reset token and sends it via email.") - .AllowAnonymous(); - } - -} diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUserEndpoint.cs b/src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUserEndpoint.cs deleted file mode 100644 index c23a8f5f40..0000000000 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUserEndpoint.cs +++ /dev/null @@ -1,21 +0,0 @@ -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Infrastructure.Auth.Policy; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Users.Endpoints; -public static class GetUserEndpoint -{ - internal static RouteHandlerBuilder MapGetUserEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapGet("/{id:guid}", (string id, IUserService service) => - { - return service.GetAsync(id, CancellationToken.None); - }) - .WithName(nameof(GetUserEndpoint)) - .WithSummary("Get user profile by ID") - .RequirePermission("Permissions.Users.View") - .WithDescription("Get another user's profile details by user ID."); - } -} diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUserPermissionsEndpoint.cs b/src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUserPermissionsEndpoint.cs deleted file mode 100644 index 6ee0f74eee..0000000000 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUserPermissionsEndpoint.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Security.Claims; -using FSH.Framework.Core.Exceptions; -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Users.Endpoints; -public static class GetUserPermissionsEndpoint -{ - internal static RouteHandlerBuilder MapGetCurrentUserPermissionsEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapGet("/permissions", async (ClaimsPrincipal user, IUserService service, CancellationToken cancellationToken) => - { - if (user.GetUserId() is not { } userId || string.IsNullOrEmpty(userId)) - { - throw new UnauthorizedException(); - } - - return await service.GetPermissionsAsync(userId, cancellationToken); - }) - .WithName("GetUserPermissions") - .WithSummary("Get current user permissions") - .WithDescription("Get current user permissions"); - } -} diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUserProfileEndpoint.cs b/src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUserProfileEndpoint.cs deleted file mode 100644 index 9f3ea36ab4..0000000000 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUserProfileEndpoint.cs +++ /dev/null @@ -1,27 +0,0 @@ -using FSH.Framework.Core.Exceptions; -using System.Security.Claims; -using FSH.Framework.Core.Identity.Users.Abstractions; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using FSH.Starter.Shared.Authorization; - -namespace FSH.Framework.Infrastructure.Identity.Users.Endpoints; -public static class GetUserProfileEndpoint -{ - internal static RouteHandlerBuilder MapGetMeEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapGet("/profile", async (ClaimsPrincipal user, IUserService service, CancellationToken cancellationToken) => - { - if (user.GetUserId() is not { } userId || string.IsNullOrEmpty(userId)) - { - throw new UnauthorizedException(); - } - - return await service.GetAsync(userId, cancellationToken); - }) - .WithName("GetMeEndpoint") - .WithSummary("Get current user information based on token") - .WithDescription("Get current user information based on token"); - } -} diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUserRolesEndpoint.cs b/src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUserRolesEndpoint.cs deleted file mode 100644 index 757f842926..0000000000 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUserRolesEndpoint.cs +++ /dev/null @@ -1,21 +0,0 @@ -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Infrastructure.Auth.Policy; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Users.Endpoints; -public static class GetUserRolesEndpoint -{ - internal static RouteHandlerBuilder MapGetUserRolesEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapGet("/{id:guid}/roles", (string id, IUserService service) => - { - return service.GetUserRolesAsync(id, CancellationToken.None); - }) - .WithName(nameof(GetUserRolesEndpoint)) - .WithSummary("get user roles") - .RequirePermission("Permissions.Users.View") - .WithDescription("get user roles"); - } -} diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUsersListEndpoint.cs b/src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUsersListEndpoint.cs deleted file mode 100644 index 0743634ca8..0000000000 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/GetUsersListEndpoint.cs +++ /dev/null @@ -1,21 +0,0 @@ -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Infrastructure.Auth.Policy; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Users.Endpoints; -public static class GetUsersListEndpoint -{ - internal static RouteHandlerBuilder MapGetUsersListEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapGet("/", (CancellationToken cancellationToken, IUserService service) => - { - return service.GetListAsync(cancellationToken); - }) - .WithName(nameof(GetUsersListEndpoint)) - .WithSummary("get users list") - .RequirePermission("Permissions.Users.View") - .WithDescription("get users list"); - } -} diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/RegisterUserEndpoint.cs b/src/api/framework/Infrastructure/Identity/Users/Endpoints/RegisterUserEndpoint.cs deleted file mode 100644 index 84b98a911f..0000000000 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/RegisterUserEndpoint.cs +++ /dev/null @@ -1,26 +0,0 @@ -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Core.Identity.Users.Features.RegisterUser; -using FSH.Framework.Infrastructure.Auth.Policy; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Users.Endpoints; -public static class RegisterUserEndpoint -{ - internal static RouteHandlerBuilder MapRegisterUserEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapPost("/register", (RegisterUserCommand request, - IUserService service, - HttpContext context, - CancellationToken cancellationToken) => - { - var origin = $"{context.Request.Scheme}://{context.Request.Host.Value}{context.Request.PathBase.Value}"; - return service.RegisterAsync(request, origin, cancellationToken); - }) - .WithName(nameof(RegisterUserEndpoint)) - .WithSummary("register user") - .RequirePermission("Permissions.Users.Create") - .WithDescription("register user"); - } -} diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/ResetPasswordEndpoint.cs b/src/api/framework/Infrastructure/Identity/Users/Endpoints/ResetPasswordEndpoint.cs deleted file mode 100644 index a1f7187208..0000000000 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/ResetPasswordEndpoint.cs +++ /dev/null @@ -1,34 +0,0 @@ -using FluentValidation; -using FluentValidation.Results; -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Core.Identity.Users.Features.ResetPassword; -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Users.Endpoints; - -public static class ResetPasswordEndpoint -{ - internal static RouteHandlerBuilder MapResetPasswordEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapPost("/reset-password", async (ResetPasswordCommand command, [FromHeader(Name = TenantConstants.Identifier)] string tenant, IValidator validator, IUserService userService, CancellationToken cancellationToken) => - { - ValidationResult result = await validator.ValidateAsync(command, cancellationToken); - if (!result.IsValid) - { - return Results.ValidationProblem(result.ToDictionary()); - } - - await userService.ResetPasswordAsync(command, cancellationToken); - return Results.Ok("Password has been reset."); - }) - .WithName(nameof(ResetPasswordEndpoint)) - .WithSummary("Reset password") - .WithDescription("Resets the password using the token and new password provided.") - .AllowAnonymous(); - } - -} diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/SelfRegisterUserEndpoint.cs b/src/api/framework/Infrastructure/Identity/Users/Endpoints/SelfRegisterUserEndpoint.cs deleted file mode 100644 index 8af1fc52f0..0000000000 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/SelfRegisterUserEndpoint.cs +++ /dev/null @@ -1,30 +0,0 @@ -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Core.Identity.Users.Features.RegisterUser; -using FSH.Framework.Infrastructure.Auth.Policy; -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Users.Endpoints; -public static class SelfRegisterUserEndpoint -{ - internal static RouteHandlerBuilder MapSelfRegisterUserEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapPost("/self-register", (RegisterUserCommand request, - [FromHeader(Name = TenantConstants.Identifier)] string tenant, - IUserService service, - HttpContext context, - CancellationToken cancellationToken) => - { - var origin = $"{context.Request.Scheme}://{context.Request.Host.Value}{context.Request.PathBase.Value}"; - return service.RegisterAsync(request, origin, cancellationToken); - }) - .WithName(nameof(SelfRegisterUserEndpoint)) - .WithSummary("self register user") - .RequirePermission("Permissions.Users.Create") - .WithDescription("self register user") - .AllowAnonymous(); - } -} diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/ToggleUserStatusEndpoint.cs b/src/api/framework/Infrastructure/Identity/Users/Endpoints/ToggleUserStatusEndpoint.cs deleted file mode 100644 index 7e705e3294..0000000000 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/ToggleUserStatusEndpoint.cs +++ /dev/null @@ -1,35 +0,0 @@ -using FluentValidation; -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Core.Identity.Users.Features.ToggleUserStatus; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Users.Endpoints; - -public static class ToggleUserStatusEndpoint -{ - internal static RouteHandlerBuilder ToggleUserStatusEndpointEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapPost("/{id:guid}/toggle-status", async ( - string id, - ToggleUserStatusCommand command, - [FromServices] IUserService userService, - CancellationToken cancellationToken) => - { - if (id != command.UserId) - { - return Results.BadRequest(); - } - - await userService.ToggleStatusAsync(command, cancellationToken); - return Results.Ok(); - }) - .WithName(nameof(ToggleUserStatusEndpoint)) - .WithSummary("Toggle a user's active status") - .WithDescription("Toggle a user's active status") - .AllowAnonymous(); - } - -} diff --git a/src/api/framework/Infrastructure/Identity/Users/Endpoints/UpdateUserEndpoint.cs b/src/api/framework/Infrastructure/Identity/Users/Endpoints/UpdateUserEndpoint.cs deleted file mode 100644 index 6d137e8d99..0000000000 --- a/src/api/framework/Infrastructure/Identity/Users/Endpoints/UpdateUserEndpoint.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Security.Claims; -using FSH.Framework.Core.Exceptions; -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Core.Identity.Users.Features.UpdateUser; -using FSH.Framework.Infrastructure.Auth.Policy; -using FSH.Starter.Shared.Authorization; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Identity.Users.Endpoints; -public static class UpdateUserEndpoint -{ - internal static RouteHandlerBuilder MapUpdateUserEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapPut("/profile", (UpdateUserCommand request, ISender mediator, ClaimsPrincipal user, IUserService service) => - { - if (user.GetUserId() is not { } userId || string.IsNullOrEmpty(userId)) - { - throw new UnauthorizedException(); - } - return service.UpdateAsync(request, userId); - }) - .WithName(nameof(UpdateUserEndpoint)) - .WithSummary("update user profile") - .RequirePermission("Permissions.Users.Update") - .WithDescription("update user profile"); - } -} diff --git a/src/api/framework/Infrastructure/Identity/Users/FshUser.cs b/src/api/framework/Infrastructure/Identity/Users/FshUser.cs deleted file mode 100644 index 4d68e207f3..0000000000 --- a/src/api/framework/Infrastructure/Identity/Users/FshUser.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.AspNetCore.Identity; - -namespace FSH.Framework.Infrastructure.Identity.Users; -public class FshUser : IdentityUser -{ - public string? FirstName { get; set; } - public string? LastName { get; set; } - public Uri? ImageUrl { get; set; } - public bool IsActive { get; set; } - public string? RefreshToken { get; set; } - public DateTime RefreshTokenExpiryTime { get; set; } - - public string? ObjectId { get; set; } -} diff --git a/src/api/framework/Infrastructure/Identity/Users/Services/CurrentUserService.cs b/src/api/framework/Infrastructure/Identity/Users/Services/CurrentUserService.cs deleted file mode 100644 index 2fcfea6fb5..0000000000 --- a/src/api/framework/Infrastructure/Identity/Users/Services/CurrentUserService.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System.Security.Claims; -using FSH.Framework.Core.Exceptions; -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Starter.Shared.Authorization; - -namespace FSH.Framework.Infrastructure.Identity.Users.Services; -public class CurrentUser : ICurrentUser, ICurrentUserInitializer -{ - private ClaimsPrincipal? _user; - - public string? Name => _user?.Identity?.Name; - - private Guid _userId = Guid.Empty; - - public Guid GetUserId() - { - return IsAuthenticated() - ? Guid.Parse(_user?.GetUserId() ?? Guid.Empty.ToString()) - : _userId; - } - - public string? GetUserEmail() => - IsAuthenticated() - ? _user!.GetEmail() - : string.Empty; - - public bool IsAuthenticated() => - _user?.Identity?.IsAuthenticated is true; - - public bool IsInRole(string role) => - _user?.IsInRole(role) is true; - - public IEnumerable? GetUserClaims() => - _user?.Claims; - - public string? GetTenant() => - IsAuthenticated() ? _user?.GetTenant() : string.Empty; - - public void SetCurrentUser(ClaimsPrincipal user) - { - if (_user != null) - { - throw new FshException("Method reserved for in-scope initialization"); - } - - _user = user; - } - - public void SetCurrentUserId(string userId) - { - if (_userId != Guid.Empty) - { - throw new FshException("Method reserved for in-scope initialization"); - } - - if (!string.IsNullOrEmpty(userId)) - { - _userId = Guid.Parse(userId); - } - } -} diff --git a/src/api/framework/Infrastructure/Identity/Users/Services/UserService.Password.cs b/src/api/framework/Infrastructure/Identity/Users/Services/UserService.Password.cs deleted file mode 100644 index 97b7af7979..0000000000 --- a/src/api/framework/Infrastructure/Identity/Users/Services/UserService.Password.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System.Collections.ObjectModel; -using System.Text; -using FSH.Framework.Core.Exceptions; -using FSH.Framework.Core.Identity.Users.Features.ChangePassword; -using FSH.Framework.Core.Identity.Users.Features.ForgotPassword; -using FSH.Framework.Core.Identity.Users.Features.ResetPassword; -using FSH.Framework.Core.Mail; -using Microsoft.AspNetCore.WebUtilities; - -namespace FSH.Framework.Infrastructure.Identity.Users.Services; -internal sealed partial class UserService -{ - public async Task ForgotPasswordAsync(ForgotPasswordCommand request, string origin, CancellationToken cancellationToken) - { - EnsureValidTenant(); - - var user = await userManager.FindByEmailAsync(request.Email); - if (user == null) - { - throw new NotFoundException("user not found"); - } - - if (string.IsNullOrWhiteSpace(user.Email)) - { - throw new InvalidOperationException("user email cannot be null or empty"); - } - - var token = await userManager.GeneratePasswordResetTokenAsync(user); - token = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token)); - - var resetPasswordUri = $"{origin}/reset-password?token={token}&email={request.Email}"; - var mailRequest = new MailRequest( - new Collection { user.Email }, - "Reset Password", - $"Please reset your password using the following link: {resetPasswordUri}"); - - jobService.Enqueue(() => mailService.SendAsync(mailRequest, CancellationToken.None)); - } - - public async Task ResetPasswordAsync(ResetPasswordCommand request, CancellationToken cancellationToken) - { - EnsureValidTenant(); - - var user = await userManager.FindByEmailAsync(request.Email); - if (user == null) - { - throw new NotFoundException("user not found"); - } - - request.Token = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(request.Token)); - var result = await userManager.ResetPasswordAsync(user, request.Token, request.Password); - - if (!result.Succeeded) - { - var errors = result.Errors.Select(e => e.Description).ToList(); - throw new FshException("error resetting password", errors); - } - } - - public async Task ChangePasswordAsync(ChangePasswordCommand request, string userId) - { - var user = await userManager.FindByIdAsync(userId); - - _ = user ?? throw new NotFoundException("user not found"); - - var result = await userManager.ChangePasswordAsync(user, request.Password, request.NewPassword); - - if (!result.Succeeded) - { - var errors = result.Errors.Select(e => e.Description).ToList(); - throw new FshException("failed to change password", errors); - } - } -} diff --git a/src/api/framework/Infrastructure/Identity/Users/Services/UserService.cs b/src/api/framework/Infrastructure/Identity/Users/Services/UserService.cs deleted file mode 100644 index a714a154d2..0000000000 --- a/src/api/framework/Infrastructure/Identity/Users/Services/UserService.cs +++ /dev/null @@ -1,313 +0,0 @@ -using System.Collections.ObjectModel; -using System.Security.Claims; -using System.Text; -using Finbuckle.MultiTenant.Abstractions; -using FSH.Framework.Core.Caching; -using FSH.Framework.Core.Exceptions; -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Core.Identity.Users.Dtos; -using FSH.Framework.Core.Identity.Users.Features.AssignUserRole; -using FSH.Framework.Core.Identity.Users.Features.RegisterUser; -using FSH.Framework.Core.Identity.Users.Features.ToggleUserStatus; -using FSH.Framework.Core.Identity.Users.Features.UpdateUser; -using FSH.Framework.Core.Jobs; -using FSH.Framework.Core.Mail; -using FSH.Framework.Core.Storage; -using FSH.Framework.Core.Storage.File; -using FSH.Framework.Infrastructure.Constants; -using FSH.Framework.Infrastructure.Identity.Persistence; -using FSH.Framework.Infrastructure.Identity.Roles; -using FSH.Framework.Infrastructure.Tenant; -using FSH.Starter.Shared.Authorization; -using Mapster; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.WebUtilities; -using Microsoft.EntityFrameworkCore; - -namespace FSH.Framework.Infrastructure.Identity.Users.Services; - -internal sealed partial class UserService( - UserManager userManager, - SignInManager signInManager, - RoleManager roleManager, - IdentityDbContext db, - ICacheService cache, - IJobService jobService, - IMailService mailService, - IMultiTenantContextAccessor multiTenantContextAccessor, - IStorageService storageService - ) : IUserService -{ - private void EnsureValidTenant() - { - if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) - { - throw new UnauthorizedException("invalid tenant"); - } - } - - public async Task ConfirmEmailAsync(string userId, string code, string tenant, CancellationToken cancellationToken) - { - EnsureValidTenant(); - - var user = await userManager.Users - .Where(u => u.Id == userId && !u.EmailConfirmed) - .FirstOrDefaultAsync(cancellationToken); - - _ = user ?? throw new FshException("An error occurred while confirming E-Mail."); - - code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code)); - var result = await userManager.ConfirmEmailAsync(user, code); - - return result.Succeeded - ? string.Format("Account Confirmed for E-Mail {0}. You can now use the /api/tokens endpoint to generate JWT.", user.Email) - : throw new FshException(string.Format("An error occurred while confirming {0}", user.Email)); - } - - public Task ConfirmPhoneNumberAsync(string userId, string code) - { - throw new NotImplementedException(); - } - - public async Task ExistsWithEmailAsync(string email, string? exceptId = null) - { - EnsureValidTenant(); - return await userManager.FindByEmailAsync(email.Normalize()) is FshUser user && user.Id != exceptId; - } - - public async Task ExistsWithNameAsync(string name) - { - EnsureValidTenant(); - return await userManager.FindByNameAsync(name) is not null; - } - - public async Task ExistsWithPhoneNumberAsync(string phoneNumber, string? exceptId = null) - { - EnsureValidTenant(); - return await userManager.Users.FirstOrDefaultAsync(x => x.PhoneNumber == phoneNumber) is FshUser user && user.Id != exceptId; - } - - public async Task GetAsync(string userId, CancellationToken cancellationToken) - { - var user = await userManager.Users - .AsNoTracking() - .Where(u => u.Id == userId) - .FirstOrDefaultAsync(cancellationToken); - - _ = user ?? throw new NotFoundException("user not found"); - - return user.Adapt(); - } - - public Task GetCountAsync(CancellationToken cancellationToken) => - userManager.Users.AsNoTracking().CountAsync(cancellationToken); - - public async Task> GetListAsync(CancellationToken cancellationToken) - { - var users = await userManager.Users.AsNoTracking().ToListAsync(cancellationToken); - return users.Adapt>(); - } - - public Task GetOrCreateFromPrincipalAsync(ClaimsPrincipal principal) - { - throw new NotImplementedException(); - } - - public async Task RegisterAsync(RegisterUserCommand request, string origin, CancellationToken cancellationToken) - { - // create user entity - var user = new FshUser - { - Email = request.Email, - FirstName = request.FirstName, - LastName = request.LastName, - UserName = request.UserName, - PhoneNumber = request.PhoneNumber, - IsActive = true, - EmailConfirmed = false, - PhoneNumberConfirmed = false, - }; - - // register user - var result = await userManager.CreateAsync(user, request.Password); - if (!result.Succeeded) - { - var errors = result.Errors.Select(error => error.Description).ToList(); - throw new FshException("error while registering a new user", errors); - } - - // add basic role - await userManager.AddToRoleAsync(user, FshRoles.Basic); - - // send confirmation mail - if (!string.IsNullOrEmpty(user.Email)) - { - string emailVerificationUri = await GetEmailVerificationUriAsync(user, origin); - var mailRequest = new MailRequest( - new Collection { user.Email }, - "Confirm Registration", - emailVerificationUri); - jobService.Enqueue("email", () => mailService.SendAsync(mailRequest, CancellationToken.None)); - } - - return new RegisterUserResponse(user.Id); - } - - public async Task ToggleStatusAsync(ToggleUserStatusCommand request, CancellationToken cancellationToken) - { - var user = await userManager.Users.Where(u => u.Id == request.UserId).FirstOrDefaultAsync(cancellationToken); - - _ = user ?? throw new NotFoundException("User Not Found."); - - bool isAdmin = await userManager.IsInRoleAsync(user, FshRoles.Admin); - if (isAdmin) - { - throw new FshException("Administrators Profile's Status cannot be toggled"); - } - - user.IsActive = request.ActivateUser; - - await userManager.UpdateAsync(user); - } - - public async Task UpdateAsync(UpdateUserCommand request, string userId) - { - var user = await userManager.FindByIdAsync(userId); - - _ = user ?? throw new NotFoundException("user not found"); - - Uri imageUri = user.ImageUrl ?? null!; - if (request.Image != null || request.DeleteCurrentImage) - { - user.ImageUrl = await storageService.UploadAsync(request.Image, FileType.Image); - if (request.DeleteCurrentImage && imageUri != null) - { - storageService.Remove(imageUri); - } - } - - user.FirstName = request.FirstName; - user.LastName = request.LastName; - user.PhoneNumber = request.PhoneNumber; - string? phoneNumber = await userManager.GetPhoneNumberAsync(user); - if (request.PhoneNumber != phoneNumber) - { - await userManager.SetPhoneNumberAsync(user, request.PhoneNumber); - } - - var result = await userManager.UpdateAsync(user); - await signInManager.RefreshSignInAsync(user); - - if (!result.Succeeded) - { - throw new FshException("Update profile failed"); - } - } - - public async Task DeleteAsync(string userId) - { - FshUser? user = await userManager.FindByIdAsync(userId); - - _ = user ?? throw new NotFoundException("User Not Found."); - - user.IsActive = false; - IdentityResult? result = await userManager.UpdateAsync(user); - - if (!result.Succeeded) - { - List errors = result.Errors.Select(error => error.Description).ToList(); - throw new FshException("Delete profile failed", errors); - } - } - - private async Task GetEmailVerificationUriAsync(FshUser user, string origin) - { - EnsureValidTenant(); - - string code = await userManager.GenerateEmailConfirmationTokenAsync(user); - code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); - const string route = "api/users/confirm-email/"; - var endpointUri = new Uri(string.Concat($"{origin}/", route)); - string verificationUri = QueryHelpers.AddQueryString(endpointUri.ToString(), QueryStringKeys.UserId, user.Id); - verificationUri = QueryHelpers.AddQueryString(verificationUri, QueryStringKeys.Code, code); - verificationUri = QueryHelpers.AddQueryString(verificationUri, - TenantConstants.Identifier, - multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id!); - return verificationUri; - } - - public async Task AssignRolesAsync(string userId, AssignUserRoleCommand request, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request); - - var user = await userManager.Users.Where(u => u.Id == userId).FirstOrDefaultAsync(cancellationToken); - - _ = user ?? throw new NotFoundException("user not found"); - - // Check if the user is an admin for which the admin role is getting disabled - if (await userManager.IsInRoleAsync(user, FshRoles.Admin) - && request.UserRoles.Exists(a => !a.Enabled && a.RoleName == FshRoles.Admin)) - { - // Get count of users in Admin Role - int adminCount = (await userManager.GetUsersInRoleAsync(FshRoles.Admin)).Count; - - // Check if user is not Root Tenant Admin - // Edge Case : there are chances for other tenants to have users with the same email as that of Root Tenant Admin. Probably can add a check while User Registration - if (user.Email == TenantConstants.Root.EmailAddress) - { - if (multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id == TenantConstants.Root.Id) - { - throw new FshException("action not permitted"); - } - } - else if (adminCount <= 2) - { - throw new FshException("tenant should have at least 2 admins."); - } - } - - foreach (var userRole in request.UserRoles) - { - // Check if Role Exists - if (await roleManager.FindByNameAsync(userRole.RoleName!) is not null) - { - if (userRole.Enabled) - { - if (!await userManager.IsInRoleAsync(user, userRole.RoleName!)) - { - await userManager.AddToRoleAsync(user, userRole.RoleName!); - } - } - else - { - await userManager.RemoveFromRoleAsync(user, userRole.RoleName!); - } - } - } - - return "User Roles Updated Successfully."; - - } - - public async Task> GetUserRolesAsync(string userId, CancellationToken cancellationToken) - { - var userRoles = new List(); - - var user = await userManager.FindByIdAsync(userId); - if (user is null) throw new NotFoundException("user not found"); - var roles = await roleManager.Roles.AsNoTracking().ToListAsync(cancellationToken); - if (roles is null) throw new NotFoundException("roles not found"); - foreach (var role in roles) - { - userRoles.Add(new UserRoleDetail - { - RoleId = role.Id, - RoleName = role.Name, - Description = role.Description, - Enabled = await userManager.IsInRoleAsync(user, role.Name!) - }); - } - - return userRoles; - } -} diff --git a/src/api/framework/Infrastructure/Infrastructure.csproj b/src/api/framework/Infrastructure/Infrastructure.csproj deleted file mode 100644 index 389248b9c6..0000000000 --- a/src/api/framework/Infrastructure/Infrastructure.csproj +++ /dev/null @@ -1,82 +0,0 @@ - - - FSH.Framework.Infrastructure - FSH.Framework.Infrastructure - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/api/framework/Infrastructure/Jobs/Extensions.cs b/src/api/framework/Infrastructure/Jobs/Extensions.cs deleted file mode 100644 index 618d07d30d..0000000000 --- a/src/api/framework/Infrastructure/Jobs/Extensions.cs +++ /dev/null @@ -1,71 +0,0 @@ -using FSH.Framework.Core.Exceptions; -using FSH.Framework.Core.Jobs; -using FSH.Framework.Core.Persistence; -using FSH.Framework.Infrastructure.Persistence; -using Hangfire; -using Hangfire.PostgreSql; -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; - -namespace FSH.Framework.Infrastructure.Jobs; - -internal static class Extensions -{ - internal static IServiceCollection ConfigureJobs(this IServiceCollection services, IConfiguration configuration) - { - var dbOptions = configuration.GetSection(nameof(DatabaseOptions)).Get() ?? - throw new FshException("database options cannot be null"); - - services.AddHangfireServer(o => - { - o.HeartbeatInterval = TimeSpan.FromSeconds(30); - o.Queues = new string[] { "default", "email" }; - o.WorkerCount = 5; - o.SchedulePollingInterval = TimeSpan.FromSeconds(30); - }); - - services.AddHangfire((provider, config) => - { - switch (dbOptions.Provider.ToUpperInvariant()) - { - case DbProviders.PostgreSQL: - config.UsePostgreSqlStorage(o => - { - o.UseNpgsqlConnection(dbOptions.ConnectionString); - }); - break; - - case DbProviders.MSSQL: - config.UseSqlServerStorage(dbOptions.ConnectionString); - break; - - default: - throw new FshException($"hangfire storage provider {dbOptions.Provider} is not supported"); - } - - config.UseFilter(new FshJobFilter(provider)); - config.UseFilter(new LogJobFilter()); - }); - - services.AddTransient(); - return services; - } - - internal static IApplicationBuilder UseJobDashboard(this IApplicationBuilder app, IConfiguration config) - { - var hangfireOptions = config.GetSection(nameof(HangfireOptions)).Get() ?? new HangfireOptions(); - var dashboardOptions = new DashboardOptions(); - dashboardOptions.AppPath = "https://fullstackhero.net/"; - dashboardOptions.Authorization = new[] - { - new HangfireCustomBasicAuthenticationFilter - { - User = hangfireOptions.UserName!, - Pass = hangfireOptions.Password! - } - }; - - return app.UseHangfireDashboard(hangfireOptions.Route, dashboardOptions); - } -} diff --git a/src/api/framework/Infrastructure/Jobs/FshJobFilter.cs b/src/api/framework/Infrastructure/Jobs/FshJobFilter.cs deleted file mode 100644 index 54b83641b2..0000000000 --- a/src/api/framework/Infrastructure/Jobs/FshJobFilter.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Finbuckle.MultiTenant.Abstractions; -using FSH.Framework.Infrastructure.Constants; -using FSH.Starter.Shared.Authorization; -using Hangfire.Client; -using Hangfire.Logging; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; - -namespace FSH.Framework.Infrastructure.Jobs; - -public class FshJobFilter : IClientFilter -{ - private static readonly ILog Logger = LogProvider.GetCurrentClassLogger(); - - private readonly IServiceProvider _services; - - public FshJobFilter(IServiceProvider services) => _services = services; - - public void OnCreating(CreatingContext context) - { - ArgumentNullException.ThrowIfNull(context); - - Logger.InfoFormat("Set TenantId and UserId parameters to job {0}.{1}...", context.Job.Method.ReflectedType?.FullName, context.Job.Method.Name); - - using var scope = _services.CreateScope(); - - var httpContext = scope.ServiceProvider.GetRequiredService()?.HttpContext; - _ = httpContext ?? throw new InvalidOperationException("Can't create a TenantJob without HttpContext."); - - var tenantInfo = scope.ServiceProvider.GetRequiredService().MultiTenantContext.TenantInfo; - context.SetJobParameter(TenantConstants.Identifier, tenantInfo); - - string? userId = httpContext.User.GetUserId(); - context.SetJobParameter(QueryStringKeys.UserId, userId); - } - - public void OnCreated(CreatedContext context) => - Logger.InfoFormat( - "Job created with parameters {0}", - context.Parameters.Select(x => x.Key + "=" + x.Value).Aggregate((s1, s2) => s1 + ";" + s2)); -} diff --git a/src/api/framework/Infrastructure/Jobs/LogJobFilter.cs b/src/api/framework/Infrastructure/Jobs/LogJobFilter.cs deleted file mode 100644 index 24f1c734ea..0000000000 --- a/src/api/framework/Infrastructure/Jobs/LogJobFilter.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Hangfire.Client; -using Hangfire.Logging; -using Hangfire.Server; -using Hangfire.States; -using Hangfire.Storage; - -namespace FSH.Framework.Infrastructure.Jobs; - -public class LogJobFilter : IClientFilter, IServerFilter, IElectStateFilter, IApplyStateFilter -{ - private static readonly ILog Logger = LogProvider.GetCurrentClassLogger(); - - public void OnCreating(CreatingContext context) => - Logger.DebugFormat("Creating a job based on method {0}...", context.Job.Method.Name); - - public void OnCreated(CreatedContext context) => - Logger.DebugFormat( - "Job that is based on method {0} has been created with id {1}", - context.Job.Method.Name, - context.BackgroundJob?.Id); - - public void OnPerforming(PerformingContext context) => - Logger.DebugFormat("Starting to perform job {0}", context.BackgroundJob.Id); - - public void OnPerformed(PerformedContext context) => - Logger.DebugFormat("Job {0} has been performed", context.BackgroundJob.Id); - - public void OnStateElection(ElectStateContext context) - { - if (context.CandidateState is FailedState failedState) - { - Logger.WarnFormat( - "Job '{0}' has been failed due to an exception {1}", - context.BackgroundJob.Id, - failedState.Exception); - } - } - - public void OnStateApplied(ApplyStateContext context, IWriteOnlyTransaction transaction) => - Logger.DebugFormat( - "Job {0} state was changed from {1} to {2}", - context.BackgroundJob.Id, - context.OldStateName, - context.NewState.Name); - - public void OnStateUnapplied(ApplyStateContext context, IWriteOnlyTransaction transaction) => - Logger.DebugFormat( - "Job {0} state {1} was unapplied.", - context.BackgroundJob.Id, - context.OldStateName); -} diff --git a/src/api/framework/Infrastructure/Logging/Serilog/Extensions.cs b/src/api/framework/Infrastructure/Logging/Serilog/Extensions.cs deleted file mode 100644 index 25a8dba177..0000000000 --- a/src/api/framework/Infrastructure/Logging/Serilog/Extensions.cs +++ /dev/null @@ -1,58 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Serilog; -using Serilog.Events; -using Serilog.Filters; - -namespace FSH.Framework.Infrastructure.Logging.Serilog; - -public static class Extensions -{ - public static WebApplicationBuilder ConfigureSerilog(this WebApplicationBuilder builder) - { - ArgumentNullException.ThrowIfNull(builder); - builder.Host.UseSerilog((context, logger) => - { - logger.WriteTo.OpenTelemetry(options => - { - try - { - options.Endpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]; - var headers = builder.Configuration["OTEL_EXPORTER_OTLP_HEADERS"]?.Split(',') ?? []; - foreach (var header in headers) - { - var (key, value) = header.Split('=') switch - { - [string k, string v] => (k, v), - var v => throw new Exception($"Invalid header format {v}") - }; - - options.Headers.Add(key, value); - } - options.ResourceAttributes.Add("service.name", "apiservice"); - //To remove the duplicate issue, we can use the below code to get the key and value from the configuration - var (otelResourceAttribute, otelResourceAttributeValue) = builder.Configuration["OTEL_RESOURCE_ATTRIBUTES"]?.Split('=') switch - { - [string k, string v] => (k, v), - _ => throw new Exception($"Invalid header format {builder.Configuration["OTEL_RESOURCE_ATTRIBUTES"]}") - }; - options.ResourceAttributes.Add(otelResourceAttribute, otelResourceAttributeValue); - } - catch - { - //ignore - } - }); - logger.ReadFrom.Configuration(context.Configuration); - logger.Enrich.FromLogContext(); - logger.Enrich.WithCorrelationId(); - logger - .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) - .MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information) - .MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Error) - .MinimumLevel.Override("Hangfire", LogEventLevel.Warning) - .MinimumLevel.Override("Finbuckle.MultiTenant", LogEventLevel.Warning) - .Filter.ByExcluding(Matching.FromSource("Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware")); - }); - return builder; - } -} diff --git a/src/api/framework/Infrastructure/Mail/Extensions.cs b/src/api/framework/Infrastructure/Mail/Extensions.cs deleted file mode 100644 index 4c772f7731..0000000000 --- a/src/api/framework/Infrastructure/Mail/Extensions.cs +++ /dev/null @@ -1,13 +0,0 @@ -using FSH.Framework.Core.Mail; -using Microsoft.Extensions.DependencyInjection; - -namespace FSH.Framework.Infrastructure.Mail; -internal static class Extensions -{ - internal static IServiceCollection ConfigureMailing(this IServiceCollection services) - { - services.AddTransient(); - services.AddOptions().BindConfiguration(nameof(MailOptions)); - return services; - } -} diff --git a/src/api/framework/Infrastructure/OpenApi/ConfigureSwaggerOptions.cs b/src/api/framework/Infrastructure/OpenApi/ConfigureSwaggerOptions.cs deleted file mode 100644 index 71eed3eb74..0000000000 --- a/src/api/framework/Infrastructure/OpenApi/ConfigureSwaggerOptions.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Text; -using Asp.Versioning.ApiExplorer; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Microsoft.OpenApi.Models; -using Swashbuckle.AspNetCore.SwaggerGen; - -namespace FSH.Framework.Infrastructure.OpenApi; -public class ConfigureSwaggerOptions : IConfigureOptions -{ - private readonly IApiVersionDescriptionProvider provider; - - /// - /// Initializes a new instance of the class. - /// - /// The provider used to generate Swagger documents. - public ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider) => this.provider = provider; - - /// - public void Configure(SwaggerGenOptions options) - { - // add a swagger document for each discovered API version - // note: you might choose to skip or document deprecated API versions differently - foreach (var description in provider.ApiVersionDescriptions) - { - options.SwaggerDoc(description.GroupName, CreateInfoForApiVersion(description)); - } - } - - private static OpenApiInfo CreateInfoForApiVersion(ApiVersionDescription description) - { - var text = new StringBuilder(".NET 8 Starter Kit with Vertical Slice Architecture!"); - var info = new OpenApiInfo() - { - Title = "FSH.Starter.WebApi", - Version = description.ApiVersion.ToString(), - Contact = new OpenApiContact() { Name = "Mukesh Murugan", Email = "hello@codewithmukesh.com" } - }; - - if (description.IsDeprecated) - { - text.Append(" This API version has been deprecated."); - } - - info.Description = text.ToString(); - - return info; - } -} diff --git a/src/api/framework/Infrastructure/OpenApi/Extensions.cs b/src/api/framework/Infrastructure/OpenApi/Extensions.cs deleted file mode 100644 index df32e185fe..0000000000 --- a/src/api/framework/Infrastructure/OpenApi/Extensions.cs +++ /dev/null @@ -1,82 +0,0 @@ -using Asp.Versioning; -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Options; -using Microsoft.OpenApi.Models; -using Swashbuckle.AspNetCore.SwaggerGen; -using Swashbuckle.AspNetCore.SwaggerUI; - -namespace FSH.Framework.Infrastructure.OpenApi; - -public static class Extensions -{ - public static IServiceCollection ConfigureOpenApi(this IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - services.AddEndpointsApiExplorer(); - services.AddTransient, ConfigureSwaggerOptions>(); - services - .AddSwaggerGen(options => - { - options.OperationFilter(); - options.AddSecurityDefinition("bearerAuth", new OpenApiSecurityScheme - { - Type = SecuritySchemeType.Http, - Scheme = "bearer", - BearerFormat = "JWT", - Description = "JWT Authorization header using the Bearer scheme." - }); - options.AddSecurityRequirement(new OpenApiSecurityRequirement - { - { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "bearerAuth" } - }, - Array.Empty() - } - }); - }); - services - .AddApiVersioning(options => - { - options.ReportApiVersions = true; - options.DefaultApiVersion = new ApiVersion(1); - options.AssumeDefaultVersionWhenUnspecified = true; - options.ApiVersionReader = new UrlSegmentApiVersionReader(); - }) - .AddApiExplorer(options => - { - options.GroupNameFormat = "'v'VVV"; - }) - .EnableApiVersionBinding(); - return services; - } - public static WebApplication UseOpenApi(this WebApplication app) - { - ArgumentNullException.ThrowIfNull(app); - if (app.Environment.IsDevelopment() || app.Environment.EnvironmentName == "docker") - { - app.UseSwagger(); - app.UseSwaggerUI(options => - { - options.DocExpansion(DocExpansion.None); - options.DisplayRequestDuration(); - - var swaggerEndpoints = app.DescribeApiVersions() - .Select(desc => new - { - Url = $"../swagger/{desc.GroupName}/swagger.json", - Name = desc.GroupName.ToUpperInvariant() - }); - - foreach (var endpoint in swaggerEndpoints) - { - options.SwaggerEndpoint(endpoint.Url, endpoint.Name); - } - }); - } - return app; - } -} diff --git a/src/api/framework/Infrastructure/OpenApi/SwaggerDefaultValues.cs b/src/api/framework/Infrastructure/OpenApi/SwaggerDefaultValues.cs deleted file mode 100644 index b872e69024..0000000000 --- a/src/api/framework/Infrastructure/OpenApi/SwaggerDefaultValues.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System.Text.Json; -using Microsoft.AspNetCore.Mvc.ApiExplorer; -using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.OpenApi.Models; -using Swashbuckle.AspNetCore.SwaggerGen; - -namespace FSH.Framework.Infrastructure.OpenApi; -public class SwaggerDefaultValues : IOperationFilter -{ - /// - public void Apply(OpenApiOperation operation, OperationFilterContext context) - { - ArgumentNullException.ThrowIfNull(operation); - ArgumentNullException.ThrowIfNull(context); - - var apiDescription = context.ApiDescription; - - operation.Deprecated |= apiDescription.IsDeprecated(); - - // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/1752#issue-663991077 - foreach (var responseType in context.ApiDescription.SupportedResponseTypes) - { - // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/b7cf75e7905050305b115dd96640ddd6e74c7ac9/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs#L383-L387 - var responseKey = responseType.IsDefaultResponse ? "default" : responseType.StatusCode.ToString(); - var response = operation.Responses[responseKey]; - - foreach (var contentType in response.Content.Keys) - { - if (!responseType.ApiResponseFormats.Any(x => x.MediaType == contentType)) - { - response.Content.Remove(contentType); - } - } - } - - if (operation.Parameters == null) - { - return; - } - - // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/412 - // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/pull/413 - foreach (var parameter in operation.Parameters) - { - var description = apiDescription.ParameterDescriptions.First(p => p.Name == parameter.Name); - - parameter.Description ??= description.ModelMetadata?.Description; - - if (parameter.Schema.Default == null && - description.DefaultValue != null && - description.DefaultValue is not DBNull && - description.ModelMetadata is ModelMetadata modelMetadata) - { - // REF: https://github.com/Microsoft/aspnet-api-versioning/issues/429#issuecomment-605402330 - var json = JsonSerializer.Serialize(description.DefaultValue, modelMetadata.ModelType); - parameter.Schema.Default = OpenApiAnyFactory.CreateFromJson(json); - } - - parameter.Required |= description.IsRequired; - } - } -} diff --git a/src/api/framework/Infrastructure/Persistence/AppendGlobalQueryFilterExtension.cs b/src/api/framework/Infrastructure/Persistence/AppendGlobalQueryFilterExtension.cs deleted file mode 100644 index dbd09831d1..0000000000 --- a/src/api/framework/Infrastructure/Persistence/AppendGlobalQueryFilterExtension.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Linq.Expressions; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Query; - -namespace FSH.Framework.Infrastructure.Persistence; - -internal static class ModelBuilderExtensions -{ - public static ModelBuilder AppendGlobalQueryFilter(this ModelBuilder modelBuilder, Expression> filter) - { - // get a list of entities without a baseType that implement the interface TInterface - var entities = modelBuilder.Model.GetEntityTypes() - .Where(e => e.BaseType is null && e.ClrType.GetInterface(typeof(TInterface).Name) is not null) - .Select(e => e.ClrType); - - foreach (var entity in entities) - { - var parameterType = Expression.Parameter(modelBuilder.Entity(entity).Metadata.ClrType); - var filterBody = ReplacingExpressionVisitor.Replace(filter.Parameters.Single(), parameterType, filter.Body); - - // get the existing query filter - if (modelBuilder.Entity(entity).Metadata.GetQueryFilter() is { } existingFilter) - { - var existingFilterBody = ReplacingExpressionVisitor.Replace(existingFilter.Parameters.Single(), parameterType, existingFilter.Body); - - // combine the existing query filter with the new query filter - filterBody = Expression.AndAlso(existingFilterBody, filterBody); - } - - // apply the new query filter - modelBuilder.Entity(entity).HasQueryFilter(Expression.Lambda(filterBody, parameterType)); - } - - return modelBuilder; - } -} diff --git a/src/api/framework/Infrastructure/Persistence/DbProviders.cs b/src/api/framework/Infrastructure/Persistence/DbProviders.cs deleted file mode 100644 index f330df5123..0000000000 --- a/src/api/framework/Infrastructure/Persistence/DbProviders.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace FSH.Framework.Infrastructure.Persistence; -internal static class DbProviders -{ - public const string PostgreSQL = "POSTGRESQL"; - public const string MSSQL = "MSSQL"; -} diff --git a/src/api/framework/Infrastructure/Persistence/Extensions.cs b/src/api/framework/Infrastructure/Persistence/Extensions.cs deleted file mode 100644 index dce8cb5a64..0000000000 --- a/src/api/framework/Infrastructure/Persistence/Extensions.cs +++ /dev/null @@ -1,56 +0,0 @@ -using FSH.Framework.Core.Persistence; -using FSH.Framework.Infrastructure.Persistence.Interceptors; -using Microsoft.AspNetCore.Builder; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Diagnostics; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Serilog; - -namespace FSH.Framework.Infrastructure.Persistence; -public static class Extensions -{ - private static readonly ILogger Logger = Log.ForContext(typeof(Extensions)); - internal static DbContextOptionsBuilder ConfigureDatabase(this DbContextOptionsBuilder builder, string dbProvider, string connectionString) - { - builder.ConfigureWarnings(warnings => warnings.Log(RelationalEventId.PendingModelChangesWarning)); - return dbProvider.ToUpperInvariant() switch - { - DbProviders.PostgreSQL => builder.UseNpgsql(connectionString, e => - e.MigrationsAssembly("FSH.Starter.WebApi.Migrations.PostgreSQL")).EnableSensitiveDataLogging(), - DbProviders.MSSQL => builder.UseSqlServer(connectionString, e => - e.MigrationsAssembly("FSH.Starter.WebApi.Migrations.MSSQL")), - _ => throw new InvalidOperationException($"DB Provider {dbProvider} is not supported."), - }; - } - - public static WebApplicationBuilder ConfigureDatabase(this WebApplicationBuilder builder) - { - ArgumentNullException.ThrowIfNull(builder); - builder.Services.AddOptions() - .BindConfiguration(nameof(DatabaseOptions)) - .ValidateDataAnnotations() - .PostConfigure(config => - { - Logger.Information("current db provider: {DatabaseProvider}", config.Provider); - Logger.Information("for documentations and guides, visit https://www.fullstackhero.net"); - Logger.Information("to sponsor this project, visit https://opencollective.com/fullstackhero"); - }); - builder.Services.AddScoped(); - return builder; - } - - public static IServiceCollection BindDbContext(this IServiceCollection services) - where TContext : DbContext - { - ArgumentNullException.ThrowIfNull(services); - - services.AddDbContext((sp, options) => - { - var dbConfig = sp.GetRequiredService>().Value; - options.ConfigureDatabase(dbConfig.Provider, dbConfig.ConnectionString); - options.AddInterceptors(sp.GetServices()); - }); - return services; - } -} diff --git a/src/api/framework/Infrastructure/Persistence/FshDbContext.cs b/src/api/framework/Infrastructure/Persistence/FshDbContext.cs deleted file mode 100644 index 1f3186e3e5..0000000000 --- a/src/api/framework/Infrastructure/Persistence/FshDbContext.cs +++ /dev/null @@ -1,60 +0,0 @@ -using Finbuckle.MultiTenant.Abstractions; -using Finbuckle.MultiTenant.EntityFrameworkCore; -using FSH.Framework.Core.Domain.Contracts; -using FSH.Framework.Core.Persistence; -using FSH.Framework.Infrastructure.Tenant; -using MediatR; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; - -namespace FSH.Framework.Infrastructure.Persistence; -public class FshDbContext(IMultiTenantContextAccessor multiTenantContextAccessor, - DbContextOptions options, - IPublisher publisher, - IOptions settings) - : MultiTenantDbContext(multiTenantContextAccessor, options) -{ - private readonly IPublisher _publisher = publisher; - private readonly DatabaseOptions _settings = settings.Value; - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - // QueryFilters need to be applied before base.OnModelCreating - modelBuilder.AppendGlobalQueryFilter(s => s.Deleted == null); - base.OnModelCreating(modelBuilder); - } - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.EnableSensitiveDataLogging(); - - if (!string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext.TenantInfo?.ConnectionString)) - { - optionsBuilder.ConfigureDatabase(_settings.Provider, multiTenantContextAccessor.MultiTenantContext.TenantInfo.ConnectionString!); - } - } - public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) - { - this.TenantNotSetMode = TenantNotSetMode.Overwrite; - int result = await base.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - await PublishDomainEventsAsync().ConfigureAwait(false); - return result; - } - private async Task PublishDomainEventsAsync() - { - var domainEvents = ChangeTracker.Entries() - .Select(e => e.Entity) - .Where(e => e.DomainEvents.Count > 0) - .SelectMany(e => - { - var domainEvents = e.DomainEvents.ToList(); - e.DomainEvents.Clear(); - return domainEvents; - }) - .ToList(); - - foreach (var domainEvent in domainEvents) - { - await _publisher.Publish(domainEvent).ConfigureAwait(false); - } - } -} diff --git a/src/api/framework/Infrastructure/Persistence/Interceptors/AuditInterceptor.cs b/src/api/framework/Infrastructure/Persistence/Interceptors/AuditInterceptor.cs deleted file mode 100644 index 6c2d819cac..0000000000 --- a/src/api/framework/Infrastructure/Persistence/Interceptors/AuditInterceptor.cs +++ /dev/null @@ -1,145 +0,0 @@ -using System.Collections.ObjectModel; -using FSH.Framework.Core.Audit; -using FSH.Framework.Core.Domain; -using FSH.Framework.Core.Domain.Contracts; -using FSH.Framework.Core.Identity.Users.Abstractions; -using FSH.Framework.Infrastructure.Identity.Audit; -using MediatR; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.ChangeTracking; -using Microsoft.EntityFrameworkCore.Diagnostics; - -namespace FSH.Framework.Infrastructure.Persistence.Interceptors; -public class AuditInterceptor(ICurrentUser currentUser, TimeProvider timeProvider, IPublisher publisher) : SaveChangesInterceptor -{ - - public override ValueTask SavedChangesAsync(SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default) - { - return base.SavedChangesAsync(eventData, result, cancellationToken); - } - - public override Task SaveChangesFailedAsync(DbContextErrorEventData eventData, CancellationToken cancellationToken = default) - { - return base.SaveChangesFailedAsync(eventData, cancellationToken); - } - - public override async ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) - { - UpdateEntities(eventData.Context); - await PublishAuditTrailsAsync(eventData); - return await base.SavingChangesAsync(eventData, result, cancellationToken); - } - - private async Task PublishAuditTrailsAsync(DbContextEventData eventData) - { - if (eventData.Context == null) return; - eventData.Context.ChangeTracker.DetectChanges(); - var trails = new List(); - var utcNow = timeProvider.GetUtcNow(); - foreach (var entry in eventData.Context.ChangeTracker.Entries().Where(x => x.State is EntityState.Added or EntityState.Deleted or EntityState.Modified).ToList()) - { - var userId = currentUser.GetUserId(); - var trail = new TrailDto() - { - Id = Guid.NewGuid(), - TableName = entry.Entity.GetType().Name, - UserId = userId, - DateTime = utcNow - }; - - foreach (var property in entry.Properties) - { - if (property.IsTemporary) - { - continue; - } - string propertyName = property.Metadata.Name; - if (property.Metadata.IsPrimaryKey()) - { - trail.KeyValues[propertyName] = property.CurrentValue; - continue; - } - - switch (entry.State) - { - case EntityState.Added: - trail.Type = TrailType.Create; - trail.NewValues[propertyName] = property.CurrentValue; - break; - - case EntityState.Deleted: - trail.Type = TrailType.Delete; - trail.OldValues[propertyName] = property.OriginalValue; - break; - - case EntityState.Modified: - if (property.IsModified) - { - if (entry.Entity is ISoftDeletable && property.OriginalValue == null && property.CurrentValue != null) - { - trail.ModifiedProperties.Add(propertyName); - trail.Type = TrailType.Delete; - trail.OldValues[propertyName] = property.OriginalValue; - trail.NewValues[propertyName] = property.CurrentValue; - } - else if (property.OriginalValue?.Equals(property.CurrentValue) == false) - { - trail.ModifiedProperties.Add(propertyName); - trail.Type = TrailType.Update; - trail.OldValues[propertyName] = property.OriginalValue; - trail.NewValues[propertyName] = property.CurrentValue; - } - else - { - property.IsModified = false; - } - } - break; - } - } - - trails.Add(trail); - } - if (trails.Count == 0) return; - var auditTrails = new Collection(); - foreach (var trail in trails) - { - auditTrails.Add(trail.ToAuditTrail()); - } - await publisher.Publish(new AuditPublishedEvent(auditTrails)); - } - - public void UpdateEntities(DbContext? context) - { - if (context == null) return; - foreach (var entry in context.ChangeTracker.Entries()) - { - var utcNow = timeProvider.GetUtcNow(); - if (entry.State is EntityState.Added or EntityState.Modified || entry.HasChangedOwnedEntities()) - { - if (entry.State == EntityState.Added) - { - entry.Entity.CreatedBy = currentUser.GetUserId(); - entry.Entity.Created = utcNow; - } - entry.Entity.LastModifiedBy = currentUser.GetUserId(); - entry.Entity.LastModified = utcNow; - } - if(entry.State is EntityState.Deleted && entry.Entity is ISoftDeletable softDelete) - { - softDelete.DeletedBy = currentUser.GetUserId(); - softDelete.Deleted = utcNow; - entry.State = EntityState.Modified; - } - } - } -} - -public static class Extensions -{ - public static bool HasChangedOwnedEntities(this EntityEntry entry) => - entry.References.Any(r => - r.TargetEntry != null && - r.TargetEntry.Metadata.IsOwned() && - (r.TargetEntry.State == EntityState.Added || r.TargetEntry.State == EntityState.Modified)); -} diff --git a/src/api/framework/Infrastructure/RateLimit/Extensions.cs b/src/api/framework/Infrastructure/RateLimit/Extensions.cs deleted file mode 100644 index 3b00e7d85f..0000000000 --- a/src/api/framework/Infrastructure/RateLimit/Extensions.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System.Threading.RateLimiting; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.RateLimiting; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace FSH.Framework.Infrastructure.RateLimit; - -public static class Extensions -{ - internal static IServiceCollection ConfigureRateLimit(this IServiceCollection services, IConfiguration config) - { - services.Configure(config.GetSection(nameof(RateLimitOptions))); - - var options = config.GetSection(nameof(RateLimitOptions)).Get(); - if (options is { EnableRateLimiting: true }) - { - services.AddRateLimiter(rateLimitOptions => - { - rateLimitOptions.GlobalLimiter = PartitionedRateLimiter.Create(httpContext => - { - return RateLimitPartition.GetFixedWindowLimiter(partitionKey: httpContext.Request.Headers.Host.ToString(), - factory: _ => new FixedWindowRateLimiterOptions - { - PermitLimit = options.PermitLimit, - Window = TimeSpan.FromSeconds(options.WindowInSeconds) - }); - }); - - rateLimitOptions.RejectionStatusCode = options.RejectionStatusCode; - rateLimitOptions.OnRejected = async (context, token) => - { - var message = BuildRateLimitResponseMessage(context); - - await context.HttpContext.Response.WriteAsync(message, cancellationToken: token); - }; - }); - } - - return services; - } - - internal static IApplicationBuilder UseRateLimit(this IApplicationBuilder app) - { - var options = app.ApplicationServices.GetRequiredService>().Value; - - if (options.EnableRateLimiting) - { - app.UseRateLimiter(); - } - - return app; - } - - private static string BuildRateLimitResponseMessage(OnRejectedContext onRejectedContext) - { - var hostName = onRejectedContext.HttpContext.Request.Headers.Host.ToString(); - - return $"You have reached the maximum number of requests allowed for the address ({hostName})."; - } -} diff --git a/src/api/framework/Infrastructure/RateLimit/RateLimitOptions.cs b/src/api/framework/Infrastructure/RateLimit/RateLimitOptions.cs deleted file mode 100644 index 6fd364c5d1..0000000000 --- a/src/api/framework/Infrastructure/RateLimit/RateLimitOptions.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace FSH.Framework.Infrastructure.RateLimit; - -public class RateLimitOptions -{ - public bool EnableRateLimiting { get; init; } - public int PermitLimit { get; init; } - public int WindowInSeconds { get; init; } - public int RejectionStatusCode { get; init; } -} diff --git a/src/api/framework/Infrastructure/SecurityHeaders/Extensions.cs b/src/api/framework/Infrastructure/SecurityHeaders/Extensions.cs deleted file mode 100644 index 7d8ea168c7..0000000000 --- a/src/api/framework/Infrastructure/SecurityHeaders/Extensions.cs +++ /dev/null @@ -1,69 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace FSH.Framework.Infrastructure.SecurityHeaders; - -public static class Extensions -{ - internal static IServiceCollection ConfigureSecurityHeaders(this IServiceCollection services, IConfiguration config) - { - services.Configure(config.GetSection(nameof(SecurityHeaderOptions))); - - return services; - } - - internal static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder app) - { - var options = app.ApplicationServices.GetRequiredService>().Value; - - if (options.Enable) - { - app.Use(async (context, next) => - { - if (!context.Response.HasStarted) - { - if (!string.IsNullOrWhiteSpace(options.Headers.XFrameOptions)) - { - context.Response.Headers.XFrameOptions = options.Headers.XFrameOptions; - } - - if (!string.IsNullOrWhiteSpace(options.Headers.XContentTypeOptions)) - { - context.Response.Headers.XContentTypeOptions = options.Headers.XContentTypeOptions; - } - - if (!string.IsNullOrWhiteSpace(options.Headers.ReferrerPolicy)) - { - context.Response.Headers.Referer = options.Headers.ReferrerPolicy; - } - - if (!string.IsNullOrWhiteSpace(options.Headers.PermissionsPolicy)) - { - context.Response.Headers["Permissions-Policy"] = options.Headers.PermissionsPolicy; - } - - if (!string.IsNullOrWhiteSpace(options.Headers.XXSSProtection)) - { - context.Response.Headers.XXSSProtection = options.Headers.XXSSProtection; - } - - if (!string.IsNullOrWhiteSpace(options.Headers.ContentSecurityPolicy)) - { - context.Response.Headers.ContentSecurityPolicy = options.Headers.ContentSecurityPolicy; - } - - if (!string.IsNullOrWhiteSpace(options.Headers.StrictTransportSecurity)) - { - context.Response.Headers.StrictTransportSecurity = options.Headers.StrictTransportSecurity; - } - } - - await next.Invoke(); - }); - } - - return app; - } -} diff --git a/src/api/framework/Infrastructure/SecurityHeaders/SecurityHeaderOptions.cs b/src/api/framework/Infrastructure/SecurityHeaders/SecurityHeaderOptions.cs deleted file mode 100644 index 4fac61a596..0000000000 --- a/src/api/framework/Infrastructure/SecurityHeaders/SecurityHeaderOptions.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace FSH.Framework.Infrastructure.SecurityHeaders; - -public class SecurityHeaderOptions -{ - public bool Enable { get; set; } - public SecurityHeaders Headers { get; set; } = default!; -} diff --git a/src/api/framework/Infrastructure/SecurityHeaders/SecurityHeaders.cs b/src/api/framework/Infrastructure/SecurityHeaders/SecurityHeaders.cs deleted file mode 100644 index 596d99a175..0000000000 --- a/src/api/framework/Infrastructure/SecurityHeaders/SecurityHeaders.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace FSH.Framework.Infrastructure.SecurityHeaders; - -public class SecurityHeaders -{ - public string? XContentTypeOptions { get; set; } - public string? ReferrerPolicy { get; set; } - public string? XXSSProtection { get; set; } - public string? XFrameOptions { get; set; } - public string? ContentSecurityPolicy { get; set; } - public string? PermissionsPolicy { get; set; } - public string? StrictTransportSecurity { get; set; } -} diff --git a/src/api/framework/Infrastructure/Storage/Files/Extension.cs b/src/api/framework/Infrastructure/Storage/Files/Extension.cs deleted file mode 100644 index 6699f126d2..0000000000 --- a/src/api/framework/Infrastructure/Storage/Files/Extension.cs +++ /dev/null @@ -1,25 +0,0 @@ -using FSH.Framework.Core.Storage; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.FileProviders; - -namespace FSH.Framework.Infrastructure.Storage.Files; - -internal static class Extension -{ - internal static IServiceCollection ConfigureFileStorage(this IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - services.AddTransient(); - - return services; - } - - internal static IApplicationBuilder UseFileStorage(this IApplicationBuilder app) => - app.UseStaticFiles(new StaticFileOptions() - { - FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "Files")), - RequestPath = new PathString("/Files") - }); -} diff --git a/src/api/framework/Infrastructure/Storage/Files/LocalFileStorageService.cs b/src/api/framework/Infrastructure/Storage/Files/LocalFileStorageService.cs deleted file mode 100644 index 16b786da6f..0000000000 --- a/src/api/framework/Infrastructure/Storage/Files/LocalFileStorageService.cs +++ /dev/null @@ -1,133 +0,0 @@ -using System.Runtime.InteropServices; -using System.Text.RegularExpressions; -using FSH.Framework.Core.Origin; -using FSH.Framework.Core.Storage; -using FSH.Framework.Core.Storage.File; -using FSH.Framework.Core.Storage.File.Features; -using FSH.Framework.Infrastructure.Common.Extensions; -using Microsoft.Extensions.Options; -namespace FSH.Framework.Infrastructure.Storage.Files -{ - public class LocalFileStorageService(IOptions originSettings) : IStorageService - { - public async Task UploadAsync(FileUploadCommand? request, FileType supportedFileType, CancellationToken cancellationToken = default) - where T : class - { - if (request == null || request.Data == null) - { - return null!; - } - - if (request.Extension is null || !supportedFileType.GetDescriptionList().Contains(request.Extension.ToLower(System.Globalization.CultureInfo.CurrentCulture))) - throw new InvalidOperationException("File Format Not Supported."); - if (request.Name is null) - throw new InvalidOperationException("Name is required."); - - string base64Data = Regex.Match(request.Data, "data:image/(?.+?),(?.+)").Groups["data"].Value; - - var streamData = new MemoryStream(Convert.FromBase64String(base64Data)); - if (streamData.Length > 0) - { - string folder = typeof(T).Name; - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - folder = folder.Replace(@"\", "/", StringComparison.Ordinal); - } - - string folderName = supportedFileType switch - { - FileType.Image => Path.Combine("assets", "images", folder), - _ => Path.Combine("assets", "others", folder), - }; - string pathToSave = Path.Combine(Directory.GetCurrentDirectory(), folderName); - Directory.CreateDirectory(pathToSave); - - string fileName = request.Name.Trim('"'); - fileName = RemoveSpecialCharacters(fileName); - fileName = fileName.ReplaceWhitespace("-"); - fileName += request.Extension.Trim(); - string fullPath = Path.Combine(pathToSave, fileName); - string dbPath = Path.Combine(folderName, fileName); - if (File.Exists(dbPath)) - { - dbPath = NextAvailableFilename(dbPath); - fullPath = NextAvailableFilename(fullPath); - } - - using var stream = new FileStream(fullPath, FileMode.Create); - await streamData.CopyToAsync(stream, cancellationToken); - var path = dbPath.Replace("\\", "/", StringComparison.Ordinal); - var imageUri = new Uri(originSettings.Value.OriginUrl!, path); - return imageUri; - } - else - { - return null!; - } - } - - public static string RemoveSpecialCharacters(string str) - { - return Regex.Replace(str, "[^a-zA-Z0-9_.]+", string.Empty, RegexOptions.Compiled); - } - - public void Remove(Uri? path) - { - var pathString = path!.ToString(); - if (File.Exists(pathString)) - { - File.Delete(pathString); - } - } - - private const string NumberPattern = "-{0}"; - - private static string NextAvailableFilename(string path) - { - if (!File.Exists(path)) - { - return path; - } - - if (Path.HasExtension(path)) - { - return GetNextFilename(path.Insert(path.LastIndexOf(Path.GetExtension(path), StringComparison.Ordinal), NumberPattern)); - } - - return GetNextFilename(path + NumberPattern); - } - - private static string GetNextFilename(string pattern) - { - string tmp = string.Format(pattern, 1); - - if (!File.Exists(tmp)) - { - return tmp; - } - - int min = 1, max = 2; - - while (File.Exists(string.Format(pattern, max))) - { - min = max; - max *= 2; - } - - while (max != min + 1) - { - int pivot = (max + min) / 2; - if (File.Exists(string.Format(pattern, pivot))) - { - min = pivot; - } - else - { - max = pivot; - } - } - - return string.Format(pattern, max); - } - } -} diff --git a/src/api/framework/Infrastructure/Tenant/Abstractions/IFshTenantInfo.cs b/src/api/framework/Infrastructure/Tenant/Abstractions/IFshTenantInfo.cs deleted file mode 100644 index 843820200f..0000000000 --- a/src/api/framework/Infrastructure/Tenant/Abstractions/IFshTenantInfo.cs +++ /dev/null @@ -1,7 +0,0 @@ -using Finbuckle.MultiTenant.Abstractions; - -namespace FSH.Framework.Infrastructure.Tenant.Abstractions; -public interface IFshTenantInfo : ITenantInfo -{ - string? ConnectionString { get; set; } -} diff --git a/src/api/framework/Infrastructure/Tenant/Endpoints/ActivateTenantEndpoint.cs b/src/api/framework/Infrastructure/Tenant/Endpoints/ActivateTenantEndpoint.cs deleted file mode 100644 index 4f2f24f87b..0000000000 --- a/src/api/framework/Infrastructure/Tenant/Endpoints/ActivateTenantEndpoint.cs +++ /dev/null @@ -1,19 +0,0 @@ -using FSH.Framework.Core.Tenant.Features.ActivateTenant; -using FSH.Framework.Infrastructure.Auth.Policy; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Tenant.Endpoints; -public static class ActivateTenantEndpoint -{ - internal static RouteHandlerBuilder MapActivateTenantEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapPost("/{id}/activate", (ISender mediator, string id) => mediator.Send(new ActivateTenantCommand(id))) - .WithName(nameof(ActivateTenantEndpoint)) - .WithSummary("activate tenant") - .RequirePermission("Permissions.Tenants.Update") - .WithDescription("activate tenant"); - } -} diff --git a/src/api/framework/Infrastructure/Tenant/Endpoints/CreateTenantEndpoint.cs b/src/api/framework/Infrastructure/Tenant/Endpoints/CreateTenantEndpoint.cs deleted file mode 100644 index 51d8a9f6fd..0000000000 --- a/src/api/framework/Infrastructure/Tenant/Endpoints/CreateTenantEndpoint.cs +++ /dev/null @@ -1,19 +0,0 @@ -using FSH.Framework.Core.Tenant.Features.CreateTenant; -using FSH.Framework.Infrastructure.Auth.Policy; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Tenant.Endpoints; -public static class CreateTenantEndpoint -{ - internal static RouteHandlerBuilder MapRegisterTenantEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapPost("/", (CreateTenantCommand request, ISender mediator) => mediator.Send(request)) - .WithName(nameof(CreateTenantEndpoint)) - .WithSummary("creates a tenant") - .RequirePermission("Permissions.Tenants.Create") - .WithDescription("creates a tenant"); - } -} diff --git a/src/api/framework/Infrastructure/Tenant/Endpoints/DisableTenantEndpoint.cs b/src/api/framework/Infrastructure/Tenant/Endpoints/DisableTenantEndpoint.cs deleted file mode 100644 index 64ef613204..0000000000 --- a/src/api/framework/Infrastructure/Tenant/Endpoints/DisableTenantEndpoint.cs +++ /dev/null @@ -1,19 +0,0 @@ -using FSH.Framework.Core.Tenant.Features.DisableTenant; -using FSH.Framework.Infrastructure.Auth.Policy; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Tenant.Endpoints; -public static class DisableTenantEndpoint -{ - internal static RouteHandlerBuilder MapDisableTenantEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapPost("/{id}/deactivate", (ISender mediator, string id) => mediator.Send(new DisableTenantCommand(id))) - .WithName(nameof(DisableTenantEndpoint)) - .WithSummary("activate tenant") - .RequirePermission("Permissions.Tenants.Update") - .WithDescription("activate tenant"); - } -} diff --git a/src/api/framework/Infrastructure/Tenant/Endpoints/Extensions.cs b/src/api/framework/Infrastructure/Tenant/Endpoints/Extensions.cs deleted file mode 100644 index bc88511001..0000000000 --- a/src/api/framework/Infrastructure/Tenant/Endpoints/Extensions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Tenant.Endpoints; -public static class Extensions -{ - public static IEndpointRouteBuilder MapTenantEndpoints(this IEndpointRouteBuilder app) - { - var tenantGroup = app.MapGroup("api/tenants").WithTags("tenants"); - tenantGroup.MapRegisterTenantEndpoint(); - tenantGroup.MapGetTenantsEndpoint(); - tenantGroup.MapGetTenantByIdEndpoint(); - tenantGroup.MapUpgradeTenantSubscriptionEndpoint(); - tenantGroup.MapActivateTenantEndpoint(); - tenantGroup.MapDisableTenantEndpoint(); - return app; - } -} diff --git a/src/api/framework/Infrastructure/Tenant/Endpoints/GetTenantByIdEndpoint.cs b/src/api/framework/Infrastructure/Tenant/Endpoints/GetTenantByIdEndpoint.cs deleted file mode 100644 index a429ac62e4..0000000000 --- a/src/api/framework/Infrastructure/Tenant/Endpoints/GetTenantByIdEndpoint.cs +++ /dev/null @@ -1,19 +0,0 @@ -using FSH.Framework.Core.Tenant.Features.GetTenantById; -using FSH.Framework.Infrastructure.Auth.Policy; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Tenant.Endpoints; -public static class GetTenantByIdEndpoint -{ - internal static RouteHandlerBuilder MapGetTenantByIdEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapGet("/{id}", (ISender mediator, string id) => mediator.Send(new GetTenantByIdQuery(id))) - .WithName(nameof(GetTenantByIdEndpoint)) - .WithSummary("get tenant by id") - .RequirePermission("Permissions.Tenants.View") - .WithDescription("get tenant by id"); - } -} diff --git a/src/api/framework/Infrastructure/Tenant/Endpoints/GetTenantsEndpoint.cs b/src/api/framework/Infrastructure/Tenant/Endpoints/GetTenantsEndpoint.cs deleted file mode 100644 index 1bf590deb4..0000000000 --- a/src/api/framework/Infrastructure/Tenant/Endpoints/GetTenantsEndpoint.cs +++ /dev/null @@ -1,19 +0,0 @@ -using FSH.Framework.Core.Tenant.Features.GetTenants; -using FSH.Framework.Infrastructure.Auth.Policy; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Tenant.Endpoints; -public static class GetTenantsEndpoint -{ - internal static RouteHandlerBuilder MapGetTenantsEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapGet("/", (ISender mediator) => mediator.Send(new GetTenantsQuery())) - .WithName(nameof(GetTenantsEndpoint)) - .WithSummary("get tenants") - .RequirePermission("Permissions.Tenants.View") - .WithDescription("get tenants"); - } -} diff --git a/src/api/framework/Infrastructure/Tenant/Endpoints/UpgradeSubscriptionEndpoint.cs b/src/api/framework/Infrastructure/Tenant/Endpoints/UpgradeSubscriptionEndpoint.cs deleted file mode 100644 index 182330544f..0000000000 --- a/src/api/framework/Infrastructure/Tenant/Endpoints/UpgradeSubscriptionEndpoint.cs +++ /dev/null @@ -1,20 +0,0 @@ -using FSH.Framework.Core.Tenant.Features.UpgradeSubscription; -using FSH.Framework.Infrastructure.Auth.Policy; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Framework.Infrastructure.Tenant.Endpoints; - -public static class UpgradeSubscriptionEndpoint -{ - internal static RouteHandlerBuilder MapUpgradeTenantSubscriptionEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapPost("/upgrade", (UpgradeSubscriptionCommand command, ISender mediator) => mediator.Send(command)) - .WithName(nameof(UpgradeSubscriptionEndpoint)) - .WithSummary("upgrade tenant subscription") - .RequirePermission("Permissions.Tenants.Update") - .WithDescription("upgrade tenant subscription"); - } -} diff --git a/src/api/framework/Infrastructure/Tenant/Extensions.cs b/src/api/framework/Infrastructure/Tenant/Extensions.cs deleted file mode 100644 index f7fea460cd..0000000000 --- a/src/api/framework/Infrastructure/Tenant/Extensions.cs +++ /dev/null @@ -1,136 +0,0 @@ -using Finbuckle.MultiTenant; -using Finbuckle.MultiTenant.Abstractions; -using Finbuckle.MultiTenant.Stores.DistributedCacheStore; -using FSH.Framework.Core.Persistence; -using FSH.Framework.Core.Tenant.Abstractions; -using FSH.Framework.Infrastructure.Persistence; -using FSH.Framework.Infrastructure.Persistence.Services; -using FSH.Framework.Infrastructure.Tenant.Persistence; -using FSH.Framework.Infrastructure.Tenant.Services; -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Serilog; - -namespace FSH.Framework.Infrastructure.Tenant; -internal static class Extensions -{ - public static IServiceCollection ConfigureMultitenancy(this IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - services.AddTransient(); - services.BindDbContext(); - services - .AddMultiTenant(config => - { - // to save database calls to resolve tenant - // this was happening for every request earlier, leading to ineffeciency - config.Events.OnTenantResolveCompleted = async (context) => - { - if (context.MultiTenantContext.StoreInfo is null) return; - if (context.MultiTenantContext.StoreInfo.StoreType != typeof(DistributedCacheStore)) - { - var sp = ((HttpContext)context.Context!).RequestServices; - var distributedCacheStore = sp - .GetService>>()! - .FirstOrDefault(s => s.GetType() == typeof(DistributedCacheStore)); - - await distributedCacheStore!.TryAddAsync(context.MultiTenantContext.TenantInfo!); - } - await Task.FromResult(0); - }; - }) - .WithClaimStrategy(FshClaims.Tenant) - .WithHeaderStrategy(TenantConstants.Identifier) - .WithDelegateStrategy(async context => - { - if (context is not HttpContext httpContext) - return null; - if (!httpContext.Request.Query.TryGetValue("tenant", out var tenantIdentifier) || string.IsNullOrEmpty(tenantIdentifier)) - return null; - return await Task.FromResult(tenantIdentifier.ToString()); - }) - .WithDistributedCacheStore(TimeSpan.FromMinutes(60)) - .WithEFCoreStore(); - services.AddScoped(); - return services; - } - - public static WebApplication UseMultitenancy(this WebApplication app) - { - ArgumentNullException.ThrowIfNull(app); - app.UseMultiTenant(); - - // set up tenant store - var tenants = TenantStoreSetup(app); - - // set up tenant databases - app.SetupTenantDatabases(tenants); - - return app; - } - - private static IApplicationBuilder SetupTenantDatabases(this IApplicationBuilder app, IEnumerable tenants) - { - foreach (var tenant in tenants) - { - // create a scope for tenant - using var tenantScope = app.ApplicationServices.CreateScope(); - - //set current tenant so that the right connection string is used - tenantScope.ServiceProvider.GetRequiredService() - .MultiTenantContext = new MultiTenantContext() - { - TenantInfo = tenant - }; - - // using the scope, perform migrations / seeding - var initializers = tenantScope.ServiceProvider.GetServices(); - foreach (var initializer in initializers) - { - initializer.MigrateAsync(CancellationToken.None).Wait(); - initializer.SeedAsync(CancellationToken.None).Wait(); - } - } - return app; - } - - private static IEnumerable TenantStoreSetup(IApplicationBuilder app) - { - var scope = app.ApplicationServices.CreateScope(); - - // tenant master schema migration - var tenantDbContext = scope.ServiceProvider.GetRequiredService(); - if (tenantDbContext.Database.GetPendingMigrations().Any()) - { - tenantDbContext.Database.Migrate(); - Log.Information("applied database migrations for tenant module"); - } - - // default tenant seeding - if (tenantDbContext.TenantInfo.Find(TenantConstants.Root.Id) is null) - { - var rootTenant = new FshTenantInfo( - TenantConstants.Root.Id, - TenantConstants.Root.Name, - string.Empty, - TenantConstants.Root.EmailAddress); - - rootTenant.SetValidity(DateTime.UtcNow.AddYears(1)); - tenantDbContext.TenantInfo.Add(rootTenant); - tenantDbContext.SaveChanges(); - Log.Information("configured default tenant data"); - } - - // get all tenants from store - var tenantStore = scope.ServiceProvider.GetRequiredService>(); - var tenants = tenantStore.GetAllAsync().Result; - - //dispose scope - scope.Dispose(); - - return tenants; - } -} diff --git a/src/api/framework/Infrastructure/Tenant/FshTenantInfo.cs b/src/api/framework/Infrastructure/Tenant/FshTenantInfo.cs deleted file mode 100644 index 7be7d62d3e..0000000000 --- a/src/api/framework/Infrastructure/Tenant/FshTenantInfo.cs +++ /dev/null @@ -1,68 +0,0 @@ -using Finbuckle.MultiTenant.Abstractions; -using FSH.Framework.Core.Exceptions; -using FSH.Framework.Infrastructure.Tenant.Abstractions; -using FSH.Starter.Shared.Authorization; - -namespace FSH.Framework.Infrastructure.Tenant; -public sealed class FshTenantInfo : IFshTenantInfo -{ - public FshTenantInfo() - { - } - - public FshTenantInfo(string id, string name, string? connectionString, string adminEmail, string? issuer = null) - { - Id = id; - Identifier = id; - Name = name; - ConnectionString = connectionString ?? string.Empty; - AdminEmail = adminEmail; - IsActive = true; - Issuer = issuer; - - // Add Default 1 Month Validity for all new tenants. Something like a DEMO period for tenants. - ValidUpto = DateTime.UtcNow.AddMonths(1); - } - public string Id { get; set; } = default!; - public string Identifier { get; set; } = default!; - - public string Name { get; set; } = default!; - public string ConnectionString { get; set; } = default!; - - public string AdminEmail { get; set; } = default!; - public bool IsActive { get; set; } - public DateTime ValidUpto { get; set; } - public string? Issuer { get; set; } - - public void AddValidity(int months) => - ValidUpto = ValidUpto.AddMonths(months); - - public void SetValidity(in DateTime validTill) => - ValidUpto = ValidUpto < validTill - ? validTill - : throw new FshException("Subscription cannot be backdated."); - - public void Activate() - { - if (Id == TenantConstants.Root.Id) - { - throw new InvalidOperationException("Invalid Tenant"); - } - - IsActive = true; - } - - public void Deactivate() - { - if (Id == TenantConstants.Root.Id) - { - throw new InvalidOperationException("Invalid Tenant"); - } - - IsActive = false; - } - string? ITenantInfo.Id { get => Id; set => Id = value ?? throw new InvalidOperationException("Id can't be null."); } - string? ITenantInfo.Identifier { get => Identifier; set => Identifier = value ?? throw new InvalidOperationException("Identifier can't be null."); } - string? ITenantInfo.Name { get => Name; set => Name = value ?? throw new InvalidOperationException("Name can't be null."); } - string? IFshTenantInfo.ConnectionString { get => ConnectionString; set => ConnectionString = value ?? throw new InvalidOperationException("ConnectionString can't be null."); } -} diff --git a/src/api/framework/Infrastructure/Tenant/Persistence/TenantDbContext.cs b/src/api/framework/Infrastructure/Tenant/Persistence/TenantDbContext.cs deleted file mode 100644 index d778a7ce59..0000000000 --- a/src/api/framework/Infrastructure/Tenant/Persistence/TenantDbContext.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Finbuckle.MultiTenant.EntityFrameworkCore.Stores.EFCoreStore; -using Microsoft.EntityFrameworkCore; - -namespace FSH.Framework.Infrastructure.Tenant.Persistence; -public class TenantDbContext : EFCoreStoreDbContext -{ - public const string Schema = "tenant"; - public TenantDbContext(DbContextOptions options) - : base(options) - { - AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - ArgumentNullException.ThrowIfNull(modelBuilder); - - base.OnModelCreating(modelBuilder); - - modelBuilder.Entity().ToTable("Tenants", Schema); - } -} diff --git a/src/api/framework/Infrastructure/Tenant/Services/TenantService.cs b/src/api/framework/Infrastructure/Tenant/Services/TenantService.cs deleted file mode 100644 index bfc8458cf4..0000000000 --- a/src/api/framework/Infrastructure/Tenant/Services/TenantService.cs +++ /dev/null @@ -1,120 +0,0 @@ -using Finbuckle.MultiTenant; -using Finbuckle.MultiTenant.Abstractions; -using FSH.Framework.Core.Exceptions; -using FSH.Framework.Core.Persistence; -using FSH.Framework.Core.Tenant.Abstractions; -using FSH.Framework.Core.Tenant.Dtos; -using FSH.Framework.Core.Tenant.Features.CreateTenant; -using Mapster; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace FSH.Framework.Infrastructure.Tenant.Services; - -public sealed class TenantService : ITenantService -{ - private readonly IMultiTenantStore _tenantStore; - private readonly DatabaseOptions _config; - private readonly IServiceProvider _serviceProvider; - - public TenantService(IMultiTenantStore tenantStore, IOptions config, IServiceProvider serviceProvider) - { - _tenantStore = tenantStore; - _config = config.Value; - _serviceProvider = serviceProvider; - } - - public async Task ActivateAsync(string id, CancellationToken cancellationToken) - { - var tenant = await GetTenantInfoAsync(id).ConfigureAwait(false); - - if (tenant.IsActive) - { - throw new FshException($"tenant {id} is already activated"); - } - - tenant.Activate(); - - await _tenantStore.TryUpdateAsync(tenant).ConfigureAwait(false); - - return $"tenant {id} is now activated"; - } - - public async Task CreateAsync(CreateTenantCommand request, CancellationToken cancellationToken) - { - var connectionString = request.ConnectionString; - if (request.ConnectionString?.Trim() == _config.ConnectionString.Trim()) - { - connectionString = string.Empty; - } - - FshTenantInfo tenant = new(request.Id, request.Name, connectionString, request.AdminEmail, request.Issuer); - await _tenantStore.TryAddAsync(tenant).ConfigureAwait(false); - - await InitializeDatabase(tenant).ConfigureAwait(false); - - return tenant.Id; - } - - private async Task InitializeDatabase(FshTenantInfo tenant) - { - // First create a new scope - using var scope = _serviceProvider.CreateScope(); - - // Then set current tenant so the right connection string is used - scope.ServiceProvider.GetRequiredService() - .MultiTenantContext = new MultiTenantContext() - { - TenantInfo = tenant - }; - - // using the scope, perform migrations / seeding - var initializers = scope.ServiceProvider.GetServices(); - foreach (var initializer in initializers) - { - await initializer.MigrateAsync(CancellationToken.None).ConfigureAwait(false); - await initializer.SeedAsync(CancellationToken.None).ConfigureAwait(false); - } - } - - public async Task DeactivateAsync(string id) - { - var tenant = await GetTenantInfoAsync(id).ConfigureAwait(false); - if (!tenant.IsActive) - { - throw new FshException($"tenant {id} is already deactivated"); - } - - tenant.Deactivate(); - await _tenantStore.TryUpdateAsync(tenant).ConfigureAwait(false); - return $"tenant {id} is now deactivated"; - } - - public async Task ExistsWithIdAsync(string id) => - await _tenantStore.TryGetAsync(id).ConfigureAwait(false) is not null; - - public async Task ExistsWithNameAsync(string name) => - (await _tenantStore.GetAllAsync().ConfigureAwait(false)).Any(t => t.Name == name); - - public async Task> GetAllAsync() - { - var tenants = (await _tenantStore.GetAllAsync().ConfigureAwait(false)).Adapt>(); - return tenants; - } - - public async Task GetByIdAsync(string id) => - (await GetTenantInfoAsync(id).ConfigureAwait(false)) - .Adapt(); - - public async Task UpgradeSubscription(string id, DateTime extendedExpiryDate) - { - var tenant = await GetTenantInfoAsync(id).ConfigureAwait(false); - tenant.SetValidity(extendedExpiryDate); - await _tenantStore.TryUpdateAsync(tenant).ConfigureAwait(false); - return tenant.ValidUpto; - } - - private async Task GetTenantInfoAsync(string id) => - await _tenantStore.TryGetAsync(id).ConfigureAwait(false) - ?? throw new NotFoundException($"{typeof(FshTenantInfo).Name} {id} Not Found."); -} diff --git a/src/api/migrations/MSSQL/Catalog/20241123030623_Add Catalog Schema.Designer.cs b/src/api/migrations/MSSQL/Catalog/20241123030623_Add Catalog Schema.Designer.cs deleted file mode 100644 index ee52283a6f..0000000000 --- a/src/api/migrations/MSSQL/Catalog/20241123030623_Add Catalog Schema.Designer.cs +++ /dev/null @@ -1,138 +0,0 @@ -// -using System; -using FSH.Starter.WebApi.Catalog.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace FSH.Starter.WebApi.Migrations.MSSQL.Catalog -{ - [DbContext(typeof(CatalogDbContext))] - [Migration("20241123030623_Add Catalog Schema")] - partial class AddCatalogSchema - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("catalog") - .HasAnnotation("ProductVersion", "9.0.0") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("FSH.Starter.WebApi.Catalog.Domain.Brand", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); - - b.Property("Created") - .HasColumnType("datetimeoffset"); - - b.Property("CreatedBy") - .HasColumnType("uniqueidentifier"); - - b.Property("Deleted") - .HasColumnType("datetimeoffset"); - - b.Property("DeletedBy") - .HasColumnType("uniqueidentifier"); - - b.Property("Description") - .HasMaxLength(1000) - .HasColumnType("nvarchar(1000)"); - - b.Property("LastModified") - .HasColumnType("datetimeoffset"); - - b.Property("LastModifiedBy") - .HasColumnType("uniqueidentifier"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.HasKey("Id"); - - b.ToTable("Brands", "catalog"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("FSH.Starter.WebApi.Catalog.Domain.Product", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); - - b.Property("BrandId") - .HasColumnType("uniqueidentifier"); - - b.Property("Created") - .HasColumnType("datetimeoffset"); - - b.Property("CreatedBy") - .HasColumnType("uniqueidentifier"); - - b.Property("Deleted") - .HasColumnType("datetimeoffset"); - - b.Property("DeletedBy") - .HasColumnType("uniqueidentifier"); - - b.Property("Description") - .HasMaxLength(1000) - .HasColumnType("nvarchar(1000)"); - - b.Property("LastModified") - .HasColumnType("datetimeoffset"); - - b.Property("LastModifiedBy") - .HasColumnType("uniqueidentifier"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Price") - .HasColumnType("decimal(18,2)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.HasKey("Id"); - - b.HasIndex("BrandId"); - - b.ToTable("Products", "catalog"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("FSH.Starter.WebApi.Catalog.Domain.Product", b => - { - b.HasOne("FSH.Starter.WebApi.Catalog.Domain.Brand", "Brand") - .WithMany() - .HasForeignKey("BrandId"); - - b.Navigation("Brand"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/api/migrations/MSSQL/Catalog/20241123030623_Add Catalog Schema.cs b/src/api/migrations/MSSQL/Catalog/20241123030623_Add Catalog Schema.cs deleted file mode 100644 index 419d491abb..0000000000 --- a/src/api/migrations/MSSQL/Catalog/20241123030623_Add Catalog Schema.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace FSH.Starter.WebApi.Migrations.MSSQL.Catalog -{ - /// - public partial class AddCatalogSchema : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.EnsureSchema( - name: "catalog"); - - migrationBuilder.CreateTable( - name: "Brands", - schema: "catalog", - columns: table => new - { - Id = table.Column(type: "uniqueidentifier", nullable: false), - Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), - Description = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: true), - TenantId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), - Created = table.Column(type: "datetimeoffset", nullable: false), - CreatedBy = table.Column(type: "uniqueidentifier", nullable: false), - LastModified = table.Column(type: "datetimeoffset", nullable: false), - LastModifiedBy = table.Column(type: "uniqueidentifier", nullable: true), - Deleted = table.Column(type: "datetimeoffset", nullable: true), - DeletedBy = table.Column(type: "uniqueidentifier", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Brands", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Products", - schema: "catalog", - columns: table => new - { - Id = table.Column(type: "uniqueidentifier", nullable: false), - Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), - Description = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: true), - Price = table.Column(type: "decimal(18,2)", nullable: false), - BrandId = table.Column(type: "uniqueidentifier", nullable: true), - TenantId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), - Created = table.Column(type: "datetimeoffset", nullable: false), - CreatedBy = table.Column(type: "uniqueidentifier", nullable: false), - LastModified = table.Column(type: "datetimeoffset", nullable: false), - LastModifiedBy = table.Column(type: "uniqueidentifier", nullable: true), - Deleted = table.Column(type: "datetimeoffset", nullable: true), - DeletedBy = table.Column(type: "uniqueidentifier", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Products", x => x.Id); - table.ForeignKey( - name: "FK_Products_Brands_BrandId", - column: x => x.BrandId, - principalSchema: "catalog", - principalTable: "Brands", - principalColumn: "Id"); - }); - - migrationBuilder.CreateIndex( - name: "IX_Products_BrandId", - schema: "catalog", - table: "Products", - column: "BrandId"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Products", - schema: "catalog"); - - migrationBuilder.DropTable( - name: "Brands", - schema: "catalog"); - } - } -} diff --git a/src/api/migrations/MSSQL/Catalog/CatalogDbContextModelSnapshot.cs b/src/api/migrations/MSSQL/Catalog/CatalogDbContextModelSnapshot.cs deleted file mode 100644 index df0564c347..0000000000 --- a/src/api/migrations/MSSQL/Catalog/CatalogDbContextModelSnapshot.cs +++ /dev/null @@ -1,135 +0,0 @@ -// -using System; -using FSH.Starter.WebApi.Catalog.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace FSH.Starter.WebApi.Migrations.MSSQL.Catalog -{ - [DbContext(typeof(CatalogDbContext))] - partial class CatalogDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("catalog") - .HasAnnotation("ProductVersion", "9.0.0") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("FSH.Starter.WebApi.Catalog.Domain.Brand", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); - - b.Property("Created") - .HasColumnType("datetimeoffset"); - - b.Property("CreatedBy") - .HasColumnType("uniqueidentifier"); - - b.Property("Deleted") - .HasColumnType("datetimeoffset"); - - b.Property("DeletedBy") - .HasColumnType("uniqueidentifier"); - - b.Property("Description") - .HasMaxLength(1000) - .HasColumnType("nvarchar(1000)"); - - b.Property("LastModified") - .HasColumnType("datetimeoffset"); - - b.Property("LastModifiedBy") - .HasColumnType("uniqueidentifier"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.HasKey("Id"); - - b.ToTable("Brands", "catalog"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("FSH.Starter.WebApi.Catalog.Domain.Product", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); - - b.Property("BrandId") - .HasColumnType("uniqueidentifier"); - - b.Property("Created") - .HasColumnType("datetimeoffset"); - - b.Property("CreatedBy") - .HasColumnType("uniqueidentifier"); - - b.Property("Deleted") - .HasColumnType("datetimeoffset"); - - b.Property("DeletedBy") - .HasColumnType("uniqueidentifier"); - - b.Property("Description") - .HasMaxLength(1000) - .HasColumnType("nvarchar(1000)"); - - b.Property("LastModified") - .HasColumnType("datetimeoffset"); - - b.Property("LastModifiedBy") - .HasColumnType("uniqueidentifier"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Price") - .HasColumnType("decimal(18,2)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.HasKey("Id"); - - b.HasIndex("BrandId"); - - b.ToTable("Products", "catalog"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("FSH.Starter.WebApi.Catalog.Domain.Product", b => - { - b.HasOne("FSH.Starter.WebApi.Catalog.Domain.Brand", "Brand") - .WithMany() - .HasForeignKey("BrandId"); - - b.Navigation("Brand"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/api/migrations/MSSQL/Identity/20241123030737_Add Identity Schema.Designer.cs b/src/api/migrations/MSSQL/Identity/20241123030737_Add Identity Schema.Designer.cs deleted file mode 100644 index 083e64b009..0000000000 --- a/src/api/migrations/MSSQL/Identity/20241123030737_Add Identity Schema.Designer.cs +++ /dev/null @@ -1,401 +0,0 @@ -// -using System; -using FSH.Framework.Infrastructure.Identity.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace FSH.Starter.WebApi.Migrations.MSSQL.Identity -{ - [DbContext(typeof(IdentityDbContext))] - [Migration("20241123030737_Add Identity Schema")] - partial class AddIdentitySchema - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.0") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("FSH.Framework.Core.Audit.AuditTrail", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); - - b.Property("DateTime") - .HasColumnType("datetimeoffset"); - - b.Property("Entity") - .HasColumnType("nvarchar(max)"); - - b.Property("ModifiedProperties") - .HasColumnType("nvarchar(max)"); - - b.Property("NewValues") - .HasColumnType("nvarchar(max)"); - - b.Property("Operation") - .HasColumnType("nvarchar(max)"); - - b.Property("PreviousValues") - .HasColumnType("nvarchar(max)"); - - b.Property("PrimaryKey") - .HasColumnType("nvarchar(max)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("UserId") - .HasColumnType("uniqueidentifier"); - - b.HasKey("Id"); - - b.ToTable("AuditTrails", "identity"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("FSH.Framework.Infrastructure.Identity.RoleClaims.FshRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedBy") - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedOn") - .HasColumnType("datetimeoffset"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("RoleClaims", "identity"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("FSH.Framework.Infrastructure.Identity.Roles.FshRole", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("Description") - .HasColumnType("nvarchar(max)"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName", "TenantId") - .IsUnique() - .HasDatabaseName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); - - b.ToTable("Roles", "identity"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("FSH.Framework.Infrastructure.Identity.Users.FshUser", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("AccessFailedCount") - .HasColumnType("int"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("bit"); - - b.Property("FirstName") - .HasColumnType("nvarchar(max)"); - - b.Property("ImageUrl") - .HasColumnType("nvarchar(max)"); - - b.Property("IsActive") - .HasColumnType("bit"); - - b.Property("LastName") - .HasColumnType("nvarchar(max)"); - - b.Property("LockoutEnabled") - .HasColumnType("bit"); - - b.Property("LockoutEnd") - .HasColumnType("datetimeoffset"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("ObjectId") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("bit"); - - b.Property("RefreshToken") - .HasColumnType("nvarchar(max)"); - - b.Property("RefreshTokenExpiryTime") - .HasColumnType("datetime2"); - - b.Property("SecurityStamp") - .HasColumnType("nvarchar(max)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("TwoFactorEnabled") - .HasColumnType("bit"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); - - b.ToTable("Users", "identity"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - 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("nvarchar(450)"); - - b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderDisplayName") - .HasColumnType("nvarchar(max)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - 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("nvarchar(450)"); - - b.Property("RoleId") - .HasColumnType("nvarchar(450)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - 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("nvarchar(450)"); - - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("Name") - .HasColumnType("nvarchar(450)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("UserTokens", "identity"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("FSH.Framework.Infrastructure.Identity.RoleClaims.FshRoleClaim", b => - { - b.HasOne("FSH.Framework.Infrastructure.Identity.Roles.FshRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("FSH.Framework.Infrastructure.Identity.Users.FshUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("FSH.Framework.Infrastructure.Identity.Users.FshUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("FSH.Framework.Infrastructure.Identity.Roles.FshRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("FSH.Framework.Infrastructure.Identity.Users.FshUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("FSH.Framework.Infrastructure.Identity.Users.FshUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/api/migrations/MSSQL/Identity/20241123030737_Add Identity Schema.cs b/src/api/migrations/MSSQL/Identity/20241123030737_Add Identity Schema.cs deleted file mode 100644 index c51ef44a6c..0000000000 --- a/src/api/migrations/MSSQL/Identity/20241123030737_Add Identity Schema.cs +++ /dev/null @@ -1,296 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace FSH.Starter.WebApi.Migrations.MSSQL.Identity -{ - /// - public partial class AddIdentitySchema : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.EnsureSchema( - name: "identity"); - - migrationBuilder.CreateTable( - name: "AuditTrails", - schema: "identity", - columns: table => new - { - Id = table.Column(type: "uniqueidentifier", nullable: false), - UserId = table.Column(type: "uniqueidentifier", nullable: false), - Operation = table.Column(type: "nvarchar(max)", nullable: true), - Entity = table.Column(type: "nvarchar(max)", nullable: true), - DateTime = table.Column(type: "datetimeoffset", nullable: false), - PreviousValues = table.Column(type: "nvarchar(max)", nullable: true), - NewValues = table.Column(type: "nvarchar(max)", nullable: true), - ModifiedProperties = table.Column(type: "nvarchar(max)", nullable: true), - PrimaryKey = table.Column(type: "nvarchar(max)", nullable: true), - TenantId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AuditTrails", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Roles", - schema: "identity", - columns: table => new - { - Id = table.Column(type: "nvarchar(450)", nullable: false), - Description = table.Column(type: "nvarchar(max)", nullable: true), - TenantId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), - Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Roles", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Users", - schema: "identity", - columns: table => new - { - Id = table.Column(type: "nvarchar(450)", nullable: false), - FirstName = table.Column(type: "nvarchar(max)", nullable: true), - LastName = table.Column(type: "nvarchar(max)", nullable: true), - ImageUrl = table.Column(type: "nvarchar(max)", nullable: true), - IsActive = table.Column(type: "bit", nullable: false), - RefreshToken = table.Column(type: "nvarchar(max)", nullable: true), - RefreshTokenExpiryTime = table.Column(type: "datetime2", nullable: false), - ObjectId = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - TenantId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), - UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - EmailConfirmed = table.Column(type: "bit", nullable: false), - PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), - SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), - ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), - PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true), - PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), - TwoFactorEnabled = table.Column(type: "bit", nullable: false), - LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), - LockoutEnabled = table.Column(type: "bit", nullable: false), - AccessFailedCount = table.Column(type: "int", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Users", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "RoleClaims", - schema: "identity", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), - CreatedOn = table.Column(type: "datetimeoffset", nullable: false), - TenantId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), - RoleId = table.Column(type: "nvarchar(450)", nullable: false), - ClaimType = table.Column(type: "nvarchar(max)", nullable: true), - ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_RoleClaims", x => x.Id); - table.ForeignKey( - name: "FK_RoleClaims_Roles_RoleId", - column: x => x.RoleId, - principalSchema: "identity", - principalTable: "Roles", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "UserClaims", - schema: "identity", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - UserId = table.Column(type: "nvarchar(450)", nullable: false), - ClaimType = table.Column(type: "nvarchar(max)", nullable: true), - ClaimValue = table.Column(type: "nvarchar(max)", nullable: true), - TenantId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_UserClaims", x => x.Id); - table.ForeignKey( - name: "FK_UserClaims_Users_UserId", - column: x => x.UserId, - principalSchema: "identity", - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "UserLogins", - schema: "identity", - columns: table => new - { - LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), - ProviderKey = table.Column(type: "nvarchar(450)", nullable: false), - ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), - UserId = table.Column(type: "nvarchar(450)", nullable: false), - TenantId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_UserLogins", x => new { x.LoginProvider, x.ProviderKey }); - table.ForeignKey( - name: "FK_UserLogins_Users_UserId", - column: x => x.UserId, - principalSchema: "identity", - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "UserRoles", - schema: "identity", - columns: table => new - { - UserId = table.Column(type: "nvarchar(450)", nullable: false), - RoleId = table.Column(type: "nvarchar(450)", nullable: false), - TenantId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_UserRoles", x => new { x.UserId, x.RoleId }); - table.ForeignKey( - name: "FK_UserRoles_Roles_RoleId", - column: x => x.RoleId, - principalSchema: "identity", - principalTable: "Roles", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_UserRoles_Users_UserId", - column: x => x.UserId, - principalSchema: "identity", - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "UserTokens", - schema: "identity", - columns: table => new - { - UserId = table.Column(type: "nvarchar(450)", nullable: false), - LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), - Name = table.Column(type: "nvarchar(450)", nullable: false), - Value = table.Column(type: "nvarchar(max)", nullable: true), - TenantId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_UserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); - table.ForeignKey( - name: "FK_UserTokens_Users_UserId", - column: x => x.UserId, - principalSchema: "identity", - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_RoleClaims_RoleId", - schema: "identity", - table: "RoleClaims", - column: "RoleId"); - - migrationBuilder.CreateIndex( - name: "RoleNameIndex", - schema: "identity", - table: "Roles", - columns: new[] { "NormalizedName", "TenantId" }, - unique: true, - filter: "[NormalizedName] IS NOT NULL"); - - migrationBuilder.CreateIndex( - name: "IX_UserClaims_UserId", - schema: "identity", - table: "UserClaims", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_UserLogins_UserId", - schema: "identity", - table: "UserLogins", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_UserRoles_RoleId", - schema: "identity", - table: "UserRoles", - column: "RoleId"); - - migrationBuilder.CreateIndex( - name: "EmailIndex", - schema: "identity", - table: "Users", - column: "NormalizedEmail"); - - migrationBuilder.CreateIndex( - name: "UserNameIndex", - schema: "identity", - table: "Users", - column: "NormalizedUserName", - unique: true, - filter: "[NormalizedUserName] IS NOT NULL"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "AuditTrails", - schema: "identity"); - - migrationBuilder.DropTable( - name: "RoleClaims", - schema: "identity"); - - migrationBuilder.DropTable( - name: "UserClaims", - schema: "identity"); - - migrationBuilder.DropTable( - name: "UserLogins", - schema: "identity"); - - migrationBuilder.DropTable( - name: "UserRoles", - schema: "identity"); - - migrationBuilder.DropTable( - name: "UserTokens", - schema: "identity"); - - migrationBuilder.DropTable( - name: "Roles", - schema: "identity"); - - migrationBuilder.DropTable( - name: "Users", - schema: "identity"); - } - } -} diff --git a/src/api/migrations/MSSQL/Identity/IdentityDbContextModelSnapshot.cs b/src/api/migrations/MSSQL/Identity/IdentityDbContextModelSnapshot.cs deleted file mode 100644 index b14e13f498..0000000000 --- a/src/api/migrations/MSSQL/Identity/IdentityDbContextModelSnapshot.cs +++ /dev/null @@ -1,398 +0,0 @@ -// -using System; -using FSH.Framework.Infrastructure.Identity.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace FSH.Starter.WebApi.Migrations.MSSQL.Identity -{ - [DbContext(typeof(IdentityDbContext))] - partial class IdentityDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.0") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("FSH.Framework.Core.Audit.AuditTrail", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); - - b.Property("DateTime") - .HasColumnType("datetimeoffset"); - - b.Property("Entity") - .HasColumnType("nvarchar(max)"); - - b.Property("ModifiedProperties") - .HasColumnType("nvarchar(max)"); - - b.Property("NewValues") - .HasColumnType("nvarchar(max)"); - - b.Property("Operation") - .HasColumnType("nvarchar(max)"); - - b.Property("PreviousValues") - .HasColumnType("nvarchar(max)"); - - b.Property("PrimaryKey") - .HasColumnType("nvarchar(max)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("UserId") - .HasColumnType("uniqueidentifier"); - - b.HasKey("Id"); - - b.ToTable("AuditTrails", "identity"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("FSH.Framework.Infrastructure.Identity.RoleClaims.FshRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedBy") - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedOn") - .HasColumnType("datetimeoffset"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("RoleClaims", "identity"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("FSH.Framework.Infrastructure.Identity.Roles.FshRole", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("Description") - .HasColumnType("nvarchar(max)"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName", "TenantId") - .IsUnique() - .HasDatabaseName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); - - b.ToTable("Roles", "identity"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("FSH.Framework.Infrastructure.Identity.Users.FshUser", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("AccessFailedCount") - .HasColumnType("int"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("bit"); - - b.Property("FirstName") - .HasColumnType("nvarchar(max)"); - - b.Property("ImageUrl") - .HasColumnType("nvarchar(max)"); - - b.Property("IsActive") - .HasColumnType("bit"); - - b.Property("LastName") - .HasColumnType("nvarchar(max)"); - - b.Property("LockoutEnabled") - .HasColumnType("bit"); - - b.Property("LockoutEnd") - .HasColumnType("datetimeoffset"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("ObjectId") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("bit"); - - b.Property("RefreshToken") - .HasColumnType("nvarchar(max)"); - - b.Property("RefreshTokenExpiryTime") - .HasColumnType("datetime2"); - - b.Property("SecurityStamp") - .HasColumnType("nvarchar(max)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("TwoFactorEnabled") - .HasColumnType("bit"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); - - b.ToTable("Users", "identity"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - 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("nvarchar(450)"); - - b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderDisplayName") - .HasColumnType("nvarchar(max)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - 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("nvarchar(450)"); - - b.Property("RoleId") - .HasColumnType("nvarchar(450)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - 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("nvarchar(450)"); - - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("Name") - .HasColumnType("nvarchar(450)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("UserTokens", "identity"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("FSH.Framework.Infrastructure.Identity.RoleClaims.FshRoleClaim", b => - { - b.HasOne("FSH.Framework.Infrastructure.Identity.Roles.FshRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("FSH.Framework.Infrastructure.Identity.Users.FshUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("FSH.Framework.Infrastructure.Identity.Users.FshUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("FSH.Framework.Infrastructure.Identity.Roles.FshRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("FSH.Framework.Infrastructure.Identity.Users.FshUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("FSH.Framework.Infrastructure.Identity.Users.FshUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/api/migrations/MSSQL/MSSQL.csproj b/src/api/migrations/MSSQL/MSSQL.csproj deleted file mode 100644 index adb542a166..0000000000 --- a/src/api/migrations/MSSQL/MSSQL.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - FSH.Starter.WebApi.Migrations.MSSQL - FSH.Starter.WebApi.Migrations.MSSQL - - - - - - - - - - - - - diff --git a/src/api/migrations/MSSQL/Tenant/20241123030647_Add Tenant Schema.Designer.cs b/src/api/migrations/MSSQL/Tenant/20241123030647_Add Tenant Schema.Designer.cs deleted file mode 100644 index 6c649f26d5..0000000000 --- a/src/api/migrations/MSSQL/Tenant/20241123030647_Add Tenant Schema.Designer.cs +++ /dev/null @@ -1,69 +0,0 @@ -// -using System; -using FSH.Framework.Infrastructure.Tenant.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace FSH.Starter.WebApi.Migrations.MSSQL.Tenant -{ - [DbContext(typeof(TenantDbContext))] - [Migration("20241123030647_Add Tenant Schema")] - partial class AddTenantSchema - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.0") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("FSH.Framework.Infrastructure.Tenant.FshTenantInfo", b => - { - b.Property("Id") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("AdminEmail") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ConnectionString") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Identifier") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.Property("IsActive") - .HasColumnType("bit"); - - b.Property("Issuer") - .HasColumnType("nvarchar(max)"); - - b.Property("Name") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ValidUpto") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("Identifier") - .IsUnique(); - - b.ToTable("Tenants", "tenant"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/api/migrations/MSSQL/Tenant/20241123030647_Add Tenant Schema.cs b/src/api/migrations/MSSQL/Tenant/20241123030647_Add Tenant Schema.cs deleted file mode 100644 index dda5acf677..0000000000 --- a/src/api/migrations/MSSQL/Tenant/20241123030647_Add Tenant Schema.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace FSH.Starter.WebApi.Migrations.MSSQL.Tenant -{ - /// - public partial class AddTenantSchema : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.EnsureSchema( - name: "tenant"); - - migrationBuilder.CreateTable( - name: "Tenants", - schema: "tenant", - columns: table => new - { - Id = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), - Identifier = table.Column(type: "nvarchar(450)", nullable: false), - Name = table.Column(type: "nvarchar(max)", nullable: false), - ConnectionString = table.Column(type: "nvarchar(max)", nullable: false), - AdminEmail = table.Column(type: "nvarchar(max)", nullable: false), - IsActive = table.Column(type: "bit", nullable: false), - ValidUpto = table.Column(type: "datetime2", nullable: false), - Issuer = table.Column(type: "nvarchar(max)", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Tenants", x => x.Id); - }); - - migrationBuilder.CreateIndex( - name: "IX_Tenants_Identifier", - schema: "tenant", - table: "Tenants", - column: "Identifier", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Tenants", - schema: "tenant"); - } - } -} diff --git a/src/api/migrations/MSSQL/Tenant/TenantDbContextModelSnapshot.cs b/src/api/migrations/MSSQL/Tenant/TenantDbContextModelSnapshot.cs deleted file mode 100644 index 288a117987..0000000000 --- a/src/api/migrations/MSSQL/Tenant/TenantDbContextModelSnapshot.cs +++ /dev/null @@ -1,66 +0,0 @@ -// -using System; -using FSH.Framework.Infrastructure.Tenant.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace FSH.Starter.WebApi.Migrations.MSSQL.Tenant -{ - [DbContext(typeof(TenantDbContext))] - partial class TenantDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.0") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("FSH.Framework.Infrastructure.Tenant.FshTenantInfo", b => - { - b.Property("Id") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("AdminEmail") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ConnectionString") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Identifier") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.Property("IsActive") - .HasColumnType("bit"); - - b.Property("Issuer") - .HasColumnType("nvarchar(max)"); - - b.Property("Name") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ValidUpto") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("Identifier") - .IsUnique(); - - b.ToTable("Tenants", "tenant"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/api/migrations/MSSQL/Todo/20241123030700_Add Todo Schema.Designer.cs b/src/api/migrations/MSSQL/Todo/20241123030700_Add Todo Schema.Designer.cs deleted file mode 100644 index 505dd92c42..0000000000 --- a/src/api/migrations/MSSQL/Todo/20241123030700_Add Todo Schema.Designer.cs +++ /dev/null @@ -1,75 +0,0 @@ -// -using System; -using FSH.Starter.WebApi.Todo.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace FSH.Starter.WebApi.Migrations.MSSQL.Todo -{ - [DbContext(typeof(TodoDbContext))] - [Migration("20241123030700_Add Todo Schema")] - partial class AddTodoSchema - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("todo") - .HasAnnotation("ProductVersion", "9.0.0") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("FSH.Starter.WebApi.Todo.Domain.TodoItem", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); - - b.Property("Created") - .HasColumnType("datetimeoffset"); - - b.Property("CreatedBy") - .HasColumnType("uniqueidentifier"); - - b.Property("Deleted") - .HasColumnType("datetimeoffset"); - - b.Property("DeletedBy") - .HasColumnType("uniqueidentifier"); - - b.Property("LastModified") - .HasColumnType("datetimeoffset"); - - b.Property("LastModifiedBy") - .HasColumnType("uniqueidentifier"); - - b.Property("Note") - .HasMaxLength(1000) - .HasColumnType("nvarchar(1000)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Title") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.ToTable("Todos", "todo"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/api/migrations/MSSQL/Todo/20241123030700_Add Todo Schema.cs b/src/api/migrations/MSSQL/Todo/20241123030700_Add Todo Schema.cs deleted file mode 100644 index 0f68d6be73..0000000000 --- a/src/api/migrations/MSSQL/Todo/20241123030700_Add Todo Schema.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace FSH.Starter.WebApi.Migrations.MSSQL.Todo -{ - /// - public partial class AddTodoSchema : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.EnsureSchema( - name: "todo"); - - migrationBuilder.CreateTable( - name: "Todos", - schema: "todo", - columns: table => new - { - Id = table.Column(type: "uniqueidentifier", nullable: false), - Title = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), - Note = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: true), - TenantId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), - Created = table.Column(type: "datetimeoffset", nullable: false), - CreatedBy = table.Column(type: "uniqueidentifier", nullable: false), - LastModified = table.Column(type: "datetimeoffset", nullable: false), - LastModifiedBy = table.Column(type: "uniqueidentifier", nullable: true), - Deleted = table.Column(type: "datetimeoffset", nullable: true), - DeletedBy = table.Column(type: "uniqueidentifier", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Todos", x => x.Id); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Todos", - schema: "todo"); - } - } -} diff --git a/src/api/migrations/MSSQL/Todo/TodoDbContextModelSnapshot.cs b/src/api/migrations/MSSQL/Todo/TodoDbContextModelSnapshot.cs deleted file mode 100644 index 59a159b6b6..0000000000 --- a/src/api/migrations/MSSQL/Todo/TodoDbContextModelSnapshot.cs +++ /dev/null @@ -1,72 +0,0 @@ -// -using System; -using FSH.Starter.WebApi.Todo.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace FSH.Starter.WebApi.Migrations.MSSQL.Todo -{ - [DbContext(typeof(TodoDbContext))] - partial class TodoDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("todo") - .HasAnnotation("ProductVersion", "9.0.0") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("FSH.Starter.WebApi.Todo.Domain.TodoItem", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); - - b.Property("Created") - .HasColumnType("datetimeoffset"); - - b.Property("CreatedBy") - .HasColumnType("uniqueidentifier"); - - b.Property("Deleted") - .HasColumnType("datetimeoffset"); - - b.Property("DeletedBy") - .HasColumnType("uniqueidentifier"); - - b.Property("LastModified") - .HasColumnType("datetimeoffset"); - - b.Property("LastModifiedBy") - .HasColumnType("uniqueidentifier"); - - b.Property("Note") - .HasMaxLength(1000) - .HasColumnType("nvarchar(1000)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Title") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.ToTable("Todos", "todo"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/api/migrations/PostgreSQL/Catalog/20241123024839_Add Catalog Schema.Designer.cs b/src/api/migrations/PostgreSQL/Catalog/20241123024839_Add Catalog Schema.Designer.cs deleted file mode 100644 index 58b695929c..0000000000 --- a/src/api/migrations/PostgreSQL/Catalog/20241123024839_Add Catalog Schema.Designer.cs +++ /dev/null @@ -1,138 +0,0 @@ -// -using System; -using FSH.Starter.WebApi.Catalog.Infrastructure.Persistence; -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.WebApi.Migrations.PostgreSQL.Catalog -{ - [DbContext(typeof(CatalogDbContext))] - [Migration("20241123024839_Add Catalog Schema")] - partial class AddCatalogSchema - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("catalog") - .HasAnnotation("ProductVersion", "9.0.0") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("FSH.Starter.WebApi.Catalog.Domain.Brand", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Created") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("uuid"); - - b.Property("Deleted") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Description") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("LastModified") - .HasColumnType("timestamp with time zone"); - - b.Property("LastModifiedBy") - .HasColumnType("uuid"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.HasKey("Id"); - - b.ToTable("Brands", "catalog"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("FSH.Starter.WebApi.Catalog.Domain.Product", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("BrandId") - .HasColumnType("uuid"); - - b.Property("Created") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("uuid"); - - b.Property("Deleted") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Description") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("LastModified") - .HasColumnType("timestamp with time zone"); - - b.Property("LastModifiedBy") - .HasColumnType("uuid"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Price") - .HasColumnType("numeric"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.HasKey("Id"); - - b.HasIndex("BrandId"); - - b.ToTable("Products", "catalog"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("FSH.Starter.WebApi.Catalog.Domain.Product", b => - { - b.HasOne("FSH.Starter.WebApi.Catalog.Domain.Brand", "Brand") - .WithMany() - .HasForeignKey("BrandId"); - - b.Navigation("Brand"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/api/migrations/PostgreSQL/Catalog/20241123024839_Add Catalog Schema.cs b/src/api/migrations/PostgreSQL/Catalog/20241123024839_Add Catalog Schema.cs deleted file mode 100644 index e31911ac56..0000000000 --- a/src/api/migrations/PostgreSQL/Catalog/20241123024839_Add Catalog Schema.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace FSH.Starter.WebApi.Migrations.PostgreSQL.Catalog -{ - /// - public partial class AddCatalogSchema : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.EnsureSchema( - name: "catalog"); - - migrationBuilder.CreateTable( - name: "Brands", - schema: "catalog", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), - Description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), - TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), - Created = table.Column(type: "timestamp with time zone", nullable: false), - CreatedBy = table.Column(type: "uuid", nullable: false), - LastModified = table.Column(type: "timestamp with time zone", nullable: false), - LastModifiedBy = table.Column(type: "uuid", nullable: true), - Deleted = table.Column(type: "timestamp with time zone", nullable: true), - DeletedBy = table.Column(type: "uuid", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Brands", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Products", - schema: "catalog", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), - Description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), - Price = table.Column(type: "numeric", nullable: false), - BrandId = table.Column(type: "uuid", nullable: true), - TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), - Created = table.Column(type: "timestamp with time zone", nullable: false), - CreatedBy = table.Column(type: "uuid", nullable: false), - LastModified = table.Column(type: "timestamp with time zone", nullable: false), - LastModifiedBy = table.Column(type: "uuid", nullable: true), - Deleted = table.Column(type: "timestamp with time zone", nullable: true), - DeletedBy = table.Column(type: "uuid", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Products", x => x.Id); - table.ForeignKey( - name: "FK_Products_Brands_BrandId", - column: x => x.BrandId, - principalSchema: "catalog", - principalTable: "Brands", - principalColumn: "Id"); - }); - - migrationBuilder.CreateIndex( - name: "IX_Products_BrandId", - schema: "catalog", - table: "Products", - column: "BrandId"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Products", - schema: "catalog"); - - migrationBuilder.DropTable( - name: "Brands", - schema: "catalog"); - } - } -} diff --git a/src/api/migrations/PostgreSQL/Catalog/CatalogDbContextModelSnapshot.cs b/src/api/migrations/PostgreSQL/Catalog/CatalogDbContextModelSnapshot.cs deleted file mode 100644 index 66ec756fd7..0000000000 --- a/src/api/migrations/PostgreSQL/Catalog/CatalogDbContextModelSnapshot.cs +++ /dev/null @@ -1,135 +0,0 @@ -// -using System; -using FSH.Starter.WebApi.Catalog.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace FSH.Starter.WebApi.Migrations.PostgreSQL.Catalog -{ - [DbContext(typeof(CatalogDbContext))] - partial class CatalogDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("catalog") - .HasAnnotation("ProductVersion", "9.0.0") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("FSH.Starter.WebApi.Catalog.Domain.Brand", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Created") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("uuid"); - - b.Property("Deleted") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Description") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("LastModified") - .HasColumnType("timestamp with time zone"); - - b.Property("LastModifiedBy") - .HasColumnType("uuid"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.HasKey("Id"); - - b.ToTable("Brands", "catalog"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("FSH.Starter.WebApi.Catalog.Domain.Product", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("BrandId") - .HasColumnType("uuid"); - - b.Property("Created") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("uuid"); - - b.Property("Deleted") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("Description") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("LastModified") - .HasColumnType("timestamp with time zone"); - - b.Property("LastModifiedBy") - .HasColumnType("uuid"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Price") - .HasColumnType("numeric"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.HasKey("Id"); - - b.HasIndex("BrandId"); - - b.ToTable("Products", "catalog"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("FSH.Starter.WebApi.Catalog.Domain.Product", b => - { - b.HasOne("FSH.Starter.WebApi.Catalog.Domain.Brand", "Brand") - .WithMany() - .HasForeignKey("BrandId"); - - b.Navigation("Brand"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/api/migrations/PostgreSQL/Identity/20241123024818_Add Identity Schema.Designer.cs b/src/api/migrations/PostgreSQL/Identity/20241123024818_Add Identity Schema.Designer.cs deleted file mode 100644 index 6163907fac..0000000000 --- a/src/api/migrations/PostgreSQL/Identity/20241123024818_Add Identity Schema.Designer.cs +++ /dev/null @@ -1,399 +0,0 @@ -// -using System; -using FSH.Framework.Infrastructure.Identity.Persistence; -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.WebApi.Migrations.PostgreSQL.Identity -{ - [DbContext(typeof(IdentityDbContext))] - [Migration("20241123024818_Add Identity Schema")] - partial class AddIdentitySchema - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.0") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("FSH.Framework.Core.Audit.AuditTrail", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DateTime") - .HasColumnType("timestamp with time zone"); - - b.Property("Entity") - .HasColumnType("text"); - - b.Property("ModifiedProperties") - .HasColumnType("text"); - - b.Property("NewValues") - .HasColumnType("text"); - - b.Property("Operation") - .HasColumnType("text"); - - b.Property("PreviousValues") - .HasColumnType("text"); - - b.Property("PrimaryKey") - .HasColumnType("text"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.ToTable("AuditTrails", "identity"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("FSH.Framework.Infrastructure.Identity.RoleClaims.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() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("RoleClaims", "identity"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("FSH.Framework.Infrastructure.Identity.Roles.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() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName", "TenantId") - .IsUnique() - .HasDatabaseName("RoleNameIndex"); - - b.ToTable("Roles", "identity"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("FSH.Framework.Infrastructure.Identity.Users.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("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() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - 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") - .IsUnique() - .HasDatabaseName("UserNameIndex"); - - b.ToTable("Users", "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() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - 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() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - 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() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - 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() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("Value") - .HasColumnType("text"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("UserTokens", "identity"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("FSH.Framework.Infrastructure.Identity.RoleClaims.FshRoleClaim", b => - { - b.HasOne("FSH.Framework.Infrastructure.Identity.Roles.FshRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("FSH.Framework.Infrastructure.Identity.Users.FshUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("FSH.Framework.Infrastructure.Identity.Users.FshUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("FSH.Framework.Infrastructure.Identity.Roles.FshRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("FSH.Framework.Infrastructure.Identity.Users.FshUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("FSH.Framework.Infrastructure.Identity.Users.FshUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/api/migrations/PostgreSQL/Identity/20241123024818_Add Identity Schema.cs b/src/api/migrations/PostgreSQL/Identity/20241123024818_Add Identity Schema.cs deleted file mode 100644 index 6487136614..0000000000 --- a/src/api/migrations/PostgreSQL/Identity/20241123024818_Add Identity Schema.cs +++ /dev/null @@ -1,295 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace FSH.Starter.WebApi.Migrations.PostgreSQL.Identity -{ - /// - public partial class AddIdentitySchema : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.EnsureSchema( - name: "identity"); - - migrationBuilder.CreateTable( - name: "AuditTrails", - schema: "identity", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - UserId = table.Column(type: "uuid", nullable: false), - Operation = table.Column(type: "text", nullable: true), - Entity = table.Column(type: "text", nullable: true), - DateTime = table.Column(type: "timestamp with time zone", nullable: false), - PreviousValues = table.Column(type: "text", nullable: true), - NewValues = table.Column(type: "text", nullable: true), - ModifiedProperties = table.Column(type: "text", nullable: true), - PrimaryKey = table.Column(type: "text", nullable: true), - TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AuditTrails", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Roles", - schema: "identity", - columns: table => new - { - Id = table.Column(type: "text", nullable: false), - Description = table.Column(type: "text", nullable: true), - TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), - Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - NormalizedName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - ConcurrencyStamp = table.Column(type: "text", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Roles", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Users", - schema: "identity", - columns: table => new - { - Id = table.Column(type: "text", nullable: false), - FirstName = table.Column(type: "text", nullable: true), - LastName = table.Column(type: "text", nullable: true), - ImageUrl = table.Column(type: "text", nullable: true), - IsActive = table.Column(type: "boolean", nullable: false), - RefreshToken = table.Column(type: "text", nullable: true), - RefreshTokenExpiryTime = table.Column(type: "timestamp with time zone", nullable: false), - ObjectId = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), - UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - NormalizedEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - EmailConfirmed = table.Column(type: "boolean", nullable: false), - PasswordHash = table.Column(type: "text", nullable: true), - SecurityStamp = table.Column(type: "text", nullable: true), - ConcurrencyStamp = table.Column(type: "text", nullable: true), - PhoneNumber = table.Column(type: "text", nullable: true), - PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), - TwoFactorEnabled = table.Column(type: "boolean", nullable: false), - LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), - LockoutEnabled = table.Column(type: "boolean", nullable: false), - AccessFailedCount = table.Column(type: "integer", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Users", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "RoleClaims", - schema: "identity", - columns: table => new - { - Id = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - CreatedBy = table.Column(type: "text", nullable: true), - CreatedOn = table.Column(type: "timestamp with time zone", nullable: false), - TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), - RoleId = table.Column(type: "text", nullable: false), - ClaimType = table.Column(type: "text", nullable: true), - ClaimValue = table.Column(type: "text", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_RoleClaims", x => x.Id); - table.ForeignKey( - name: "FK_RoleClaims_Roles_RoleId", - column: x => x.RoleId, - principalSchema: "identity", - principalTable: "Roles", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "UserClaims", - schema: "identity", - columns: table => new - { - Id = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - UserId = table.Column(type: "text", nullable: false), - ClaimType = table.Column(type: "text", nullable: true), - ClaimValue = table.Column(type: "text", nullable: true), - TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_UserClaims", x => x.Id); - table.ForeignKey( - name: "FK_UserClaims_Users_UserId", - column: x => x.UserId, - principalSchema: "identity", - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "UserLogins", - schema: "identity", - columns: table => new - { - LoginProvider = table.Column(type: "text", nullable: false), - ProviderKey = table.Column(type: "text", nullable: false), - ProviderDisplayName = table.Column(type: "text", nullable: true), - UserId = table.Column(type: "text", nullable: false), - TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_UserLogins", x => new { x.LoginProvider, x.ProviderKey }); - table.ForeignKey( - name: "FK_UserLogins_Users_UserId", - column: x => x.UserId, - principalSchema: "identity", - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "UserRoles", - schema: "identity", - columns: table => new - { - UserId = table.Column(type: "text", nullable: false), - RoleId = table.Column(type: "text", nullable: false), - TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_UserRoles", x => new { x.UserId, x.RoleId }); - table.ForeignKey( - name: "FK_UserRoles_Roles_RoleId", - column: x => x.RoleId, - principalSchema: "identity", - principalTable: "Roles", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_UserRoles_Users_UserId", - column: x => x.UserId, - principalSchema: "identity", - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "UserTokens", - schema: "identity", - columns: table => new - { - UserId = table.Column(type: "text", nullable: false), - LoginProvider = table.Column(type: "text", nullable: false), - Name = table.Column(type: "text", nullable: false), - Value = table.Column(type: "text", nullable: true), - TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_UserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); - table.ForeignKey( - name: "FK_UserTokens_Users_UserId", - column: x => x.UserId, - principalSchema: "identity", - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_RoleClaims_RoleId", - schema: "identity", - table: "RoleClaims", - column: "RoleId"); - - migrationBuilder.CreateIndex( - name: "RoleNameIndex", - schema: "identity", - table: "Roles", - columns: new[] { "NormalizedName", "TenantId" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_UserClaims_UserId", - schema: "identity", - table: "UserClaims", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_UserLogins_UserId", - schema: "identity", - table: "UserLogins", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_UserRoles_RoleId", - schema: "identity", - table: "UserRoles", - column: "RoleId"); - - migrationBuilder.CreateIndex( - name: "EmailIndex", - schema: "identity", - table: "Users", - column: "NormalizedEmail"); - - migrationBuilder.CreateIndex( - name: "UserNameIndex", - schema: "identity", - table: "Users", - column: "NormalizedUserName", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "AuditTrails", - schema: "identity"); - - migrationBuilder.DropTable( - name: "RoleClaims", - schema: "identity"); - - migrationBuilder.DropTable( - name: "UserClaims", - schema: "identity"); - - migrationBuilder.DropTable( - name: "UserLogins", - schema: "identity"); - - migrationBuilder.DropTable( - name: "UserRoles", - schema: "identity"); - - migrationBuilder.DropTable( - name: "UserTokens", - schema: "identity"); - - migrationBuilder.DropTable( - name: "Roles", - schema: "identity"); - - migrationBuilder.DropTable( - name: "Users", - schema: "identity"); - } - } -} diff --git a/src/api/migrations/PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs b/src/api/migrations/PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs deleted file mode 100644 index 685f78d39e..0000000000 --- a/src/api/migrations/PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs +++ /dev/null @@ -1,396 +0,0 @@ -// -using System; -using FSH.Framework.Infrastructure.Identity.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace FSH.Starter.WebApi.Migrations.PostgreSQL.Identity -{ - [DbContext(typeof(IdentityDbContext))] - partial class IdentityDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.0") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("FSH.Framework.Core.Audit.AuditTrail", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DateTime") - .HasColumnType("timestamp with time zone"); - - b.Property("Entity") - .HasColumnType("text"); - - b.Property("ModifiedProperties") - .HasColumnType("text"); - - b.Property("NewValues") - .HasColumnType("text"); - - b.Property("Operation") - .HasColumnType("text"); - - b.Property("PreviousValues") - .HasColumnType("text"); - - b.Property("PrimaryKey") - .HasColumnType("text"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.ToTable("AuditTrails", "identity"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("FSH.Framework.Infrastructure.Identity.RoleClaims.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() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("RoleClaims", "identity"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("FSH.Framework.Infrastructure.Identity.Roles.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() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName", "TenantId") - .IsUnique() - .HasDatabaseName("RoleNameIndex"); - - b.ToTable("Roles", "identity"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("FSH.Framework.Infrastructure.Identity.Users.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("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() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - 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") - .IsUnique() - .HasDatabaseName("UserNameIndex"); - - b.ToTable("Users", "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() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - 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() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - 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() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - 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() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("Value") - .HasColumnType("text"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("UserTokens", "identity"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); - - modelBuilder.Entity("FSH.Framework.Infrastructure.Identity.RoleClaims.FshRoleClaim", b => - { - b.HasOne("FSH.Framework.Infrastructure.Identity.Roles.FshRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("FSH.Framework.Infrastructure.Identity.Users.FshUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("FSH.Framework.Infrastructure.Identity.Users.FshUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("FSH.Framework.Infrastructure.Identity.Roles.FshRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("FSH.Framework.Infrastructure.Identity.Users.FshUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("FSH.Framework.Infrastructure.Identity.Users.FshUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/api/migrations/PostgreSQL/PostgreSQL.csproj b/src/api/migrations/PostgreSQL/PostgreSQL.csproj deleted file mode 100644 index 706f2aa206..0000000000 --- a/src/api/migrations/PostgreSQL/PostgreSQL.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - FSH.Starter.WebApi.Migrations.PostgreSQL - FSH.Starter.WebApi.Migrations.PostgreSQL - - - - - - - - - - - - - diff --git a/src/api/migrations/PostgreSQL/Tenant/20241123024825_Add Tenant Schema.Designer.cs b/src/api/migrations/PostgreSQL/Tenant/20241123024825_Add Tenant Schema.Designer.cs deleted file mode 100644 index cd4007fd06..0000000000 --- a/src/api/migrations/PostgreSQL/Tenant/20241123024825_Add Tenant Schema.Designer.cs +++ /dev/null @@ -1,69 +0,0 @@ -// -using System; -using FSH.Framework.Infrastructure.Tenant.Persistence; -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.WebApi.Migrations.PostgreSQL.Tenant -{ - [DbContext(typeof(TenantDbContext))] - [Migration("20241123024825_Add Tenant Schema")] - partial class AddTenantSchema - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.0") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("FSH.Framework.Infrastructure.Tenant.FshTenantInfo", b => - { - b.Property("Id") - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("AdminEmail") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConnectionString") - .IsRequired() - .HasColumnType("text"); - - b.Property("Identifier") - .IsRequired() - .HasColumnType("text"); - - b.Property("IsActive") - .HasColumnType("boolean"); - - b.Property("Issuer") - .HasColumnType("text"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("ValidUpto") - .HasColumnType("timestamp without time zone"); - - b.HasKey("Id"); - - b.HasIndex("Identifier") - .IsUnique(); - - b.ToTable("Tenants", "tenant"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/api/migrations/PostgreSQL/Tenant/20241123024825_Add Tenant Schema.cs b/src/api/migrations/PostgreSQL/Tenant/20241123024825_Add Tenant Schema.cs deleted file mode 100644 index 2b7a5dd93c..0000000000 --- a/src/api/migrations/PostgreSQL/Tenant/20241123024825_Add Tenant Schema.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace FSH.Starter.WebApi.Migrations.PostgreSQL.Tenant -{ - /// - public partial class AddTenantSchema : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.EnsureSchema( - name: "tenant"); - - migrationBuilder.CreateTable( - name: "Tenants", - schema: "tenant", - columns: table => new - { - Id = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), - Identifier = table.Column(type: "text", nullable: false), - Name = table.Column(type: "text", nullable: false), - ConnectionString = table.Column(type: "text", nullable: false), - AdminEmail = table.Column(type: "text", nullable: false), - IsActive = table.Column(type: "boolean", nullable: false), - ValidUpto = table.Column(type: "timestamp without time zone", nullable: false), - Issuer = table.Column(type: "text", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Tenants", x => x.Id); - }); - - migrationBuilder.CreateIndex( - name: "IX_Tenants_Identifier", - schema: "tenant", - table: "Tenants", - column: "Identifier", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Tenants", - schema: "tenant"); - } - } -} diff --git a/src/api/migrations/PostgreSQL/Tenant/TenantDbContextModelSnapshot.cs b/src/api/migrations/PostgreSQL/Tenant/TenantDbContextModelSnapshot.cs deleted file mode 100644 index 123e35b38e..0000000000 --- a/src/api/migrations/PostgreSQL/Tenant/TenantDbContextModelSnapshot.cs +++ /dev/null @@ -1,66 +0,0 @@ -// -using System; -using FSH.Framework.Infrastructure.Tenant.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace FSH.Starter.WebApi.Migrations.PostgreSQL.Tenant -{ - [DbContext(typeof(TenantDbContext))] - partial class TenantDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.0") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("FSH.Framework.Infrastructure.Tenant.FshTenantInfo", b => - { - b.Property("Id") - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("AdminEmail") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConnectionString") - .IsRequired() - .HasColumnType("text"); - - b.Property("Identifier") - .IsRequired() - .HasColumnType("text"); - - b.Property("IsActive") - .HasColumnType("boolean"); - - b.Property("Issuer") - .HasColumnType("text"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("ValidUpto") - .HasColumnType("timestamp without time zone"); - - b.HasKey("Id"); - - b.HasIndex("Identifier") - .IsUnique(); - - b.ToTable("Tenants", "tenant"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/api/migrations/PostgreSQL/Todo/20241123024832_Add Todo Schema.Designer.cs b/src/api/migrations/PostgreSQL/Todo/20241123024832_Add Todo Schema.Designer.cs deleted file mode 100644 index 249977d6fd..0000000000 --- a/src/api/migrations/PostgreSQL/Todo/20241123024832_Add Todo Schema.Designer.cs +++ /dev/null @@ -1,75 +0,0 @@ -// -using System; -using FSH.Starter.WebApi.Todo.Persistence; -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.WebApi.Migrations.PostgreSQL.Todo -{ - [DbContext(typeof(TodoDbContext))] - [Migration("20241123024832_Add Todo Schema")] - partial class AddTodoSchema - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("todo") - .HasAnnotation("ProductVersion", "9.0.0") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("FSH.Starter.WebApi.Todo.Domain.TodoItem", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Created") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("uuid"); - - b.Property("Deleted") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("LastModified") - .HasColumnType("timestamp with time zone"); - - b.Property("LastModifiedBy") - .HasColumnType("uuid"); - - b.Property("Note") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("Title") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.HasKey("Id"); - - b.ToTable("Todos", "todo"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/api/migrations/PostgreSQL/Todo/20241123024832_Add Todo Schema.cs b/src/api/migrations/PostgreSQL/Todo/20241123024832_Add Todo Schema.cs deleted file mode 100644 index 15c5f75996..0000000000 --- a/src/api/migrations/PostgreSQL/Todo/20241123024832_Add Todo Schema.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace FSH.Starter.WebApi.Migrations.PostgreSQL.Todo -{ - /// - public partial class AddTodoSchema : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.EnsureSchema( - name: "todo"); - - migrationBuilder.CreateTable( - name: "Todos", - schema: "todo", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - Title = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), - Note = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), - TenantId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), - Created = table.Column(type: "timestamp with time zone", nullable: false), - CreatedBy = table.Column(type: "uuid", nullable: false), - LastModified = table.Column(type: "timestamp with time zone", nullable: false), - LastModifiedBy = table.Column(type: "uuid", nullable: true), - Deleted = table.Column(type: "timestamp with time zone", nullable: true), - DeletedBy = table.Column(type: "uuid", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Todos", x => x.Id); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Todos", - schema: "todo"); - } - } -} diff --git a/src/api/migrations/PostgreSQL/Todo/TodoDbContextModelSnapshot.cs b/src/api/migrations/PostgreSQL/Todo/TodoDbContextModelSnapshot.cs deleted file mode 100644 index 4edd8aa7ec..0000000000 --- a/src/api/migrations/PostgreSQL/Todo/TodoDbContextModelSnapshot.cs +++ /dev/null @@ -1,72 +0,0 @@ -// -using System; -using FSH.Starter.WebApi.Todo.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace FSH.Starter.WebApi.Migrations.PostgreSQL.Todo -{ - [DbContext(typeof(TodoDbContext))] - partial class TodoDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("todo") - .HasAnnotation("ProductVersion", "9.0.0") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("FSH.Starter.WebApi.Todo.Domain.TodoItem", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Created") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("uuid"); - - b.Property("Deleted") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("LastModified") - .HasColumnType("timestamp with time zone"); - - b.Property("LastModifiedBy") - .HasColumnType("uuid"); - - b.Property("Note") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("TenantId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("Title") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.HasKey("Id"); - - b.ToTable("Todos", "todo"); - - b.HasAnnotation("Finbuckle:MultiTenant", true); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/api/modules/Catalog/Catalog.Application/Brands/Create/v1/CreateBrandCommand.cs b/src/api/modules/Catalog/Catalog.Application/Brands/Create/v1/CreateBrandCommand.cs deleted file mode 100644 index 0c2559f9aa..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Brands/Create/v1/CreateBrandCommand.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.ComponentModel; -using MediatR; - -namespace FSH.Starter.WebApi.Catalog.Application.Brands.Create.v1; -public sealed record CreateBrandCommand( - [property: DefaultValue("Sample Brand")] string? Name, - [property: DefaultValue("Descriptive Description")] string? Description = null) : IRequest; - diff --git a/src/api/modules/Catalog/Catalog.Application/Brands/Create/v1/CreateBrandCommandValidator.cs b/src/api/modules/Catalog/Catalog.Application/Brands/Create/v1/CreateBrandCommandValidator.cs deleted file mode 100644 index 0d2e695331..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Brands/Create/v1/CreateBrandCommandValidator.cs +++ /dev/null @@ -1,11 +0,0 @@ -using FluentValidation; - -namespace FSH.Starter.WebApi.Catalog.Application.Brands.Create.v1; -public class CreateBrandCommandValidator : AbstractValidator -{ - public CreateBrandCommandValidator() - { - RuleFor(b => b.Name).NotEmpty().MinimumLength(2).MaximumLength(100); - RuleFor(b => b.Description).MaximumLength(1000); - } -} diff --git a/src/api/modules/Catalog/Catalog.Application/Brands/Create/v1/CreateBrandHandler.cs b/src/api/modules/Catalog/Catalog.Application/Brands/Create/v1/CreateBrandHandler.cs deleted file mode 100644 index 15b4b7633d..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Brands/Create/v1/CreateBrandHandler.cs +++ /dev/null @@ -1,21 +0,0 @@ -using FSH.Framework.Core.Persistence; -using FSH.Starter.WebApi.Catalog.Domain; -using MediatR; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace FSH.Starter.WebApi.Catalog.Application.Brands.Create.v1; -public sealed class CreateBrandHandler( - ILogger logger, - [FromKeyedServices("catalog:brands")] IRepository repository) - : IRequestHandler -{ - public async Task Handle(CreateBrandCommand request, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request); - var brand = Brand.Create(request.Name!, request.Description); - await repository.AddAsync(brand, cancellationToken); - logger.LogInformation("brand created {BrandId}", brand.Id); - return new CreateBrandResponse(brand.Id); - } -} diff --git a/src/api/modules/Catalog/Catalog.Application/Brands/Create/v1/CreateBrandResponse.cs b/src/api/modules/Catalog/Catalog.Application/Brands/Create/v1/CreateBrandResponse.cs deleted file mode 100644 index 11e63834bd..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Brands/Create/v1/CreateBrandResponse.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace FSH.Starter.WebApi.Catalog.Application.Brands.Create.v1; - -public sealed record CreateBrandResponse(Guid? Id); - diff --git a/src/api/modules/Catalog/Catalog.Application/Brands/Delete/v1/DeleteBrandCommand.cs b/src/api/modules/Catalog/Catalog.Application/Brands/Delete/v1/DeleteBrandCommand.cs deleted file mode 100644 index 0e11b24149..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Brands/Delete/v1/DeleteBrandCommand.cs +++ /dev/null @@ -1,5 +0,0 @@ -using MediatR; - -namespace FSH.Starter.WebApi.Catalog.Application.Brands.Delete.v1; -public sealed record DeleteBrandCommand( - Guid Id) : IRequest; diff --git a/src/api/modules/Catalog/Catalog.Application/Brands/Delete/v1/DeleteBrandHandler.cs b/src/api/modules/Catalog/Catalog.Application/Brands/Delete/v1/DeleteBrandHandler.cs deleted file mode 100644 index d4afe86ef8..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Brands/Delete/v1/DeleteBrandHandler.cs +++ /dev/null @@ -1,22 +0,0 @@ -using FSH.Framework.Core.Persistence; -using FSH.Starter.WebApi.Catalog.Domain; -using FSH.Starter.WebApi.Catalog.Domain.Exceptions; -using MediatR; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace FSH.Starter.WebApi.Catalog.Application.Brands.Delete.v1; -public sealed class DeleteBrandHandler( - ILogger logger, - [FromKeyedServices("catalog:brands")] IRepository repository) - : IRequestHandler -{ - public async Task Handle(DeleteBrandCommand request, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request); - var brand = await repository.GetByIdAsync(request.Id, cancellationToken); - _ = brand ?? throw new BrandNotFoundException(request.Id); - await repository.DeleteAsync(brand, cancellationToken); - logger.LogInformation("Brand with id : {BrandId} deleted", brand.Id); - } -} diff --git a/src/api/modules/Catalog/Catalog.Application/Brands/EventHandlers/BrandCreatedEventHandler.cs b/src/api/modules/Catalog/Catalog.Application/Brands/EventHandlers/BrandCreatedEventHandler.cs deleted file mode 100644 index 777526b767..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Brands/EventHandlers/BrandCreatedEventHandler.cs +++ /dev/null @@ -1,16 +0,0 @@ -using FSH.Starter.WebApi.Catalog.Domain.Events; -using MediatR; -using Microsoft.Extensions.Logging; - -namespace FSH.Starter.WebApi.Catalog.Application.Brands.EventHandlers; - -public class BrandCreatedEventHandler(ILogger logger) : INotificationHandler -{ - public async Task Handle(BrandCreated notification, - CancellationToken cancellationToken) - { - logger.LogInformation("handling brand created domain event.."); - await Task.FromResult(notification); - logger.LogInformation("finished handling brand created domain event.."); - } -} diff --git a/src/api/modules/Catalog/Catalog.Application/Brands/Get/v1/BrandResponse.cs b/src/api/modules/Catalog/Catalog.Application/Brands/Get/v1/BrandResponse.cs deleted file mode 100644 index 726030b24e..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Brands/Get/v1/BrandResponse.cs +++ /dev/null @@ -1,2 +0,0 @@ -namespace FSH.Starter.WebApi.Catalog.Application.Brands.Get.v1; -public sealed record BrandResponse(Guid? Id, string Name, string? Description); diff --git a/src/api/modules/Catalog/Catalog.Application/Brands/Get/v1/GetBrandHandler.cs b/src/api/modules/Catalog/Catalog.Application/Brands/Get/v1/GetBrandHandler.cs deleted file mode 100644 index 7848a10d62..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Brands/Get/v1/GetBrandHandler.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using FSH.Starter.WebApi.Catalog.Domain.Exceptions; -using FSH.Framework.Core.Persistence; -using FSH.Framework.Core.Caching; -using FSH.Starter.WebApi.Catalog.Domain; -using MediatR; - -namespace FSH.Starter.WebApi.Catalog.Application.Brands.Get.v1; -public sealed class GetBrandHandler( - [FromKeyedServices("catalog:brands")] IReadRepository repository, - ICacheService cache) - : IRequestHandler -{ - public async Task Handle(GetBrandRequest request, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request); - var item = await cache.GetOrSetAsync( - $"brand:{request.Id}", - async () => - { - var brandItem = await repository.GetByIdAsync(request.Id, cancellationToken); - if (brandItem == null) throw new BrandNotFoundException(request.Id); - return new BrandResponse(brandItem.Id, brandItem.Name, brandItem.Description); - }, - cancellationToken: cancellationToken); - return item!; - } -} diff --git a/src/api/modules/Catalog/Catalog.Application/Brands/Get/v1/GetBrandRequest.cs b/src/api/modules/Catalog/Catalog.Application/Brands/Get/v1/GetBrandRequest.cs deleted file mode 100644 index a9354be5a8..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Brands/Get/v1/GetBrandRequest.cs +++ /dev/null @@ -1,8 +0,0 @@ -using MediatR; - -namespace FSH.Starter.WebApi.Catalog.Application.Brands.Get.v1; -public class GetBrandRequest : IRequest -{ - public Guid Id { get; set; } - public GetBrandRequest(Guid id) => Id = id; -} diff --git a/src/api/modules/Catalog/Catalog.Application/Brands/Search/v1/SearchBrandSpecs.cs b/src/api/modules/Catalog/Catalog.Application/Brands/Search/v1/SearchBrandSpecs.cs deleted file mode 100644 index b18cadc7f9..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Brands/Search/v1/SearchBrandSpecs.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Ardalis.Specification; -using FSH.Framework.Core.Paging; -using FSH.Framework.Core.Specifications; -using FSH.Starter.WebApi.Catalog.Application.Brands.Get.v1; -using FSH.Starter.WebApi.Catalog.Domain; - -namespace FSH.Starter.WebApi.Catalog.Application.Brands.Search.v1; -public class SearchBrandSpecs : EntitiesByPaginationFilterSpec -{ - public SearchBrandSpecs(SearchBrandsCommand command) - : base(command) => - Query - .OrderBy(c => c.Name, !command.HasOrderBy()) - .Where(b => b.Name.Contains(command.Keyword), !string.IsNullOrEmpty(command.Keyword)); -} diff --git a/src/api/modules/Catalog/Catalog.Application/Brands/Search/v1/SearchBrandsCommand.cs b/src/api/modules/Catalog/Catalog.Application/Brands/Search/v1/SearchBrandsCommand.cs deleted file mode 100644 index 70f4b3e0a8..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Brands/Search/v1/SearchBrandsCommand.cs +++ /dev/null @@ -1,11 +0,0 @@ -using FSH.Framework.Core.Paging; -using FSH.Starter.WebApi.Catalog.Application.Brands.Get.v1; -using MediatR; - -namespace FSH.Starter.WebApi.Catalog.Application.Brands.Search.v1; - -public class SearchBrandsCommand : PaginationFilter, IRequest> -{ - public string? Name { get; set; } - public string? Description { get; set; } -} diff --git a/src/api/modules/Catalog/Catalog.Application/Brands/Search/v1/SearchBrandsHandler.cs b/src/api/modules/Catalog/Catalog.Application/Brands/Search/v1/SearchBrandsHandler.cs deleted file mode 100644 index 29b7107f40..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Brands/Search/v1/SearchBrandsHandler.cs +++ /dev/null @@ -1,24 +0,0 @@ -using FSH.Framework.Core.Paging; -using FSH.Framework.Core.Persistence; -using FSH.Starter.WebApi.Catalog.Application.Brands.Get.v1; -using FSH.Starter.WebApi.Catalog.Domain; -using MediatR; -using Microsoft.Extensions.DependencyInjection; - -namespace FSH.Starter.WebApi.Catalog.Application.Brands.Search.v1; -public sealed class SearchBrandsHandler( - [FromKeyedServices("catalog:brands")] IReadRepository repository) - : IRequestHandler> -{ - public async Task> Handle(SearchBrandsCommand request, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request); - - var spec = new SearchBrandSpecs(request); - - var items = await repository.ListAsync(spec, cancellationToken).ConfigureAwait(false); - var totalCount = await repository.CountAsync(spec, cancellationToken).ConfigureAwait(false); - - return new PagedList(items, request!.PageNumber, request!.PageSize, totalCount); - } -} diff --git a/src/api/modules/Catalog/Catalog.Application/Brands/Update/v1/UpdateBrandCommand.cs b/src/api/modules/Catalog/Catalog.Application/Brands/Update/v1/UpdateBrandCommand.cs deleted file mode 100644 index ce7dd54cf8..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Brands/Update/v1/UpdateBrandCommand.cs +++ /dev/null @@ -1,7 +0,0 @@ -using MediatR; - -namespace FSH.Starter.WebApi.Catalog.Application.Brands.Update.v1; -public sealed record UpdateBrandCommand( - Guid Id, - string? Name, - string? Description = null) : IRequest; diff --git a/src/api/modules/Catalog/Catalog.Application/Brands/Update/v1/UpdateBrandCommandValidator.cs b/src/api/modules/Catalog/Catalog.Application/Brands/Update/v1/UpdateBrandCommandValidator.cs deleted file mode 100644 index a3ce8da6cb..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Brands/Update/v1/UpdateBrandCommandValidator.cs +++ /dev/null @@ -1,11 +0,0 @@ -using FluentValidation; - -namespace FSH.Starter.WebApi.Catalog.Application.Brands.Update.v1; -public class UpdateBrandCommandValidator : AbstractValidator -{ - public UpdateBrandCommandValidator() - { - RuleFor(b => b.Name).NotEmpty().MinimumLength(2).MaximumLength(100); - RuleFor(b => b.Description).MaximumLength(1000); - } -} diff --git a/src/api/modules/Catalog/Catalog.Application/Brands/Update/v1/UpdateBrandHandler.cs b/src/api/modules/Catalog/Catalog.Application/Brands/Update/v1/UpdateBrandHandler.cs deleted file mode 100644 index 2477fdb4ad..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Brands/Update/v1/UpdateBrandHandler.cs +++ /dev/null @@ -1,24 +0,0 @@ -using FSH.Framework.Core.Persistence; -using FSH.Starter.WebApi.Catalog.Domain; -using FSH.Starter.WebApi.Catalog.Domain.Exceptions; -using MediatR; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace FSH.Starter.WebApi.Catalog.Application.Brands.Update.v1; -public sealed class UpdateBrandHandler( - ILogger logger, - [FromKeyedServices("catalog:brands")] IRepository repository) - : IRequestHandler -{ - public async Task Handle(UpdateBrandCommand request, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request); - var brand = await repository.GetByIdAsync(request.Id, cancellationToken); - _ = brand ?? throw new BrandNotFoundException(request.Id); - var updatedBrand = brand.Update(request.Name, request.Description); - await repository.UpdateAsync(updatedBrand, cancellationToken); - logger.LogInformation("Brand with id : {BrandId} updated.", brand.Id); - return new UpdateBrandResponse(brand.Id); - } -} diff --git a/src/api/modules/Catalog/Catalog.Application/Brands/Update/v1/UpdateBrandResponse.cs b/src/api/modules/Catalog/Catalog.Application/Brands/Update/v1/UpdateBrandResponse.cs deleted file mode 100644 index 6b4acdc870..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Brands/Update/v1/UpdateBrandResponse.cs +++ /dev/null @@ -1,2 +0,0 @@ -namespace FSH.Starter.WebApi.Catalog.Application.Brands.Update.v1; -public sealed record UpdateBrandResponse(Guid? Id); diff --git a/src/api/modules/Catalog/Catalog.Application/Catalog.Application.csproj b/src/api/modules/Catalog/Catalog.Application/Catalog.Application.csproj deleted file mode 100644 index 34ba8437ef..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Catalog.Application.csproj +++ /dev/null @@ -1,10 +0,0 @@ - - - FSH.Starter.WebApi.Catalog.Application - FSH.Starter.WebApi.Catalog.Application - - - - - - diff --git a/src/api/modules/Catalog/Catalog.Application/CatalogMetadata.cs b/src/api/modules/Catalog/Catalog.Application/CatalogMetadata.cs deleted file mode 100644 index 0301ffd004..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/CatalogMetadata.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace FSH.Starter.WebApi.Catalog.Application; -public static class CatalogMetadata -{ - public static string Name { get; set; } = "CatalogApplication"; -} - diff --git a/src/api/modules/Catalog/Catalog.Application/Products/Create/v1/CreateProductCommand.cs b/src/api/modules/Catalog/Catalog.Application/Products/Create/v1/CreateProductCommand.cs deleted file mode 100644 index 99291ae8e8..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Products/Create/v1/CreateProductCommand.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.ComponentModel; -using MediatR; - -namespace FSH.Starter.WebApi.Catalog.Application.Products.Create.v1; -public sealed record CreateProductCommand( - [property: DefaultValue("Sample Product")] string? Name, - [property: DefaultValue(10)] decimal Price, - [property: DefaultValue("Descriptive Description")] string? Description = null, - [property: DefaultValue(null)] Guid? BrandId = null) : IRequest; diff --git a/src/api/modules/Catalog/Catalog.Application/Products/Create/v1/CreateProductCommandValidator.cs b/src/api/modules/Catalog/Catalog.Application/Products/Create/v1/CreateProductCommandValidator.cs deleted file mode 100644 index 97e81f7599..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Products/Create/v1/CreateProductCommandValidator.cs +++ /dev/null @@ -1,11 +0,0 @@ -using FluentValidation; - -namespace FSH.Starter.WebApi.Catalog.Application.Products.Create.v1; -public class CreateProductCommandValidator : AbstractValidator -{ - public CreateProductCommandValidator() - { - RuleFor(p => p.Name).NotEmpty().MinimumLength(2).MaximumLength(75); - RuleFor(p => p.Price).GreaterThan(0); - } -} diff --git a/src/api/modules/Catalog/Catalog.Application/Products/Create/v1/CreateProductHandler.cs b/src/api/modules/Catalog/Catalog.Application/Products/Create/v1/CreateProductHandler.cs deleted file mode 100644 index cb640ac642..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Products/Create/v1/CreateProductHandler.cs +++ /dev/null @@ -1,21 +0,0 @@ -using FSH.Framework.Core.Persistence; -using FSH.Starter.WebApi.Catalog.Domain; -using MediatR; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace FSH.Starter.WebApi.Catalog.Application.Products.Create.v1; -public sealed class CreateProductHandler( - ILogger logger, - [FromKeyedServices("catalog:products")] IRepository repository) - : IRequestHandler -{ - public async Task Handle(CreateProductCommand request, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request); - var product = Product.Create(request.Name!, request.Description, request.Price, request.BrandId); - await repository.AddAsync(product, cancellationToken); - logger.LogInformation("product created {ProductId}", product.Id); - return new CreateProductResponse(product.Id); - } -} diff --git a/src/api/modules/Catalog/Catalog.Application/Products/Create/v1/CreateProductResponse.cs b/src/api/modules/Catalog/Catalog.Application/Products/Create/v1/CreateProductResponse.cs deleted file mode 100644 index 2c97edab79..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Products/Create/v1/CreateProductResponse.cs +++ /dev/null @@ -1,2 +0,0 @@ -namespace FSH.Starter.WebApi.Catalog.Application.Products.Create.v1; -public sealed record CreateProductResponse(Guid? Id); diff --git a/src/api/modules/Catalog/Catalog.Application/Products/Delete/v1/DeleteProductCommand.cs b/src/api/modules/Catalog/Catalog.Application/Products/Delete/v1/DeleteProductCommand.cs deleted file mode 100644 index 5119734346..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Products/Delete/v1/DeleteProductCommand.cs +++ /dev/null @@ -1,5 +0,0 @@ -using MediatR; - -namespace FSH.Starter.WebApi.Catalog.Application.Products.Delete.v1; -public sealed record DeleteProductCommand( - Guid Id) : IRequest; diff --git a/src/api/modules/Catalog/Catalog.Application/Products/Delete/v1/DeleteProductHandler.cs b/src/api/modules/Catalog/Catalog.Application/Products/Delete/v1/DeleteProductHandler.cs deleted file mode 100644 index 182a282a4b..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Products/Delete/v1/DeleteProductHandler.cs +++ /dev/null @@ -1,22 +0,0 @@ -using FSH.Framework.Core.Persistence; -using FSH.Starter.WebApi.Catalog.Domain; -using FSH.Starter.WebApi.Catalog.Domain.Exceptions; -using MediatR; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace FSH.Starter.WebApi.Catalog.Application.Products.Delete.v1; -public sealed class DeleteProductHandler( - ILogger logger, - [FromKeyedServices("catalog:products")] IRepository repository) - : IRequestHandler -{ - public async Task Handle(DeleteProductCommand request, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request); - var product = await repository.GetByIdAsync(request.Id, cancellationToken); - _ = product ?? throw new ProductNotFoundException(request.Id); - await repository.DeleteAsync(product, cancellationToken); - logger.LogInformation("product with id : {ProductId} deleted", product.Id); - } -} diff --git a/src/api/modules/Catalog/Catalog.Application/Products/EventHandlers/ProductCreatedEventHandler.cs b/src/api/modules/Catalog/Catalog.Application/Products/EventHandlers/ProductCreatedEventHandler.cs deleted file mode 100644 index 694616ab34..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Products/EventHandlers/ProductCreatedEventHandler.cs +++ /dev/null @@ -1,17 +0,0 @@ -using FSH.Starter.WebApi.Catalog.Domain.Events; -using MediatR; -using Microsoft.Extensions.Logging; - -namespace FSH.Starter.WebApi.Catalog.Application.Products.EventHandlers; - -public class ProductCreatedEventHandler(ILogger logger) : INotificationHandler -{ - public async Task Handle(ProductCreated notification, - CancellationToken cancellationToken) - { - logger.LogInformation("handling product created domain event.."); - await Task.FromResult(notification); - logger.LogInformation("finished handling product created domain event.."); - } -} - diff --git a/src/api/modules/Catalog/Catalog.Application/Products/Get/v1/GetProductHandler.cs b/src/api/modules/Catalog/Catalog.Application/Products/Get/v1/GetProductHandler.cs deleted file mode 100644 index 53f327e262..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Products/Get/v1/GetProductHandler.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using FSH.Starter.WebApi.Catalog.Domain.Exceptions; -using FSH.Framework.Core.Persistence; -using FSH.Framework.Core.Caching; -using FSH.Starter.WebApi.Catalog.Domain; -using MediatR; - -namespace FSH.Starter.WebApi.Catalog.Application.Products.Get.v1; -public sealed class GetProductHandler( - [FromKeyedServices("catalog:products")] IReadRepository repository, - ICacheService cache) - : IRequestHandler -{ - public async Task Handle(GetProductRequest request, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request); - var item = await cache.GetOrSetAsync( - $"product:{request.Id}", - async () => - { - var spec = new GetProductSpecs(request.Id); - var productItem = await repository.FirstOrDefaultAsync(spec, cancellationToken); - if (productItem == null) throw new ProductNotFoundException(request.Id); - return productItem; - }, - cancellationToken: cancellationToken); - return item!; - } -} diff --git a/src/api/modules/Catalog/Catalog.Application/Products/Get/v1/GetProductRequest.cs b/src/api/modules/Catalog/Catalog.Application/Products/Get/v1/GetProductRequest.cs deleted file mode 100644 index a85bd13fb1..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Products/Get/v1/GetProductRequest.cs +++ /dev/null @@ -1,8 +0,0 @@ -using MediatR; - -namespace FSH.Starter.WebApi.Catalog.Application.Products.Get.v1; -public class GetProductRequest : IRequest -{ - public Guid Id { get; set; } - public GetProductRequest(Guid id) => Id = id; -} diff --git a/src/api/modules/Catalog/Catalog.Application/Products/Get/v1/GetProductSpecs.cs b/src/api/modules/Catalog/Catalog.Application/Products/Get/v1/GetProductSpecs.cs deleted file mode 100644 index 9e30c3767b..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Products/Get/v1/GetProductSpecs.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Ardalis.Specification; -using FSH.Starter.WebApi.Catalog.Domain; - -namespace FSH.Starter.WebApi.Catalog.Application.Products.Get.v1; - -public class GetProductSpecs : Specification -{ - public GetProductSpecs(Guid id) - { - Query - .Where(p => p.Id == id) - .Include(p => p.Brand); - } -} diff --git a/src/api/modules/Catalog/Catalog.Application/Products/Get/v1/ProductResponse.cs b/src/api/modules/Catalog/Catalog.Application/Products/Get/v1/ProductResponse.cs deleted file mode 100644 index 080d358685..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Products/Get/v1/ProductResponse.cs +++ /dev/null @@ -1,4 +0,0 @@ -using FSH.Starter.WebApi.Catalog.Application.Brands.Get.v1; - -namespace FSH.Starter.WebApi.Catalog.Application.Products.Get.v1; -public sealed record ProductResponse(Guid? Id, string Name, string? Description, decimal Price, BrandResponse? Brand); diff --git a/src/api/modules/Catalog/Catalog.Application/Products/Search/v1/SearchProductSpecs.cs b/src/api/modules/Catalog/Catalog.Application/Products/Search/v1/SearchProductSpecs.cs deleted file mode 100644 index 98567c6a5a..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Products/Search/v1/SearchProductSpecs.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Ardalis.Specification; -using FSH.Framework.Core.Paging; -using FSH.Framework.Core.Specifications; -using FSH.Starter.WebApi.Catalog.Application.Products.Get.v1; -using FSH.Starter.WebApi.Catalog.Domain; - -namespace FSH.Starter.WebApi.Catalog.Application.Products.Search.v1; -public class SearchProductSpecs : EntitiesByPaginationFilterSpec -{ - public SearchProductSpecs(SearchProductsCommand command) - : base(command) => - Query - .Include(p => p.Brand) - .OrderBy(c => c.Name, !command.HasOrderBy()) - .Where(p => p.BrandId == command.BrandId!.Value, command.BrandId.HasValue) - .Where(p => p.Price >= command.MinimumRate!.Value, command.MinimumRate.HasValue) - .Where(p => p.Price <= command.MaximumRate!.Value, command.MaximumRate.HasValue); -} diff --git a/src/api/modules/Catalog/Catalog.Application/Products/Search/v1/SearchProductsCommand.cs b/src/api/modules/Catalog/Catalog.Application/Products/Search/v1/SearchProductsCommand.cs deleted file mode 100644 index ed19c2f958..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Products/Search/v1/SearchProductsCommand.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FSH.Framework.Core.Paging; -using FSH.Starter.WebApi.Catalog.Application.Products.Get.v1; -using MediatR; - -namespace FSH.Starter.WebApi.Catalog.Application.Products.Search.v1; - -public class SearchProductsCommand : PaginationFilter, IRequest> -{ - public Guid? BrandId { get; set; } - public decimal? MinimumRate { get; set; } - public decimal? MaximumRate { get; set; } -} diff --git a/src/api/modules/Catalog/Catalog.Application/Products/Search/v1/SearchProductsHandler.cs b/src/api/modules/Catalog/Catalog.Application/Products/Search/v1/SearchProductsHandler.cs deleted file mode 100644 index 7c6c290df0..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Products/Search/v1/SearchProductsHandler.cs +++ /dev/null @@ -1,26 +0,0 @@ -using FSH.Framework.Core.Paging; -using FSH.Framework.Core.Persistence; -using FSH.Starter.WebApi.Catalog.Application.Products.Get.v1; -using FSH.Starter.WebApi.Catalog.Domain; -using MediatR; -using Microsoft.Extensions.DependencyInjection; - - -namespace FSH.Starter.WebApi.Catalog.Application.Products.Search.v1; -public sealed class SearchProductsHandler( - [FromKeyedServices("catalog:products")] IReadRepository repository) - : IRequestHandler> -{ - public async Task> Handle(SearchProductsCommand request, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request); - - var spec = new SearchProductSpecs(request); - - var items = await repository.ListAsync(spec, cancellationToken).ConfigureAwait(false); - var totalCount = await repository.CountAsync(spec, cancellationToken).ConfigureAwait(false); - - return new PagedList(items, request!.PageNumber, request!.PageSize, totalCount); - } -} - diff --git a/src/api/modules/Catalog/Catalog.Application/Products/Update/v1/UpdateProductCommand.cs b/src/api/modules/Catalog/Catalog.Application/Products/Update/v1/UpdateProductCommand.cs deleted file mode 100644 index dd7db751c0..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Products/Update/v1/UpdateProductCommand.cs +++ /dev/null @@ -1,9 +0,0 @@ -using MediatR; - -namespace FSH.Starter.WebApi.Catalog.Application.Products.Update.v1; -public sealed record UpdateProductCommand( - Guid Id, - string? Name, - decimal Price, - string? Description = null, - Guid? BrandId = null) : IRequest; diff --git a/src/api/modules/Catalog/Catalog.Application/Products/Update/v1/UpdateProductCommandValidator.cs b/src/api/modules/Catalog/Catalog.Application/Products/Update/v1/UpdateProductCommandValidator.cs deleted file mode 100644 index e0110ea84f..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Products/Update/v1/UpdateProductCommandValidator.cs +++ /dev/null @@ -1,11 +0,0 @@ -using FluentValidation; - -namespace FSH.Starter.WebApi.Catalog.Application.Products.Update.v1; -public class UpdateProductCommandValidator : AbstractValidator -{ - public UpdateProductCommandValidator() - { - RuleFor(p => p.Name).NotEmpty().MinimumLength(2).MaximumLength(75); - RuleFor(p => p.Price).GreaterThan(0); - } -} diff --git a/src/api/modules/Catalog/Catalog.Application/Products/Update/v1/UpdateProductHandler.cs b/src/api/modules/Catalog/Catalog.Application/Products/Update/v1/UpdateProductHandler.cs deleted file mode 100644 index 5062196250..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Products/Update/v1/UpdateProductHandler.cs +++ /dev/null @@ -1,24 +0,0 @@ -using FSH.Framework.Core.Persistence; -using FSH.Starter.WebApi.Catalog.Domain; -using FSH.Starter.WebApi.Catalog.Domain.Exceptions; -using MediatR; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace FSH.Starter.WebApi.Catalog.Application.Products.Update.v1; -public sealed class UpdateProductHandler( - ILogger logger, - [FromKeyedServices("catalog:products")] IRepository repository) - : IRequestHandler -{ - public async Task Handle(UpdateProductCommand request, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request); - var product = await repository.GetByIdAsync(request.Id, cancellationToken); - _ = product ?? throw new ProductNotFoundException(request.Id); - var updatedProduct = product.Update(request.Name, request.Description, request.Price, request.BrandId); - await repository.UpdateAsync(updatedProduct, cancellationToken); - logger.LogInformation("product with id : {ProductId} updated.", product.Id); - return new UpdateProductResponse(product.Id); - } -} diff --git a/src/api/modules/Catalog/Catalog.Application/Products/Update/v1/UpdateProductResponse.cs b/src/api/modules/Catalog/Catalog.Application/Products/Update/v1/UpdateProductResponse.cs deleted file mode 100644 index cf28a756c8..0000000000 --- a/src/api/modules/Catalog/Catalog.Application/Products/Update/v1/UpdateProductResponse.cs +++ /dev/null @@ -1,2 +0,0 @@ -namespace FSH.Starter.WebApi.Catalog.Application.Products.Update.v1; -public sealed record UpdateProductResponse(Guid? Id); diff --git a/src/api/modules/Catalog/Catalog.Domain/Brand.cs b/src/api/modules/Catalog/Catalog.Domain/Brand.cs deleted file mode 100644 index 0d24695f54..0000000000 --- a/src/api/modules/Catalog/Catalog.Domain/Brand.cs +++ /dev/null @@ -1,51 +0,0 @@ -using FSH.Framework.Core.Domain; -using FSH.Framework.Core.Domain.Contracts; -using FSH.Starter.WebApi.Catalog.Domain.Events; - -namespace FSH.Starter.WebApi.Catalog.Domain; -public class Brand : AuditableEntity, IAggregateRoot -{ - public string Name { get; private set; } = string.Empty; - public string? Description { get; private set; } - - private Brand() { } - - private Brand(Guid id, string name, string? description) - { - Id = id; - Name = name; - Description = description; - QueueDomainEvent(new BrandCreated { Brand = this }); - } - - public static Brand Create(string name, string? description) - { - return new Brand(Guid.NewGuid(), name, description); - } - - public Brand Update(string? name, string? description) - { - bool isUpdated = false; - - if (!string.IsNullOrWhiteSpace(name) && !string.Equals(Name, name, StringComparison.OrdinalIgnoreCase)) - { - Name = name; - isUpdated = true; - } - - if (!string.Equals(Description, description, StringComparison.OrdinalIgnoreCase)) - { - Description = description; - isUpdated = true; - } - - if (isUpdated) - { - QueueDomainEvent(new BrandUpdated { Brand = this }); - } - - return this; - } -} - - diff --git a/src/api/modules/Catalog/Catalog.Domain/Catalog.Domain.csproj b/src/api/modules/Catalog/Catalog.Domain/Catalog.Domain.csproj deleted file mode 100644 index 96c37fb508..0000000000 --- a/src/api/modules/Catalog/Catalog.Domain/Catalog.Domain.csproj +++ /dev/null @@ -1,9 +0,0 @@ - - - FSH.Starter.WebApi.Catalog.Domain - FSH.Starter.WebApi.Catalog.Domain - - - - - diff --git a/src/api/modules/Catalog/Catalog.Domain/Events/BrandCreated.cs b/src/api/modules/Catalog/Catalog.Domain/Events/BrandCreated.cs deleted file mode 100644 index a1ae4abeb1..0000000000 --- a/src/api/modules/Catalog/Catalog.Domain/Events/BrandCreated.cs +++ /dev/null @@ -1,7 +0,0 @@ -using FSH.Framework.Core.Domain.Events; - -namespace FSH.Starter.WebApi.Catalog.Domain.Events; -public sealed record BrandCreated : DomainEvent -{ - public Brand? Brand { get; set; } -} diff --git a/src/api/modules/Catalog/Catalog.Domain/Events/BrandUpdated.cs b/src/api/modules/Catalog/Catalog.Domain/Events/BrandUpdated.cs deleted file mode 100644 index 4446dcf079..0000000000 --- a/src/api/modules/Catalog/Catalog.Domain/Events/BrandUpdated.cs +++ /dev/null @@ -1,7 +0,0 @@ -using FSH.Framework.Core.Domain.Events; - -namespace FSH.Starter.WebApi.Catalog.Domain.Events; -public sealed record BrandUpdated : DomainEvent -{ - public Brand? Brand { get; set; } -} diff --git a/src/api/modules/Catalog/Catalog.Domain/Events/ProductCreated.cs b/src/api/modules/Catalog/Catalog.Domain/Events/ProductCreated.cs deleted file mode 100644 index a049fe6856..0000000000 --- a/src/api/modules/Catalog/Catalog.Domain/Events/ProductCreated.cs +++ /dev/null @@ -1,7 +0,0 @@ -using FSH.Framework.Core.Domain.Events; - -namespace FSH.Starter.WebApi.Catalog.Domain.Events; -public sealed record ProductCreated : DomainEvent -{ - public Product? Product { get; set; } -} diff --git a/src/api/modules/Catalog/Catalog.Domain/Events/ProductUpdated.cs b/src/api/modules/Catalog/Catalog.Domain/Events/ProductUpdated.cs deleted file mode 100644 index ed38e3584a..0000000000 --- a/src/api/modules/Catalog/Catalog.Domain/Events/ProductUpdated.cs +++ /dev/null @@ -1,7 +0,0 @@ -using FSH.Framework.Core.Domain.Events; - -namespace FSH.Starter.WebApi.Catalog.Domain.Events; -public sealed record ProductUpdated : DomainEvent -{ - public Product? Product { get; set; } -} diff --git a/src/api/modules/Catalog/Catalog.Domain/Exceptions/BrandNotFoundException.cs b/src/api/modules/Catalog/Catalog.Domain/Exceptions/BrandNotFoundException.cs deleted file mode 100644 index 84a40a1b81..0000000000 --- a/src/api/modules/Catalog/Catalog.Domain/Exceptions/BrandNotFoundException.cs +++ /dev/null @@ -1,10 +0,0 @@ -using FSH.Framework.Core.Exceptions; - -namespace FSH.Starter.WebApi.Catalog.Domain.Exceptions; -public sealed class BrandNotFoundException : NotFoundException -{ - public BrandNotFoundException(Guid id) - : base($"brand with id {id} not found") - { - } -} diff --git a/src/api/modules/Catalog/Catalog.Domain/Exceptions/ProductNotFoundException.cs b/src/api/modules/Catalog/Catalog.Domain/Exceptions/ProductNotFoundException.cs deleted file mode 100644 index b3a3963a33..0000000000 --- a/src/api/modules/Catalog/Catalog.Domain/Exceptions/ProductNotFoundException.cs +++ /dev/null @@ -1,10 +0,0 @@ -using FSH.Framework.Core.Exceptions; - -namespace FSH.Starter.WebApi.Catalog.Domain.Exceptions; -public sealed class ProductNotFoundException : NotFoundException -{ - public ProductNotFoundException(Guid id) - : base($"product with id {id} not found") - { - } -} diff --git a/src/api/modules/Catalog/Catalog.Domain/Product.cs b/src/api/modules/Catalog/Catalog.Domain/Product.cs deleted file mode 100644 index e1ebd863ff..0000000000 --- a/src/api/modules/Catalog/Catalog.Domain/Product.cs +++ /dev/null @@ -1,68 +0,0 @@ -using FSH.Framework.Core.Domain; -using FSH.Framework.Core.Domain.Contracts; -using FSH.Starter.WebApi.Catalog.Domain.Events; - -namespace FSH.Starter.WebApi.Catalog.Domain; -public class Product : AuditableEntity, IAggregateRoot -{ - public string Name { get; private set; } = string.Empty; - public string? Description { get; private set; } - public decimal Price { get; private set; } - public Guid? BrandId { get; private set; } - public virtual Brand Brand { get; private set; } = default!; - - private Product() { } - - private Product(Guid id, string name, string? description, decimal price, Guid? brandId) - { - Id = id; - Name = name; - Description = description; - Price = price; - BrandId = brandId; - - QueueDomainEvent(new ProductCreated { Product = this }); - } - - public static Product Create(string name, string? description, decimal price, Guid? brandId) - { - return new Product(Guid.NewGuid(), name, description, price, brandId); - } - - public Product Update(string? name, string? description, decimal? price, Guid? brandId) - { - bool isUpdated = false; - - if (!string.IsNullOrWhiteSpace(name) && !string.Equals(Name, name, StringComparison.OrdinalIgnoreCase)) - { - Name = name; - isUpdated = true; - } - - if (!string.Equals(Description, description, StringComparison.OrdinalIgnoreCase)) - { - Description = description; - isUpdated = true; - } - - if (price.HasValue && Price != price.Value) - { - Price = price.Value; - isUpdated = true; - } - - if (brandId.HasValue && brandId.Value != Guid.Empty && BrandId != brandId.Value) - { - BrandId = brandId.Value; - isUpdated = true; - } - - if (isUpdated) - { - QueueDomainEvent(new ProductUpdated { Product = this }); - } - - return this; - } -} - diff --git a/src/api/modules/Catalog/Catalog.Infrastructure/Catalog.Infrastructure.csproj b/src/api/modules/Catalog/Catalog.Infrastructure/Catalog.Infrastructure.csproj deleted file mode 100644 index 7607e1698f..0000000000 --- a/src/api/modules/Catalog/Catalog.Infrastructure/Catalog.Infrastructure.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - FSH.Starter.WebApi.Catalog.Infrastructure - FSH.Starter.WebApi.Catalog.Infrastructure - - - - - - - - - - - diff --git a/src/api/modules/Catalog/Catalog.Infrastructure/CatalogModule.cs b/src/api/modules/Catalog/Catalog.Infrastructure/CatalogModule.cs deleted file mode 100644 index 8327a6a9a0..0000000000 --- a/src/api/modules/Catalog/Catalog.Infrastructure/CatalogModule.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Carter; -using FSH.Framework.Core.Persistence; -using FSH.Framework.Infrastructure.Persistence; -using FSH.Starter.WebApi.Catalog.Domain; -using FSH.Starter.WebApi.Catalog.Infrastructure.Endpoints.v1; -using FSH.Starter.WebApi.Catalog.Infrastructure.Persistence; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; - -namespace FSH.Starter.WebApi.Catalog.Infrastructure; -public static class CatalogModule -{ - public class Endpoints : CarterModule - { - public Endpoints() : base("catalog") { } - public override void AddRoutes(IEndpointRouteBuilder app) - { - var productGroup = app.MapGroup("products").WithTags("products"); - productGroup.MapProductCreationEndpoint(); - productGroup.MapGetProductEndpoint(); - productGroup.MapGetProductListEndpoint(); - productGroup.MapProductUpdateEndpoint(); - productGroup.MapProductDeleteEndpoint(); - - var brandGroup = app.MapGroup("brands").WithTags("brands"); - brandGroup.MapBrandCreationEndpoint(); - brandGroup.MapGetBrandEndpoint(); - brandGroup.MapGetBrandListEndpoint(); - brandGroup.MapBrandUpdateEndpoint(); - brandGroup.MapBrandDeleteEndpoint(); - } - } - public static WebApplicationBuilder RegisterCatalogServices(this WebApplicationBuilder builder) - { - ArgumentNullException.ThrowIfNull(builder); - builder.Services.BindDbContext(); - builder.Services.AddScoped(); - builder.Services.AddKeyedScoped, CatalogRepository>("catalog:products"); - builder.Services.AddKeyedScoped, CatalogRepository>("catalog:products"); - builder.Services.AddKeyedScoped, CatalogRepository>("catalog:brands"); - builder.Services.AddKeyedScoped, CatalogRepository>("catalog:brands"); - return builder; - } - public static WebApplication UseCatalogModule(this WebApplication app) - { - return app; - } -} diff --git a/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/CreateBrandEndpoint.cs b/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/CreateBrandEndpoint.cs deleted file mode 100644 index b7adb6b9bf..0000000000 --- a/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/CreateBrandEndpoint.cs +++ /dev/null @@ -1,26 +0,0 @@ -using FSH.Framework.Infrastructure.Auth.Policy; -using FSH.Starter.WebApi.Catalog.Application.Brands.Create.v1; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Starter.WebApi.Catalog.Infrastructure.Endpoints.v1; -public static class CreateBrandEndpoint -{ - internal static RouteHandlerBuilder MapBrandCreationEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints - .MapPost("/", async (CreateBrandCommand request, ISender mediator) => - { - var response = await mediator.Send(request); - return Results.Ok(response); - }) - .WithName(nameof(CreateBrandEndpoint)) - .WithSummary("creates a brand") - .WithDescription("creates a brand") - .Produces() - .RequirePermission("Permissions.Brands.Create") - .MapToApiVersion(1); - } -} diff --git a/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/CreateProductEndpoint.cs b/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/CreateProductEndpoint.cs deleted file mode 100644 index 1e018c0ed8..0000000000 --- a/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/CreateProductEndpoint.cs +++ /dev/null @@ -1,26 +0,0 @@ -using FSH.Framework.Infrastructure.Auth.Policy; -using FSH.Starter.WebApi.Catalog.Application.Products.Create.v1; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Starter.WebApi.Catalog.Infrastructure.Endpoints.v1; -public static class CreateProductEndpoint -{ - internal static RouteHandlerBuilder MapProductCreationEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints - .MapPost("/", async (CreateProductCommand request, ISender mediator) => - { - var response = await mediator.Send(request); - return Results.Ok(response); - }) - .WithName(nameof(CreateProductEndpoint)) - .WithSummary("creates a product") - .WithDescription("creates a product") - .Produces() - .RequirePermission("Permissions.Products.Create") - .MapToApiVersion(1); - } -} diff --git a/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/DeleteBrandEndpoint.cs b/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/DeleteBrandEndpoint.cs deleted file mode 100644 index 3b39820dce..0000000000 --- a/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/DeleteBrandEndpoint.cs +++ /dev/null @@ -1,26 +0,0 @@ -using FSH.Framework.Infrastructure.Auth.Policy; -using FSH.Starter.WebApi.Catalog.Application.Brands.Delete.v1; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Starter.WebApi.Catalog.Infrastructure.Endpoints.v1; -public static class DeleteBrandEndpoint -{ - internal static RouteHandlerBuilder MapBrandDeleteEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints - .MapDelete("/{id:guid}", async (Guid id, ISender mediator) => - { - await mediator.Send(new DeleteBrandCommand(id)); - return Results.NoContent(); - }) - .WithName(nameof(DeleteBrandEndpoint)) - .WithSummary("deletes brand by id") - .WithDescription("deletes brand by id") - .Produces(StatusCodes.Status204NoContent) - .RequirePermission("Permissions.Brands.Delete") - .MapToApiVersion(1); - } -} diff --git a/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/DeleteProductEndpoint.cs b/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/DeleteProductEndpoint.cs deleted file mode 100644 index c5c25fce2a..0000000000 --- a/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/DeleteProductEndpoint.cs +++ /dev/null @@ -1,26 +0,0 @@ -using FSH.Framework.Infrastructure.Auth.Policy; -using FSH.Starter.WebApi.Catalog.Application.Products.Delete.v1; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Starter.WebApi.Catalog.Infrastructure.Endpoints.v1; -public static class DeleteProductEndpoint -{ - internal static RouteHandlerBuilder MapProductDeleteEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints - .MapDelete("/{id:guid}", async (Guid id, ISender mediator) => - { - await mediator.Send(new DeleteProductCommand(id)); - return Results.NoContent(); - }) - .WithName(nameof(DeleteProductEndpoint)) - .WithSummary("deletes product by id") - .WithDescription("deletes product by id") - .Produces(StatusCodes.Status204NoContent) - .RequirePermission("Permissions.Products.Delete") - .MapToApiVersion(1); - } -} diff --git a/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/GetBrandEndpoint.cs b/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/GetBrandEndpoint.cs deleted file mode 100644 index 13600025c9..0000000000 --- a/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/GetBrandEndpoint.cs +++ /dev/null @@ -1,26 +0,0 @@ -using FSH.Framework.Infrastructure.Auth.Policy; -using FSH.Starter.WebApi.Catalog.Application.Brands.Get.v1; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Starter.WebApi.Catalog.Infrastructure.Endpoints.v1; -public static class GetBrandEndpoint -{ - internal static RouteHandlerBuilder MapGetBrandEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints - .MapGet("/{id:guid}", async (Guid id, ISender mediator) => - { - var response = await mediator.Send(new GetBrandRequest(id)); - return Results.Ok(response); - }) - .WithName(nameof(GetBrandEndpoint)) - .WithSummary("gets brand by id") - .WithDescription("gets brand by id") - .Produces() - .RequirePermission("Permissions.Brands.View") - .MapToApiVersion(1); - } -} diff --git a/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/GetProductEndpoint.cs b/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/GetProductEndpoint.cs deleted file mode 100644 index 7fd15eb1f7..0000000000 --- a/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/GetProductEndpoint.cs +++ /dev/null @@ -1,26 +0,0 @@ -using FSH.Framework.Infrastructure.Auth.Policy; -using FSH.Starter.WebApi.Catalog.Application.Products.Get.v1; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Starter.WebApi.Catalog.Infrastructure.Endpoints.v1; -public static class GetProductEndpoint -{ - internal static RouteHandlerBuilder MapGetProductEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints - .MapGet("/{id:guid}", async (Guid id, ISender mediator) => - { - var response = await mediator.Send(new GetProductRequest(id)); - return Results.Ok(response); - }) - .WithName(nameof(GetProductEndpoint)) - .WithSummary("gets product by id") - .WithDescription("gets prodct by id") - .Produces() - .RequirePermission("Permissions.Products.View") - .MapToApiVersion(1); - } -} diff --git a/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/SearchBrandsEndpoint.cs b/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/SearchBrandsEndpoint.cs deleted file mode 100644 index bc6d9a83f0..0000000000 --- a/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/SearchBrandsEndpoint.cs +++ /dev/null @@ -1,30 +0,0 @@ -using FSH.Framework.Core.Paging; -using FSH.Framework.Infrastructure.Auth.Policy; -using FSH.Starter.WebApi.Catalog.Application.Brands.Get.v1; -using FSH.Starter.WebApi.Catalog.Application.Brands.Search.v1; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Starter.WebApi.Catalog.Infrastructure.Endpoints.v1; - -public static class SearchBrandsEndpoint -{ - internal static RouteHandlerBuilder MapGetBrandListEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints - .MapPost("/search", async (ISender mediator, [FromBody] SearchBrandsCommand command) => - { - var response = await mediator.Send(command); - return Results.Ok(response); - }) - .WithName(nameof(SearchBrandsEndpoint)) - .WithSummary("Gets a list of brands") - .WithDescription("Gets a list of brands with pagination and filtering support") - .Produces>() - .RequirePermission("Permissions.Brands.View") - .MapToApiVersion(1); - } -} diff --git a/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/SearchProductsEndpoint.cs b/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/SearchProductsEndpoint.cs deleted file mode 100644 index e3d058006b..0000000000 --- a/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/SearchProductsEndpoint.cs +++ /dev/null @@ -1,31 +0,0 @@ -using FSH.Framework.Core.Paging; -using FSH.Framework.Infrastructure.Auth.Policy; -using FSH.Starter.WebApi.Catalog.Application.Products.Get.v1; -using FSH.Starter.WebApi.Catalog.Application.Products.Search.v1; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Starter.WebApi.Catalog.Infrastructure.Endpoints.v1; - -public static class SearchProductsEndpoint -{ - internal static RouteHandlerBuilder MapGetProductListEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints - .MapPost("/search", async (ISender mediator, [FromBody] SearchProductsCommand command) => - { - var response = await mediator.Send(command); - return Results.Ok(response); - }) - .WithName(nameof(SearchProductsEndpoint)) - .WithSummary("Gets a list of products") - .WithDescription("Gets a list of products with pagination and filtering support") - .Produces>() - .RequirePermission("Permissions.Products.View") - .MapToApiVersion(1); - } -} - diff --git a/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/UpdateBrandEndpoint.cs b/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/UpdateBrandEndpoint.cs deleted file mode 100644 index 3e07b34bef..0000000000 --- a/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/UpdateBrandEndpoint.cs +++ /dev/null @@ -1,27 +0,0 @@ -using FSH.Framework.Infrastructure.Auth.Policy; -using FSH.Starter.WebApi.Catalog.Application.Brands.Update.v1; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Starter.WebApi.Catalog.Infrastructure.Endpoints.v1; -public static class UpdateBrandEndpoint -{ - internal static RouteHandlerBuilder MapBrandUpdateEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints - .MapPut("/{id:guid}", async (Guid id, UpdateBrandCommand request, ISender mediator) => - { - if (id != request.Id) return Results.BadRequest(); - var response = await mediator.Send(request); - return Results.Ok(response); - }) - .WithName(nameof(UpdateBrandEndpoint)) - .WithSummary("update a brand") - .WithDescription("update a brand") - .Produces() - .RequirePermission("Permissions.Brands.Update") - .MapToApiVersion(1); - } -} diff --git a/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/UpdateProductEndpoint.cs b/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/UpdateProductEndpoint.cs deleted file mode 100644 index e3ee4f3e55..0000000000 --- a/src/api/modules/Catalog/Catalog.Infrastructure/Endpoints/v1/UpdateProductEndpoint.cs +++ /dev/null @@ -1,27 +0,0 @@ -using FSH.Framework.Infrastructure.Auth.Policy; -using FSH.Starter.WebApi.Catalog.Application.Products.Update.v1; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Starter.WebApi.Catalog.Infrastructure.Endpoints.v1; -public static class UpdateProductEndpoint -{ - internal static RouteHandlerBuilder MapProductUpdateEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints - .MapPut("/{id:guid}", async (Guid id, UpdateProductCommand request, ISender mediator) => - { - if (id != request.Id) return Results.BadRequest(); - var response = await mediator.Send(request); - return Results.Ok(response); - }) - .WithName(nameof(UpdateProductEndpoint)) - .WithSummary("update a product") - .WithDescription("update a product") - .Produces() - .RequirePermission("Permissions.Products.Update") - .MapToApiVersion(1); - } -} diff --git a/src/api/modules/Catalog/Catalog.Infrastructure/Persistence/CatalogDbContext.cs b/src/api/modules/Catalog/Catalog.Infrastructure/Persistence/CatalogDbContext.cs deleted file mode 100644 index dddec6be7f..0000000000 --- a/src/api/modules/Catalog/Catalog.Infrastructure/Persistence/CatalogDbContext.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Finbuckle.MultiTenant.Abstractions; -using FSH.Framework.Core.Persistence; -using FSH.Framework.Infrastructure.Persistence; -using FSH.Framework.Infrastructure.Tenant; -using FSH.Starter.WebApi.Catalog.Domain; -using MediatR; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; -using Shared.Constants; - -namespace FSH.Starter.WebApi.Catalog.Infrastructure.Persistence; - -public sealed class CatalogDbContext : FshDbContext -{ - public CatalogDbContext(IMultiTenantContextAccessor multiTenantContextAccessor, DbContextOptions options, IPublisher publisher, IOptions settings) - : base(multiTenantContextAccessor, options, publisher, settings) - { - } - - public DbSet Products { get; set; } = null!; - public DbSet Brands { get; set; } = null!; - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - ArgumentNullException.ThrowIfNull(modelBuilder); - base.OnModelCreating(modelBuilder); - modelBuilder.ApplyConfigurationsFromAssembly(typeof(CatalogDbContext).Assembly); - modelBuilder.HasDefaultSchema(SchemaNames.Catalog); - } -} diff --git a/src/api/modules/Catalog/Catalog.Infrastructure/Persistence/CatalogDbInitializer.cs b/src/api/modules/Catalog/Catalog.Infrastructure/Persistence/CatalogDbInitializer.cs deleted file mode 100644 index db213a0624..0000000000 --- a/src/api/modules/Catalog/Catalog.Infrastructure/Persistence/CatalogDbInitializer.cs +++ /dev/null @@ -1,34 +0,0 @@ -using FSH.Framework.Core.Persistence; -using FSH.Starter.WebApi.Catalog.Domain; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; - -namespace FSH.Starter.WebApi.Catalog.Infrastructure.Persistence; -internal sealed class CatalogDbInitializer( - ILogger logger, - CatalogDbContext context) : IDbInitializer -{ - public async Task MigrateAsync(CancellationToken cancellationToken) - { - if ((await context.Database.GetPendingMigrationsAsync(cancellationToken)).Any()) - { - await context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false); - logger.LogInformation("[{Tenant}] applied database migrations for catalog module", context.TenantInfo!.Identifier); - } - } - - public async Task SeedAsync(CancellationToken cancellationToken) - { - const string Name = "Keychron V6 QMK Custom Wired Mechanical Keyboard"; - const string Description = "A full-size layout QMK/VIA custom mechanical keyboard"; - const decimal Price = 79; - Guid? BrandId = null; - if (await context.Products.FirstOrDefaultAsync(t => t.Name == Name, cancellationToken).ConfigureAwait(false) is null) - { - var product = Product.Create(Name, Description, Price, BrandId); - await context.Products.AddAsync(product, cancellationToken); - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - logger.LogInformation("[{Tenant}] seeding default catalog data", context.TenantInfo!.Identifier); - } - } -} diff --git a/src/api/modules/Catalog/Catalog.Infrastructure/Persistence/CatalogRepository.cs b/src/api/modules/Catalog/Catalog.Infrastructure/Persistence/CatalogRepository.cs deleted file mode 100644 index baa1292f08..0000000000 --- a/src/api/modules/Catalog/Catalog.Infrastructure/Persistence/CatalogRepository.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Ardalis.Specification; -using Ardalis.Specification.EntityFrameworkCore; -using FSH.Framework.Core.Domain.Contracts; -using FSH.Framework.Core.Persistence; -using Mapster; - -namespace FSH.Starter.WebApi.Catalog.Infrastructure.Persistence; -internal sealed class CatalogRepository : RepositoryBase, IReadRepository, IRepository - where T : class, IAggregateRoot -{ - public CatalogRepository(CatalogDbContext context) - : base(context) - { - } - - // We override the default behavior when mapping to a dto. - // We're using Mapster's ProjectToType here to immediately map the result from the database. - // This is only done when no Selector is defined, so regular specifications with a selector also still work. - protected override IQueryable ApplySpecification(ISpecification specification) => - specification.Selector is not null - ? base.ApplySpecification(specification) - : ApplySpecification(specification, false) - .ProjectToType(); -} diff --git a/src/api/modules/Catalog/Catalog.Infrastructure/Persistence/Configurations/BrandConfigurations.cs b/src/api/modules/Catalog/Catalog.Infrastructure/Persistence/Configurations/BrandConfigurations.cs deleted file mode 100644 index 0abf96da30..0000000000 --- a/src/api/modules/Catalog/Catalog.Infrastructure/Persistence/Configurations/BrandConfigurations.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Finbuckle.MultiTenant; -using FSH.Starter.WebApi.Catalog.Domain; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace FSH.Starter.WebApi.Catalog.Infrastructure.Persistence.Configurations; -internal sealed class BrandConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.IsMultiTenant(); - builder.HasKey(x => x.Id); - builder.Property(x => x.Name).HasMaxLength(100); - builder.Property(x => x.Description).HasMaxLength(1000); - } -} diff --git a/src/api/modules/Catalog/Catalog.Infrastructure/Persistence/Configurations/ProductConfiguration.cs b/src/api/modules/Catalog/Catalog.Infrastructure/Persistence/Configurations/ProductConfiguration.cs deleted file mode 100644 index fc1cfffa92..0000000000 --- a/src/api/modules/Catalog/Catalog.Infrastructure/Persistence/Configurations/ProductConfiguration.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Finbuckle.MultiTenant; -using FSH.Starter.WebApi.Catalog.Domain; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace FSH.Starter.WebApi.Catalog.Infrastructure.Persistence.Configurations; -internal sealed class ProductConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.IsMultiTenant(); - builder.HasKey(x => x.Id); - builder.Property(x => x.Name).HasMaxLength(100); - builder.Property(x => x.Description).HasMaxLength(1000); - } -} diff --git a/src/api/modules/Catalog/CatalogModule.cs b/src/api/modules/Catalog/CatalogModule.cs deleted file mode 100644 index e90fd3c217..0000000000 --- a/src/api/modules/Catalog/CatalogModule.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Carter; -using FSH.WebApi.Modules.Catalog.Features.Products.ProductCreation.v1; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.WebApi.Modules.Catalog; - -public static class CatalogModule -{ - public class Endpoints : CarterModule - { - public Endpoints() : base("catalog") { } - public override void AddRoutes(IEndpointRouteBuilder app) - { - var productGroup = app.MapGroup("products").WithTags("products"); - productGroup.MapProductCreationEndpoint(); - - var testGroup = app.MapGroup("test").WithTags("test"); - testGroup.MapGet("/test", () => "hi"); - } - } - public static WebApplicationBuilder RegisterCatalogServices(this WebApplicationBuilder builder) - { - ArgumentNullException.ThrowIfNull(builder); - return builder; - } - public static WebApplication UseCatalogModule(this WebApplication app) - { - return app; - } -} diff --git a/src/api/modules/Todo/Domain/Events/TodoItemCreated.cs b/src/api/modules/Todo/Domain/Events/TodoItemCreated.cs deleted file mode 100644 index 224e6de853..0000000000 --- a/src/api/modules/Todo/Domain/Events/TodoItemCreated.cs +++ /dev/null @@ -1,22 +0,0 @@ - -using FSH.Framework.Core.Caching; -using FSH.Framework.Core.Domain.Events; -using FSH.Starter.WebApi.Todo.Features.Get.v1; -using MediatR; -using Microsoft.Extensions.Logging; - -namespace FSH.Starter.WebApi.Todo.Domain.Events; -public record TodoItemCreated(Guid Id, string Title, string Note) : DomainEvent; - -public class TodoItemCreatedEventHandler( - ILogger logger, - ICacheService cache) - : INotificationHandler -{ - public async Task Handle(TodoItemCreated notification, CancellationToken cancellationToken) - { - logger.LogInformation("handling todo item created domain event.."); - var cacheResponse = new GetTodoResponse(notification.Id, notification.Title, notification.Note); - await cache.SetAsync($"todo:{notification.Id}", cacheResponse, cancellationToken: cancellationToken); - } -} diff --git a/src/api/modules/Todo/Domain/Events/TodoItemUpdated.cs b/src/api/modules/Todo/Domain/Events/TodoItemUpdated.cs deleted file mode 100644 index c5a11e85b8..0000000000 --- a/src/api/modules/Todo/Domain/Events/TodoItemUpdated.cs +++ /dev/null @@ -1,22 +0,0 @@ - -using FSH.Framework.Core.Caching; -using FSH.Framework.Core.Domain.Events; -using FSH.Starter.WebApi.Todo.Features.Get.v1; -using MediatR; -using Microsoft.Extensions.Logging; - -namespace FSH.Starter.WebApi.Todo.Domain.Events; -public record TodoItemUpdated(TodoItem item) : DomainEvent; - -public class TodoItemUpdatedEventHandler( - ILogger logger, - ICacheService cache) - : INotificationHandler -{ - public async Task Handle(TodoItemUpdated notification, CancellationToken cancellationToken) - { - logger.LogInformation("handling todo item update domain event.."); - var cacheResponse = new GetTodoResponse(notification.item.Id, notification.item.Title, notification.item.Note); - await cache.SetAsync($"todo:{notification.item.Id}", cacheResponse, cancellationToken: cancellationToken); - } -} diff --git a/src/api/modules/Todo/Domain/TodoItem.cs b/src/api/modules/Todo/Domain/TodoItem.cs deleted file mode 100644 index 0e32a2add5..0000000000 --- a/src/api/modules/Todo/Domain/TodoItem.cs +++ /dev/null @@ -1,46 +0,0 @@ -using FSH.Framework.Core.Domain; -using FSH.Framework.Core.Domain.Contracts; -using FSH.Starter.WebApi.Todo.Domain.Events; - -namespace FSH.Starter.WebApi.Todo.Domain; -public sealed class TodoItem : AuditableEntity, IAggregateRoot -{ - public string Title { get; private set; } = string.Empty; - public string Note { get; private set; } = string.Empty; - - private TodoItem() { } - - private TodoItem(string title, string note) - { - Title = title; - Note = note; - QueueDomainEvent(new TodoItemCreated(Id, Title, Note)); - TodoMetrics.Created.Add(1); - } - - public static TodoItem Create(string title, string note) => new(title, note); - - public TodoItem Update(string? title, string? note) - { - bool isUpdated = false; - - if (!string.IsNullOrWhiteSpace(title) && !string.Equals(Title, title, StringComparison.OrdinalIgnoreCase)) - { - Title = title; - isUpdated = true; - } - - if (!string.IsNullOrWhiteSpace(note) && !string.Equals(Note, note, StringComparison.OrdinalIgnoreCase)) - { - Note = note; - isUpdated = true; - } - - if (isUpdated) - { - QueueDomainEvent(new TodoItemUpdated(this)); - } - - return this; - } -} diff --git a/src/api/modules/Todo/Domain/TodoMetrics.cs b/src/api/modules/Todo/Domain/TodoMetrics.cs deleted file mode 100644 index cdd98b8df6..0000000000 --- a/src/api/modules/Todo/Domain/TodoMetrics.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Diagnostics.Metrics; -using FSH.Starter.Aspire.ServiceDefaults; - -namespace FSH.Starter.WebApi.Todo.Domain; - -public static class TodoMetrics -{ - private static readonly Meter Meter = new Meter(MetricsConstants.Todos, "1.0.0"); - public static readonly Counter Created = Meter.CreateCounter("items.created"); - public static readonly Counter Updated = Meter.CreateCounter("items.updated"); - public static readonly Counter Deleted = Meter.CreateCounter("items.deleted"); -} - diff --git a/src/api/modules/Todo/Exceptions/TodoItemNotFoundException.cs b/src/api/modules/Todo/Exceptions/TodoItemNotFoundException.cs deleted file mode 100644 index 39ab54f701..0000000000 --- a/src/api/modules/Todo/Exceptions/TodoItemNotFoundException.cs +++ /dev/null @@ -1,10 +0,0 @@ -using FSH.Framework.Core.Exceptions; - -namespace FSH.Starter.WebApi.Todo.Exceptions; -internal sealed class TodoItemNotFoundException : NotFoundException -{ - public TodoItemNotFoundException(Guid id) - : base($"todo item with id {id} not found") - { - } -} diff --git a/src/api/modules/Todo/Features/Create/v1/CreateTodoCommand.cs b/src/api/modules/Todo/Features/Create/v1/CreateTodoCommand.cs deleted file mode 100644 index 7834dbfe81..0000000000 --- a/src/api/modules/Todo/Features/Create/v1/CreateTodoCommand.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.ComponentModel; -using MediatR; - -namespace FSH.Starter.WebApi.Todo.Features.Create.v1; -public record CreateTodoCommand( - [property: DefaultValue("Hello World!")] string Title, - [property: DefaultValue("Important Note.")] string Note) : IRequest; - - - diff --git a/src/api/modules/Todo/Features/Create/v1/CreateTodoEndpoint.cs b/src/api/modules/Todo/Features/Create/v1/CreateTodoEndpoint.cs deleted file mode 100644 index 7ad7673107..0000000000 --- a/src/api/modules/Todo/Features/Create/v1/CreateTodoEndpoint.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Asp.Versioning; -using FSH.Framework.Infrastructure.Auth.Policy; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Starter.WebApi.Todo.Features.Create.v1; -public static class CreateTodoEndpoint -{ - internal static RouteHandlerBuilder MapTodoItemCreationEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapPost("/", async (CreateTodoCommand request, ISender mediator) => - { - var response = await mediator.Send(request); - return Results.CreatedAtRoute(nameof(CreateTodoEndpoint), new { id = response.Id }, response); - }) - .WithName(nameof(CreateTodoEndpoint)) - .WithSummary("Creates a todo item") - .WithDescription("Creates a todo item") - .Produces(StatusCodes.Status201Created) - .RequirePermission("Permissions.Todos.Create") - .MapToApiVersion(new ApiVersion(1, 0)); - - } -} diff --git a/src/api/modules/Todo/Features/Create/v1/CreateTodoHandler.cs b/src/api/modules/Todo/Features/Create/v1/CreateTodoHandler.cs deleted file mode 100644 index c910b630bd..0000000000 --- a/src/api/modules/Todo/Features/Create/v1/CreateTodoHandler.cs +++ /dev/null @@ -1,22 +0,0 @@ -using FSH.Framework.Core.Persistence; -using FSH.Starter.WebApi.Todo.Domain; -using MediatR; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace FSH.Starter.WebApi.Todo.Features.Create.v1; -public sealed class CreateTodoHandler( - ILogger logger, - [FromKeyedServices("todo")] IRepository repository) - : IRequestHandler -{ - public async Task Handle(CreateTodoCommand request, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request); - var item = TodoItem.Create(request.Title, request.Note); - await repository.AddAsync(item, cancellationToken).ConfigureAwait(false); - await repository.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - logger.LogInformation("todo item created {TodoItemId}", item.Id); - return new CreateTodoResponse(item.Id); - } -} diff --git a/src/api/modules/Todo/Features/Create/v1/CreateTodoResponse.cs b/src/api/modules/Todo/Features/Create/v1/CreateTodoResponse.cs deleted file mode 100644 index d50f053479..0000000000 --- a/src/api/modules/Todo/Features/Create/v1/CreateTodoResponse.cs +++ /dev/null @@ -1,2 +0,0 @@ -namespace FSH.Starter.WebApi.Todo.Features.Create.v1; -public record CreateTodoResponse(Guid? Id); diff --git a/src/api/modules/Todo/Features/Create/v1/CreateTodoValidator.cs b/src/api/modules/Todo/Features/Create/v1/CreateTodoValidator.cs deleted file mode 100644 index 8549f6a3ce..0000000000 --- a/src/api/modules/Todo/Features/Create/v1/CreateTodoValidator.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FluentValidation; -using FSH.Starter.WebApi.Todo.Persistence; - -namespace FSH.Starter.WebApi.Todo.Features.Create.v1; -public class CreateTodoValidator : AbstractValidator -{ - public CreateTodoValidator(TodoDbContext context) - { - RuleFor(p => p.Title).NotEmpty(); - RuleFor(p => p.Note).NotEmpty(); - } -} diff --git a/src/api/modules/Todo/Features/Delete/v1/DeleteTodoCommand.cs b/src/api/modules/Todo/Features/Delete/v1/DeleteTodoCommand.cs deleted file mode 100644 index 86388518a4..0000000000 --- a/src/api/modules/Todo/Features/Delete/v1/DeleteTodoCommand.cs +++ /dev/null @@ -1,8 +0,0 @@ -using MediatR; - -namespace FSH.Starter.WebApi.Todo.Features.Delete.v1; -public sealed record DeleteTodoCommand( - Guid Id) : IRequest; - - - diff --git a/src/api/modules/Todo/Features/Delete/v1/DeleteTodoEndpoint.cs b/src/api/modules/Todo/Features/Delete/v1/DeleteTodoEndpoint.cs deleted file mode 100644 index b73b0658d9..0000000000 --- a/src/api/modules/Todo/Features/Delete/v1/DeleteTodoEndpoint.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Asp.Versioning; -using FSH.Framework.Infrastructure.Auth.Policy; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Starter.WebApi.Todo.Features.Delete.v1; -public static class DeleteTodoEndpoint -{ - internal static RouteHandlerBuilder MapTodoItemDeletionEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints - .MapDelete("/{id:guid}", async (Guid id, ISender mediator) => - { - await mediator.Send(new DeleteTodoCommand(id)); - return Results.NoContent(); - }) - .WithName(nameof(DeleteTodoEndpoint)) - .WithSummary("Deletes a todo item") - .WithDescription("Deleted a todo item") - .Produces(StatusCodes.Status204NoContent) - .RequirePermission("Permissions.Todos.Delete") - .MapToApiVersion(new ApiVersion(1, 0)); - - } -} diff --git a/src/api/modules/Todo/Features/Delete/v1/DeleteTodoHandler.cs b/src/api/modules/Todo/Features/Delete/v1/DeleteTodoHandler.cs deleted file mode 100644 index 0574538757..0000000000 --- a/src/api/modules/Todo/Features/Delete/v1/DeleteTodoHandler.cs +++ /dev/null @@ -1,22 +0,0 @@ -using FSH.Framework.Core.Persistence; -using FSH.Starter.WebApi.Todo.Domain; -using FSH.Starter.WebApi.Todo.Exceptions; -using MediatR; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace FSH.Starter.WebApi.Todo.Features.Delete.v1; -public sealed class DeleteTodoHandler( - ILogger logger, - [FromKeyedServices("todo")] IRepository repository) - : IRequestHandler -{ - public async Task Handle(DeleteTodoCommand request, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request); - var todoItem = await repository.GetByIdAsync(request.Id, cancellationToken); - _ = todoItem ?? throw new TodoItemNotFoundException(request.Id); - await repository.DeleteAsync(todoItem, cancellationToken); - logger.LogInformation("todo with id : {TodoId} deleted", todoItem.Id); - } -} diff --git a/src/api/modules/Todo/Features/Get/v1/GetTodoEndpoint.cs b/src/api/modules/Todo/Features/Get/v1/GetTodoEndpoint.cs deleted file mode 100644 index 1f039d9d73..0000000000 --- a/src/api/modules/Todo/Features/Get/v1/GetTodoEndpoint.cs +++ /dev/null @@ -1,24 +0,0 @@ -using FSH.Framework.Infrastructure.Auth.Policy; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Starter.WebApi.Todo.Features.Get.v1; -public static class GetTodoEndpoint -{ - internal static RouteHandlerBuilder MapGetTodoEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapGet("/{id:guid}", async (Guid id, ISender mediator) => - { - var response = await mediator.Send(new GetTodoRequest(id)); - return Results.Ok(response); - }) - .WithName(nameof(GetTodoEndpoint)) - .WithSummary("gets todo item by id") - .WithDescription("gets todo item by id") - .Produces() - .RequirePermission("Permissions.Todos.View") - .MapToApiVersion(1); - } -} diff --git a/src/api/modules/Todo/Features/Get/v1/GetTodoHandler.cs b/src/api/modules/Todo/Features/Get/v1/GetTodoHandler.cs deleted file mode 100644 index 9d7d679363..0000000000 --- a/src/api/modules/Todo/Features/Get/v1/GetTodoHandler.cs +++ /dev/null @@ -1,28 +0,0 @@ -using FSH.Framework.Core.Caching; -using FSH.Framework.Core.Persistence; -using FSH.Starter.WebApi.Todo.Domain; -using FSH.Starter.WebApi.Todo.Exceptions; -using MediatR; -using Microsoft.Extensions.DependencyInjection; - -namespace FSH.Starter.WebApi.Todo.Features.Get.v1; -public sealed class GetTodoHandler( - [FromKeyedServices("todo")] IReadRepository repository, - ICacheService cache) - : IRequestHandler -{ - public async Task Handle(GetTodoRequest request, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request); - var item = await cache.GetOrSetAsync( - $"todo:{request.Id}", - async () => - { - var todoItem = await repository.GetByIdAsync(request.Id, cancellationToken); - if (todoItem == null) throw new TodoItemNotFoundException(request.Id); - return new GetTodoResponse(todoItem.Id, todoItem.Title!, todoItem.Note!); - }, - cancellationToken: cancellationToken); - return item!; - } -} diff --git a/src/api/modules/Todo/Features/Get/v1/GetTodoRequest.cs b/src/api/modules/Todo/Features/Get/v1/GetTodoRequest.cs deleted file mode 100644 index 6569694ca5..0000000000 --- a/src/api/modules/Todo/Features/Get/v1/GetTodoRequest.cs +++ /dev/null @@ -1,8 +0,0 @@ -using MediatR; - -namespace FSH.Starter.WebApi.Todo.Features.Get.v1; -public class GetTodoRequest : IRequest -{ - public Guid Id { get; set; } - public GetTodoRequest(Guid id) => Id = id; -} diff --git a/src/api/modules/Todo/Features/Get/v1/GetTodoResponse.cs b/src/api/modules/Todo/Features/Get/v1/GetTodoResponse.cs deleted file mode 100644 index 910288c95d..0000000000 --- a/src/api/modules/Todo/Features/Get/v1/GetTodoResponse.cs +++ /dev/null @@ -1,2 +0,0 @@ -namespace FSH.Starter.WebApi.Todo.Features.Get.v1; -public record GetTodoResponse(Guid? Id, string? Title, string? Note); diff --git a/src/api/modules/Todo/Features/GetList/v1/GetTodoListEndpoint.cs b/src/api/modules/Todo/Features/GetList/v1/GetTodoListEndpoint.cs deleted file mode 100644 index d183c3e33b..0000000000 --- a/src/api/modules/Todo/Features/GetList/v1/GetTodoListEndpoint.cs +++ /dev/null @@ -1,27 +0,0 @@ -using FSH.Framework.Core.Paging; -using FSH.Framework.Infrastructure.Auth.Policy; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Starter.WebApi.Todo.Features.GetList.v1; - -public static class GetTodoListEndpoint -{ - internal static RouteHandlerBuilder MapGetTodoListEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapPost("/search", async (ISender mediator, [FromBody] PaginationFilter filter) => - { - var response = await mediator.Send(new GetTodoListRequest(filter)); - return Results.Ok(response); - }) - .WithName(nameof(GetTodoListEndpoint)) - .WithSummary("Gets a list of todo items with paging support") - .WithDescription("Gets a list of todo items with paging support") - .Produces>() - .RequirePermission("Permissions.Todos.View") - .MapToApiVersion(1); - } -} diff --git a/src/api/modules/Todo/Features/GetList/v1/GetTodoListHandler.cs b/src/api/modules/Todo/Features/GetList/v1/GetTodoListHandler.cs deleted file mode 100644 index d9ad5479e9..0000000000 --- a/src/api/modules/Todo/Features/GetList/v1/GetTodoListHandler.cs +++ /dev/null @@ -1,25 +0,0 @@ -using FSH.Framework.Core.Paging; -using FSH.Framework.Core.Persistence; -using FSH.Framework.Core.Specifications; -using FSH.Starter.WebApi.Todo.Domain; -using MediatR; -using Microsoft.Extensions.DependencyInjection; - -namespace FSH.Starter.WebApi.Todo.Features.GetList.v1; - -public sealed class GetTodoListHandler( - [FromKeyedServices("todo")] IReadRepository repository) - : IRequestHandler> -{ - public async Task> Handle(GetTodoListRequest request, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request); - - var spec = new EntitiesByPaginationFilterSpec(request.Filter); - - var items = await repository.ListAsync(spec, cancellationToken).ConfigureAwait(false); - var totalCount = await repository.CountAsync(spec, cancellationToken).ConfigureAwait(false); - - return new PagedList(items, request.Filter.PageNumber, request.Filter.PageSize, totalCount); - } -} diff --git a/src/api/modules/Todo/Features/GetList/v1/GetTodoListRequest.cs b/src/api/modules/Todo/Features/GetList/v1/GetTodoListRequest.cs deleted file mode 100644 index 349fb44a88..0000000000 --- a/src/api/modules/Todo/Features/GetList/v1/GetTodoListRequest.cs +++ /dev/null @@ -1,5 +0,0 @@ -using FSH.Framework.Core.Paging; -using MediatR; - -namespace FSH.Starter.WebApi.Todo.Features.GetList.v1; -public record GetTodoListRequest(PaginationFilter Filter) : IRequest>; diff --git a/src/api/modules/Todo/Features/GetList/v1/TodoDto.cs b/src/api/modules/Todo/Features/GetList/v1/TodoDto.cs deleted file mode 100644 index 869d34eb99..0000000000 --- a/src/api/modules/Todo/Features/GetList/v1/TodoDto.cs +++ /dev/null @@ -1,2 +0,0 @@ -namespace FSH.Starter.WebApi.Todo.Features.GetList.v1; -public record TodoDto(Guid? Id, string Title, string Note); diff --git a/src/api/modules/Todo/Features/Update/v1/UpdateTodoCommand.cs b/src/api/modules/Todo/Features/Update/v1/UpdateTodoCommand.cs deleted file mode 100644 index 89b30556c1..0000000000 --- a/src/api/modules/Todo/Features/Update/v1/UpdateTodoCommand.cs +++ /dev/null @@ -1,10 +0,0 @@ -using MediatR; - -namespace FSH.Starter.WebApi.Todo.Features.Update.v1; -public sealed record UpdateTodoCommand( - Guid Id, - string? Title, - string? Note = null): IRequest; - - - diff --git a/src/api/modules/Todo/Features/Update/v1/UpdateTodoEndpoint.cs b/src/api/modules/Todo/Features/Update/v1/UpdateTodoEndpoint.cs deleted file mode 100644 index a19b9ba901..0000000000 --- a/src/api/modules/Todo/Features/Update/v1/UpdateTodoEndpoint.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Asp.Versioning; -using FSH.Framework.Infrastructure.Auth.Policy; -using MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace FSH.Starter.WebApi.Todo.Features.Update.v1; -public static class UpdateTodoEndpoint -{ - internal static RouteHandlerBuilder MapTodoItemUpdationEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints. - MapPut("/{id:guid}", async (Guid id, UpdateTodoCommand request, ISender mediator) => - { - if (id != request.Id) return Results.BadRequest(); - var response = await mediator.Send(request); - return Results.Ok(response); - }) - .WithName(nameof(UpdateTodoEndpoint)) - .WithSummary("Updates a todo item") - .WithDescription("Updated a todo item") - .Produces(StatusCodes.Status200OK) - .RequirePermission("Permissions.Todos.Update") - .MapToApiVersion(new ApiVersion(1, 0)); - - } -} diff --git a/src/api/modules/Todo/Features/Update/v1/UpdateTodoHandler.cs b/src/api/modules/Todo/Features/Update/v1/UpdateTodoHandler.cs deleted file mode 100644 index fa1c26653d..0000000000 --- a/src/api/modules/Todo/Features/Update/v1/UpdateTodoHandler.cs +++ /dev/null @@ -1,24 +0,0 @@ -using FSH.Framework.Core.Persistence; -using FSH.Starter.WebApi.Todo.Domain; -using FSH.Starter.WebApi.Todo.Exceptions; -using MediatR; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace FSH.Starter.WebApi.Todo.Features.Update.v1; -public sealed class UpdateTodoHandler( - ILogger logger, - [FromKeyedServices("todo")] IRepository repository) - : IRequestHandler -{ - public async Task Handle(UpdateTodoCommand request, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request); - var todo = await repository.GetByIdAsync(request.Id, cancellationToken); - _ = todo ?? throw new TodoItemNotFoundException(request.Id); - var updatedTodo = todo.Update(request.Title, request.Note); - await repository.UpdateAsync(updatedTodo, cancellationToken); - logger.LogInformation("todo item updated {TodoItemId}", updatedTodo.Id); - return new UpdateTodoResponse(updatedTodo.Id); - } -} diff --git a/src/api/modules/Todo/Features/Update/v1/UpdateTodoResponse.cs b/src/api/modules/Todo/Features/Update/v1/UpdateTodoResponse.cs deleted file mode 100644 index b97b5c283f..0000000000 --- a/src/api/modules/Todo/Features/Update/v1/UpdateTodoResponse.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace FSH.Starter.WebApi.Todo.Features.Update.v1; -public record UpdateTodoResponse(Guid? Id); - diff --git a/src/api/modules/Todo/Features/Update/v1/UpdateTodoValidator.cs b/src/api/modules/Todo/Features/Update/v1/UpdateTodoValidator.cs deleted file mode 100644 index 9cf5935b6f..0000000000 --- a/src/api/modules/Todo/Features/Update/v1/UpdateTodoValidator.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FluentValidation; -using FSH.Starter.WebApi.Todo.Persistence; - -namespace FSH.Starter.WebApi.Todo.Features.Update.v1; -public class UpdateTodoValidator : AbstractValidator -{ - public UpdateTodoValidator(TodoDbContext context) - { - RuleFor(p => p.Title).NotEmpty(); - RuleFor(p => p.Note).NotEmpty(); - } -} diff --git a/src/api/modules/Todo/Persistence/Configurations/TodoItemConfiguration.cs b/src/api/modules/Todo/Persistence/Configurations/TodoItemConfiguration.cs deleted file mode 100644 index 7b9a290db4..0000000000 --- a/src/api/modules/Todo/Persistence/Configurations/TodoItemConfiguration.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Finbuckle.MultiTenant; -using FSH.Starter.WebApi.Todo.Domain; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace FSH.Starter.WebApi.Todo.Persistence.Configurations; -internal sealed class TodoItemConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.IsMultiTenant(); - builder.HasKey(x => x.Id); - builder.Property(x => x.Title).HasMaxLength(100); - builder.Property(x => x.Note).HasMaxLength(1000); - } -} diff --git a/src/api/modules/Todo/Persistence/TodoDbContext.cs b/src/api/modules/Todo/Persistence/TodoDbContext.cs deleted file mode 100644 index e802801d4d..0000000000 --- a/src/api/modules/Todo/Persistence/TodoDbContext.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Finbuckle.MultiTenant.Abstractions; -using FSH.Framework.Core.Persistence; -using FSH.Framework.Infrastructure.Persistence; -using FSH.Framework.Infrastructure.Tenant; -using FSH.Starter.WebApi.Todo.Domain; -using MediatR; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; -using Shared.Constants; - -namespace FSH.Starter.WebApi.Todo.Persistence; -public sealed class TodoDbContext : FshDbContext -{ - public TodoDbContext(IMultiTenantContextAccessor multiTenantContextAccessor, DbContextOptions options, IPublisher publisher, IOptions settings) - : base(multiTenantContextAccessor, options, publisher, settings) - { - } - - public DbSet Todos { get; set; } = null!; - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - ArgumentNullException.ThrowIfNull(modelBuilder); - modelBuilder.ApplyConfigurationsFromAssembly(typeof(TodoDbContext).Assembly); - modelBuilder.HasDefaultSchema(SchemaNames.Todo); - } -} diff --git a/src/api/modules/Todo/Persistence/TodoDbInitializer.cs b/src/api/modules/Todo/Persistence/TodoDbInitializer.cs deleted file mode 100644 index 77f37affb0..0000000000 --- a/src/api/modules/Todo/Persistence/TodoDbInitializer.cs +++ /dev/null @@ -1,32 +0,0 @@ -using FSH.Framework.Core.Persistence; -using FSH.Starter.WebApi.Todo.Domain; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; - -namespace FSH.Starter.WebApi.Todo.Persistence; -internal sealed class TodoDbInitializer( - ILogger logger, - TodoDbContext context) : IDbInitializer -{ - public async Task MigrateAsync(CancellationToken cancellationToken) - { - if ((await context.Database.GetPendingMigrationsAsync(cancellationToken).ConfigureAwait(false)).Any()) - { - await context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false); - logger.LogInformation("[{Tenant}] applied database migrations for todo module", context.TenantInfo!.Identifier); - } - } - - public async Task SeedAsync(CancellationToken cancellationToken) - { - const string title = "Hello World!"; - const string note = "This is your first task"; - if (await context.Todos.FirstOrDefaultAsync(t => t.Title == title, cancellationToken).ConfigureAwait(false) is null) - { - var todo = TodoItem.Create(title, note); - await context.Todos.AddAsync(todo, cancellationToken); - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - logger.LogInformation("[{Tenant}] seeding default todo data", context.TenantInfo!.Identifier); - } - } -} diff --git a/src/api/modules/Todo/Persistence/TodoRepository.cs b/src/api/modules/Todo/Persistence/TodoRepository.cs deleted file mode 100644 index ff3904c568..0000000000 --- a/src/api/modules/Todo/Persistence/TodoRepository.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Ardalis.Specification; -using Ardalis.Specification.EntityFrameworkCore; -using FSH.Framework.Core.Domain.Contracts; -using FSH.Framework.Core.Persistence; -using Mapster; - -namespace FSH.Starter.WebApi.Todo.Persistence; -internal sealed class TodoRepository : RepositoryBase, IReadRepository, IRepository - where T : class, IAggregateRoot -{ - public TodoRepository(TodoDbContext context) - : base(context) - { - } - - // We override the default behavior when mapping to a dto. - // We're using Mapster's ProjectToType here to immediately map the result from the database. - // This is only done when no Selector is defined, so regular specifications with a selector also still work. - protected override IQueryable ApplySpecification(ISpecification specification) => - specification.Selector is not null - ? base.ApplySpecification(specification) - : ApplySpecification(specification, false) - .ProjectToType(); -} diff --git a/src/api/modules/Todo/Todo.csproj b/src/api/modules/Todo/Todo.csproj deleted file mode 100644 index d6a5873721..0000000000 --- a/src/api/modules/Todo/Todo.csproj +++ /dev/null @@ -1,10 +0,0 @@ - - - FSH.Starter.WebApi.Todo - FSH.Starter.WebApi.Todo - - - - - - diff --git a/src/api/modules/Todo/TodoModule.cs b/src/api/modules/Todo/TodoModule.cs deleted file mode 100644 index 558d9526f0..0000000000 --- a/src/api/modules/Todo/TodoModule.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Carter; -using FSH.Framework.Core.Persistence; -using FSH.Framework.Infrastructure.Persistence; -using FSH.Starter.WebApi.Todo.Domain; -using FSH.Starter.WebApi.Todo.Features.Create.v1; -using FSH.Starter.WebApi.Todo.Features.Delete.v1; -using FSH.Starter.WebApi.Todo.Features.Get.v1; -using FSH.Starter.WebApi.Todo.Features.GetList.v1; -using FSH.Starter.WebApi.Todo.Features.Update.v1; -using FSH.Starter.WebApi.Todo.Persistence; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; - -namespace FSH.Starter.WebApi.Todo; -public static class TodoModule -{ - - public class Endpoints : CarterModule - { - public override void AddRoutes(IEndpointRouteBuilder app) - { - var todoGroup = app.MapGroup("todos").WithTags("todos"); - todoGroup.MapTodoItemCreationEndpoint(); - todoGroup.MapGetTodoEndpoint(); - todoGroup.MapGetTodoListEndpoint(); - todoGroup.MapTodoItemUpdationEndpoint(); - todoGroup.MapTodoItemDeletionEndpoint(); - } - } - public static WebApplicationBuilder RegisterTodoServices(this WebApplicationBuilder builder) - { - ArgumentNullException.ThrowIfNull(builder); - builder.Services.BindDbContext(); - builder.Services.AddScoped(); - builder.Services.AddKeyedScoped, TodoRepository>("todo"); - builder.Services.AddKeyedScoped, TodoRepository>("todo"); - return builder; - } - public static WebApplication UseTodoModule(this WebApplication app) - { - return app; - } -} diff --git a/src/api/server/Extensions.cs b/src/api/server/Extensions.cs deleted file mode 100644 index cc2f773e80..0000000000 --- a/src/api/server/Extensions.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Reflection; -using Asp.Versioning.Conventions; -using Carter; -using FluentValidation; -using FSH.Starter.WebApi.Catalog.Application; -using FSH.Starter.WebApi.Catalog.Infrastructure; -using FSH.Starter.WebApi.Todo; - -namespace FSH.Starter.WebApi.Host; - -public static class Extensions -{ - public static WebApplicationBuilder RegisterModules(this WebApplicationBuilder builder) - { - ArgumentNullException.ThrowIfNull(builder); - - //define module assemblies - var assemblies = new Assembly[] - { - typeof(CatalogMetadata).Assembly, - typeof(TodoModule).Assembly - }; - - //register validators - builder.Services.AddValidatorsFromAssemblies(assemblies); - - //register mediatr - builder.Services.AddMediatR(cfg => - { - cfg.RegisterServicesFromAssemblies(assemblies); - }); - - //register module services - builder.RegisterCatalogServices(); - builder.RegisterTodoServices(); - - //add carter endpoint modules - builder.Services.AddCarter(configurator: config => - { - config.WithModule(); - config.WithModule(); - }); - - return builder; - } - - public static WebApplication UseModules(this WebApplication app) - { - ArgumentNullException.ThrowIfNull(app); - - //register modules - app.UseCatalogModule(); - app.UseTodoModule(); - - //register api versions - var versions = app.NewApiVersionSet() - .HasApiVersion(1) - .HasApiVersion(2) - .ReportApiVersions() - .Build(); - - //map versioned endpoint - var endpoints = app.MapGroup("api/v{version:apiVersion}").WithApiVersionSet(versions); - - //use carter - endpoints.MapCarter(); - - return app; - } -} diff --git a/src/api/server/Program.cs b/src/api/server/Program.cs deleted file mode 100644 index f8f65e281c..0000000000 --- a/src/api/server/Program.cs +++ /dev/null @@ -1,30 +0,0 @@ -using FSH.Framework.Infrastructure; -using FSH.Framework.Infrastructure.Logging.Serilog; -using FSH.Starter.WebApi.Host; -using Serilog; - -StaticLogger.EnsureInitialized(); -Log.Information("server booting up.."); -try -{ - var builder = WebApplication.CreateBuilder(args); - builder.ConfigureFshFramework(); - builder.RegisterModules(); - - var app = builder.Build(); - - app.UseFshFramework(); - app.UseModules(); - await app.RunAsync(); -} -catch (Exception ex) when (!ex.GetType().Name.Equals("HostAbortedException", StringComparison.Ordinal)) -{ - StaticLogger.EnsureInitialized(); - Log.Fatal(ex.Message, "unhandled exception"); -} -finally -{ - StaticLogger.EnsureInitialized(); - Log.Information("server shutting down.."); - await Log.CloseAndFlushAsync(); -} diff --git a/src/api/server/Properties/launchSettings.json b/src/api/server/Properties/launchSettings.json deleted file mode 100644 index 0ee67a65b6..0000000000 --- a/src/api/server/Properties/launchSettings.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "profiles": { - "Kestrel": { - "commandName": "Project", - "launchBrowser": true, - "launchUrl": "swagger", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317", - "OTEL_SERVICE_NAME": "FSH.Starter.WebApi.Host" - }, - "dotnetRunMessages": true, - "applicationUrl": "https://localhost:7000;http://localhost:5000" - } - }, - "$schema": "http://json.schemastore.org/launchsettings.json" -} \ No newline at end of file diff --git a/src/api/server/Server.csproj b/src/api/server/Server.csproj deleted file mode 100644 index 11c255c90d..0000000000 --- a/src/api/server/Server.csproj +++ /dev/null @@ -1,37 +0,0 @@ - - - FSH.Starter.WebApi.Host - FSH.Starter.WebApi.Host - root - - - webapi - DefaultContainer - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - Always - - - Always - - - Always - - - diff --git a/src/api/server/Server.http b/src/api/server/Server.http deleted file mode 100644 index 9a38128e9d..0000000000 --- a/src/api/server/Server.http +++ /dev/null @@ -1,6 +0,0 @@ -@Server_HostAddress = http://localhost:5000 - -GET {{Server_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/src/api/server/appsettings.Development.json b/src/api/server/appsettings.Development.json deleted file mode 100644 index 351acfd5df..0000000000 --- a/src/api/server/appsettings.Development.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "DatabaseOptions": { - "Provider": "postgresql", - }, - "OriginOptions": { - "OriginUrl": "https://localhost:7000" - }, - "CacheOptions": { - "Redis": "" - }, - "HangfireOptions": { - "Username": "admin", - "Password": "Secure1234!Me", - "Route": "/jobs" - }, - "JwtOptions": { - "Key": "QsJbczCNysv/5SGh+U7sxedX8C07TPQPBdsnSDKZ/aE=", - "TokenExpirationInMinutes": 60, - "RefreshTokenExpirationInDays": 7 - }, - "MailOptions": { - "From": "mukesh@fullstackhero.net", - "Host": "smtp.ethereal.email", - "Port": 587, - "DisplayName": "Mukesh Murugan" - }, - "CorsOptions": { - "AllowedOrigins": [ - "https://localhost:7100", - "http://localhost:7100", - "http://localhost:5010" - ] - }, - "Serilog": { - "Using": [ - "Serilog.Sinks.Console" - ], - "MinimumLevel": { - "Default": "Debug" - }, - "WriteTo": [ - { - "Name": "Console" - } - ] - }, - "RateLimitOptions": { - "EnableRateLimiting": false, - "PermitLimit": 5, - "WindowInSeconds": 10, - "RejectionStatusCode": 429 - }, - "SecurityHeaderOptions": { - "Enable": true, - "Headers": { - "XContentTypeOptions": "nosniff", - "ReferrerPolicy": "no-referrer", - "XXSSProtection": "1; mode=block", - "XFrameOptions": "DENY", - "ContentSecurityPolicy": "block-all-mixed-content; style-src 'self' 'unsafe-inline'; font-src 'self'; form-action 'self'; frame-ancestors 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline'", - "PermissionsPolicy": "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()", - "StrictTransportSecurity": "max-age=31536000" - } - } -} \ No newline at end of file diff --git a/src/api/server/appsettings.json b/src/api/server/appsettings.json deleted file mode 100644 index 94292c5c98..0000000000 --- a/src/api/server/appsettings.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "DatabaseOptions": { - "Provider": "postgresql", - "ConnectionString": "Server=localhost;Database=fullstackhero;Port=5433;User Id=pgadmin;Password=pgadmin;" - }, - "OriginOptions": { - "OriginUrl": "https://localhost:7000" - }, - "CacheOptions": { - "Redis": "" - }, - "HangfireOptions": { - "Username": "admin", - "Password": "Secure1234!Me", - "Route": "/jobs" - }, - "JwtOptions": { - "Key": "QsJbczCNysv/5SGh+U7sxedX8C07TPQPBdsnSDKZ/aE=", - "TokenExpirationInMinutes": 10, - "RefreshTokenExpirationInDays": 7 - }, - "MailOptions": { - "From": "mukesh@fullstackhero.net", - "Host": "smtp.ethereal.email", - "Port": 587, - "UserName": "ruth.ruecker@ethereal.email", - "Password": "wygzuX6kpcK6AfDJcd", - "DisplayName": "Mukesh Murugan" - }, - "CorsOptions": { - "AllowedOrigins": [ - "https://localhost:7100", - "http://localhost:7100", - "http://localhost:5010" - ] - }, - "Serilog": { - "Using": [ - "Serilog.Sinks.Console" - ], - "MinimumLevel": { - "Default": "Debug" - }, - "WriteTo": [ - { - "Name": "Console" - } - ] - }, - "RateLimitOptions": { - "EnableRateLimiting": false, - "PermitLimit": 5, - "WindowInSeconds": 10, - "RejectionStatusCode": 429 - }, - "SecurityHeaderOptions": { - "Enable": true, - "Headers": { - "XContentTypeOptions": "nosniff", - "ReferrerPolicy": "no-referrer", - "XXSSProtection": "1; mode=block", - "XFrameOptions": "DENY", - "ContentSecurityPolicy": "block-all-mixed-content; style-src 'self' 'unsafe-inline'; font-src 'self'; form-action 'self'; frame-ancestors 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline'", - "PermissionsPolicy": "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()", - "StrictTransportSecurity": "max-age=31536000" - } - } -} \ No newline at end of file diff --git a/src/api/server/assets/defaults/profile-picture.webp b/src/api/server/assets/defaults/profile-picture.webp deleted file mode 100644 index d0922c3f68..0000000000 Binary files a/src/api/server/assets/defaults/profile-picture.webp and /dev/null differ diff --git a/src/apps/blazor/client/App.razor b/src/apps/blazor/client/App.razor deleted file mode 100644 index 95fc72e0f0..0000000000 --- a/src/apps/blazor/client/App.razor +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - @if (@context.User.Identity?.IsAuthenticated is true) - { -

You are not authorized to be here.

- } - else - { - - } -
-
-
- - - - - -
-
\ No newline at end of file diff --git a/src/apps/blazor/client/Client.csproj b/src/apps/blazor/client/Client.csproj deleted file mode 100644 index 9b732733b7..0000000000 --- a/src/apps/blazor/client/Client.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - - net9.0 - enable - enable - FSH.Starter.Blazor.Client - FSH.Starter.Blazor.Client - service-worker-assets.js - - - - - - - - - - - - - - - - - - diff --git a/src/apps/blazor/client/Components/ApiHelper.cs b/src/apps/blazor/client/Components/ApiHelper.cs deleted file mode 100644 index 8c6522a460..0000000000 --- a/src/apps/blazor/client/Components/ApiHelper.cs +++ /dev/null @@ -1,70 +0,0 @@ -using FSH.Starter.Blazor.Infrastructure.Api; -using Microsoft.AspNetCore.Components; -using MudBlazor; - -namespace FSH.Starter.Blazor.Client.Components; - -public static class ApiHelper -{ - public static async Task ExecuteCallGuardedAsync( - Func> call, - ISnackbar snackbar, - NavigationManager navigationManager, - FshValidation? customValidation = null, - string? successMessage = null) - { - customValidation?.ClearErrors(); - try - { - var result = await call(); - - if (!string.IsNullOrWhiteSpace(successMessage)) - { - snackbar.Add(successMessage, Severity.Info); - } - - return result; - } - catch (ApiException ex) - { - if (ex.StatusCode == 401) - { - navigationManager.NavigateTo("/logout"); - } - var message = ex.Message switch - { - "TypeError: Failed to fetch" => "Unable to Reach API", - _ => ex.Message - }; - snackbar.Add(message, Severity.Error); - } - - return default; - } - - public static async Task ExecuteCallGuardedAsync( - Func call, - ISnackbar snackbar, - FshValidation? customValidation = null, - string? successMessage = null) - { - customValidation?.ClearErrors(); - try - { - await call(); - - if (!string.IsNullOrWhiteSpace(successMessage)) - { - snackbar.Add(successMessage, Severity.Success); - } - - return true; - } - catch (ApiException ex) - { - snackbar.Add(ex.Message, Severity.Error); - } - - return false; - } -} diff --git a/src/apps/blazor/client/Components/Common/CustomValidation.cs b/src/apps/blazor/client/Components/Common/CustomValidation.cs deleted file mode 100644 index 66ea933dad..0000000000 --- a/src/apps/blazor/client/Components/Common/CustomValidation.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Forms; - -namespace FSH.Starter.Blazor.Client.Components.Common; - -// See https://docs.microsoft.com/en-us/aspnet/core/blazor/forms-validation?view=aspnetcore-6.0#server-validation-with-a-validator-component -public class CustomValidation : ComponentBase -{ - private ValidationMessageStore? _messageStore; - - [CascadingParameter] - private EditContext? CurrentEditContext { get; set; } - - protected override void OnInitialized() - { - if (CurrentEditContext is null) - { - throw new InvalidOperationException( - $"{nameof(CustomValidation)} requires a cascading " + - $"parameter of type {nameof(EditContext)}. " + - $"For example, you can use {nameof(CustomValidation)} " + - $"inside an {nameof(EditForm)}."); - } - - _messageStore = new(CurrentEditContext); - - CurrentEditContext.OnValidationRequested += (s, e) => - _messageStore?.Clear(); - CurrentEditContext.OnFieldChanged += (s, e) => - _messageStore?.Clear(e.FieldIdentifier); - } - - public void DisplayErrors(IDictionary> errors) - { - if (CurrentEditContext is not null && errors is not null) - { - foreach (var err in errors) - { - _messageStore?.Add(CurrentEditContext.Field(err.Key), err.Value); - } - - CurrentEditContext.NotifyValidationStateChanged(); - } - } - - public void ClearErrors() - { - _messageStore?.Clear(); - CurrentEditContext?.NotifyValidationStateChanged(); - } -} \ No newline at end of file diff --git a/src/apps/blazor/client/Components/Common/DialogServiceExtensions.cs b/src/apps/blazor/client/Components/Common/DialogServiceExtensions.cs deleted file mode 100644 index 90fce2c71a..0000000000 --- a/src/apps/blazor/client/Components/Common/DialogServiceExtensions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Microsoft.AspNetCore.Components; -using MudBlazor; - -namespace FSH.Starter.Blazor.Client.Components.Common; - -public static class DialogServiceExtensions -{ - public static Task ShowModalAsync(this IDialogService dialogService, DialogParameters parameters) - where TDialog : ComponentBase => - dialogService.ShowModal(parameters).Result!; - - public static IDialogReference ShowModal(this IDialogService dialogService, DialogParameters parameters) - where TDialog : ComponentBase - { - var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Medium, FullWidth = true, BackdropClick = false }; - - return dialogService.Show(string.Empty, parameters, options); - } -} \ No newline at end of file diff --git a/src/apps/blazor/client/Components/Common/FshCustomError.razor b/src/apps/blazor/client/Components/Common/FshCustomError.razor deleted file mode 100644 index 827318bbf1..0000000000 --- a/src/apps/blazor/client/Components/Common/FshCustomError.razor +++ /dev/null @@ -1 +0,0 @@ -Oopsie !! 😔 \ No newline at end of file diff --git a/src/apps/blazor/client/Components/Common/FshTable.cs b/src/apps/blazor/client/Components/Common/FshTable.cs deleted file mode 100644 index 3ba9fdf2a4..0000000000 --- a/src/apps/blazor/client/Components/Common/FshTable.cs +++ /dev/null @@ -1,40 +0,0 @@ -using FSH.Starter.Blazor.Infrastructure.Notifications; -using FSH.Starter.Blazor.Infrastructure.Preferences; -using MediatR.Courier; -using Microsoft.AspNetCore.Components; -using MudBlazor; - -namespace FSH.Starter.Blazor.Client.Components.Common; - -public class FshTable : MudTable -{ - [Inject] - private IClientPreferenceManager ClientPreferences { get; set; } = default!; - [Inject] - protected ICourier Courier { get; set; } = default!; - - - protected override async Task OnInitializedAsync() - { - if (await ClientPreferences.GetPreference() is ClientPreference clientPreference) - { - SetTablePreference(clientPreference.TablePreference); - } - - Courier.SubscribeWeak>(wrapper => - { - SetTablePreference(wrapper.Notification); - StateHasChanged(); - }); - - await base.OnInitializedAsync(); - } - - private void SetTablePreference(FshTablePreference tablePreference) - { - Dense = tablePreference.IsDense; - Striped = tablePreference.IsStriped; - Bordered = tablePreference.HasBorder; - Hover = tablePreference.IsHoverable; - } -} diff --git a/src/apps/blazor/client/Components/Common/TablePager.razor b/src/apps/blazor/client/Components/Common/TablePager.razor deleted file mode 100644 index 71bb4ed697..0000000000 --- a/src/apps/blazor/client/Components/Common/TablePager.razor +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/apps/blazor/client/Components/Dialogs/DeleteConfirmation.razor b/src/apps/blazor/client/Components/Dialogs/DeleteConfirmation.razor deleted file mode 100644 index f69c2336e6..0000000000 --- a/src/apps/blazor/client/Components/Dialogs/DeleteConfirmation.razor +++ /dev/null @@ -1,29 +0,0 @@ - - - - - Delete Confirmation - - - - @ContentText - - - Cancel - Confirm - - - -@code { - [CascadingParameter] - IMudDialogInstance MudDialog { get; set; } = default!; - - [Parameter] - public string? ContentText { get; set; } - - void Submit() - { - MudDialog.Close(DialogResult.Ok(true)); - } - void Cancel() => MudDialog.Cancel(); -} \ No newline at end of file diff --git a/src/apps/blazor/client/Components/Dialogs/Logout.razor b/src/apps/blazor/client/Components/Dialogs/Logout.razor deleted file mode 100644 index 5a0d76f1fb..0000000000 --- a/src/apps/blazor/client/Components/Dialogs/Logout.razor +++ /dev/null @@ -1,39 +0,0 @@ -@namespace FSH.Starter.Blazor.Client.Components.Dialogs - - -@inject IAuthenticationService AuthService - - - - - - Logout Confirmation - - - - @ContentText - - - Cancel - @ButtonText - - - -@code { - [Parameter] public string? ContentText { get; set; } - - [Parameter] public string? ButtonText { get; set; } - - [Parameter] public Color Color { get; set; } - - [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; - - async Task Submit() - { - Navigation.NavigateTo("/logout"); - MudDialog.Close(DialogResult.Ok(true)); - } - - void Cancel() => - MudDialog.Cancel(); -} \ No newline at end of file diff --git a/src/apps/blazor/client/Components/EntityTable/AddEditModal.razor b/src/apps/blazor/client/Components/EntityTable/AddEditModal.razor deleted file mode 100644 index 4d18b0f3c5..0000000000 --- a/src/apps/blazor/client/Components/EntityTable/AddEditModal.razor +++ /dev/null @@ -1,49 +0,0 @@ -@typeparam TRequest - - - - - - - @if (IsCreate) - { - - } - else - { - - } - @Title - - - - - - - - - @ChildContent(RequestModel) - - - - - - - Cancel - - @if (IsCreate) - { - - Save - - } - else - { - - Update - - } - - - - \ No newline at end of file diff --git a/src/apps/blazor/client/Components/EntityTable/AddEditModal.razor.cs b/src/apps/blazor/client/Components/EntityTable/AddEditModal.razor.cs deleted file mode 100644 index dd1aa5efe2..0000000000 --- a/src/apps/blazor/client/Components/EntityTable/AddEditModal.razor.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Microsoft.AspNetCore.Components; -using MudBlazor; - -namespace FSH.Starter.Blazor.Client.Components.EntityTable; - -public partial class AddEditModal : IAddEditModal -{ - [Parameter] - [EditorRequired] - public RenderFragment ChildContent { get; set; } = default!; - [Parameter] - [EditorRequired] - public TRequest RequestModel { get; set; } = default!; - [Parameter] - [EditorRequired] - public Func SaveFunc { get; set; } = default!; - [Parameter] - public Func? OnInitializedFunc { get; set; } - [Parameter] - [EditorRequired] - public string Title { get; set; } = default!; - [Parameter] - public bool IsCreate { get; set; } - [Parameter] - public string? SuccessMessage { get; set; } - - [CascadingParameter] - private IMudDialogInstance MudDialog { get; set; } = default!; - - private FshValidation? _customValidation; - - public void ForceRender() => StateHasChanged(); - - protected override Task OnInitializedAsync() => - OnInitializedFunc is not null - ? OnInitializedFunc() - : Task.CompletedTask; - - private async Task SaveAsync() - { - if (await ApiHelper.ExecuteCallGuardedAsync( - () => SaveFunc(RequestModel), Toast, _customValidation, SuccessMessage)) - { - MudDialog.Close(); - } - } -} \ No newline at end of file diff --git a/src/apps/blazor/client/Components/EntityTable/EntityClientTableContext.cs b/src/apps/blazor/client/Components/EntityTable/EntityClientTableContext.cs deleted file mode 100644 index b9d84fa8e7..0000000000 --- a/src/apps/blazor/client/Components/EntityTable/EntityClientTableContext.cs +++ /dev/null @@ -1,67 +0,0 @@ -namespace FSH.Starter.Blazor.Client.Components.EntityTable; - -/// -/// Initialization Context for the EntityTable Component. -/// Use this one if you want to use Client Paging, Sorting and Filtering. -/// -public class EntityClientTableContext - : EntityTableContext -{ - /// - /// A function that loads all the data for the table from the api and returns a ListResult of TEntity. - /// - public Func?>> LoadDataFunc { get; } - - /// - /// A function that returns a boolean which indicates whether the supplied entity meets the search criteria - /// (the supplied string is the search string entered). - /// - public Func SearchFunc { get; } - - public EntityClientTableContext( - List> fields, - Func?>> loadDataFunc, - Func searchFunc, - Func? idFunc = null, - Func>? getDefaultsFunc = null, - Func? createFunc = null, - Func>? getDetailsFunc = null, - Func? updateFunc = null, - Func? deleteFunc = null, - string? entityName = null, - string? entityNamePlural = null, - string? entityResource = null, - string? searchAction = null, - string? createAction = null, - string? updateAction = null, - string? deleteAction = null, - string? exportAction = null, - Func? editFormInitializedFunc = null, - Func? hasExtraActionsFunc = null, - Func? canUpdateEntityFunc = null, - Func? canDeleteEntityFunc = null) - : base( - fields, - idFunc, - getDefaultsFunc, - createFunc, - getDetailsFunc, - updateFunc, - deleteFunc, - entityName, - entityNamePlural, - entityResource, - searchAction, - createAction, - updateAction, - deleteAction, - exportAction, - editFormInitializedFunc, - hasExtraActionsFunc, - canUpdateEntityFunc, - canDeleteEntityFunc) - { - LoadDataFunc = loadDataFunc; - SearchFunc = searchFunc; - } -} \ No newline at end of file diff --git a/src/apps/blazor/client/Components/EntityTable/EntityField.cs b/src/apps/blazor/client/Components/EntityTable/EntityField.cs deleted file mode 100644 index 5b9d16eaf7..0000000000 --- a/src/apps/blazor/client/Components/EntityTable/EntityField.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Microsoft.AspNetCore.Components; - -namespace FSH.Starter.Blazor.Client.Components.EntityTable; - -public record EntityField(Func ValueFunc, string DisplayName, string SortLabel = "", Type? Type = null, RenderFragment? Template = null) -{ - /// - /// A function that returns the actual value of this field from the supplied entity. - /// - public Func ValueFunc { get; init; } = ValueFunc; - - /// - /// The string that's shown on the UI for this field. - /// - public string DisplayName { get; init; } = DisplayName; - - /// - /// The string that's sent to the api as property to sort on for this field. - /// This is only relevant when using server side sorting. - /// - public string SortLabel { get; init; } = SortLabel; - - /// - /// The type of the field. Default is string, but when boolean, it shows as a checkbox. - /// - public Type? Type { get; init; } = Type; - - /// - /// When supplied this template will be used for this field in stead of the default template. - /// - public RenderFragment? Template { get; init; } = Template; - - public bool CheckedForSearch { get; set; } = true; -} diff --git a/src/apps/blazor/client/Components/EntityTable/EntityServerTableContext.cs b/src/apps/blazor/client/Components/EntityTable/EntityServerTableContext.cs deleted file mode 100644 index c2fb79527a..0000000000 --- a/src/apps/blazor/client/Components/EntityTable/EntityServerTableContext.cs +++ /dev/null @@ -1,66 +0,0 @@ -using FSH.Starter.Blazor.Infrastructure.Api; - -namespace FSH.Starter.Blazor.Client.Components.EntityTable; - -/// -/// Initialization Context for the EntityTable Component. -/// Use this one if you want to use Server Paging, Sorting and Filtering. -/// -public class EntityServerTableContext - : EntityTableContext -{ - /// - /// A function that loads the specified page from the api with the specified search criteria - /// and returns a PaginatedResult of TEntity. - /// - public Func>> SearchFunc { get; } - - public bool EnableAdvancedSearch { get; } - - public EntityServerTableContext( - List> fields, - Func>> searchFunc, - bool enableAdvancedSearch = false, - Func? idFunc = null, - Func>? getDefaultsFunc = null, - Func? createFunc = null, - Func>? getDetailsFunc = null, - Func? updateFunc = null, - Func? deleteFunc = null, - string? entityName = null, - string? entityNamePlural = null, - string? entityResource = null, - string? searchAction = null, - string? createAction = null, - string? updateAction = null, - string? deleteAction = null, - string? exportAction = null, - Func? editFormInitializedFunc = null, - Func? hasExtraActionsFunc = null, - Func? canUpdateEntityFunc = null, - Func? canDeleteEntityFunc = null) - : base( - fields, - idFunc, - getDefaultsFunc, - createFunc, - getDetailsFunc, - updateFunc, - deleteFunc, - entityName, - entityNamePlural, - entityResource, - searchAction, - createAction, - updateAction, - deleteAction, - exportAction, - editFormInitializedFunc, - hasExtraActionsFunc, - canUpdateEntityFunc, - canDeleteEntityFunc) - { - SearchFunc = searchFunc; - EnableAdvancedSearch = enableAdvancedSearch; - } -} \ No newline at end of file diff --git a/src/apps/blazor/client/Components/EntityTable/EntityTable.razor b/src/apps/blazor/client/Components/EntityTable/EntityTable.razor deleted file mode 100644 index cad1b9b48e..0000000000 --- a/src/apps/blazor/client/Components/EntityTable/EntityTable.razor +++ /dev/null @@ -1,150 +0,0 @@ -@typeparam TEntity -@typeparam TId -@typeparam TRequest - -@inject IJSRuntime JS - - - - - - - @if (_canSearch && (Context.AdvancedSearchEnabled || AdvancedSearchContent is not null)) - { - - - - @* @if (Context.AdvancedSearchEnabled) - { -
- - @foreach (var field in Context.Fields) - { - - } -
- } *@ - @AdvancedSearchContent - -
- } - - - - -
- @if (_canCreate) - { - Create - } - Reload -
- - @if (_canSearch && !_advancedSearchExpanded) - { - - - } -
- - - @if (Context.Fields is not null) - { - foreach (var field in Context.Fields) - { - - @if (Context.IsClientContext) - { - @field.DisplayName - } - else - { - @field.DisplayName - } - - } - } - Actions - - - - @foreach (var field in Context.Fields) - { - - @if (field.Template is not null) - { - @field.Template(context) - } - else if (field.Type == typeof(bool)) - { - - } - else - { - - } - - } - - @if (ActionsContent is not null) - { - @ActionsContent(context) - } - else if (HasActions) - { - - @if (CanUpdateEntity(context)) - { - Edit - } - @if (CanDeleteEntity(context)) - { - Delete - } - @if (ExtraActions is not null) - { - @ExtraActions(context) - } - - } - else - { - - No Allowed Actions - - } - - - - - - - -
- -
- - - -
\ No newline at end of file diff --git a/src/apps/blazor/client/Components/EntityTable/EntityTable.razor.cs b/src/apps/blazor/client/Components/EntityTable/EntityTable.razor.cs deleted file mode 100644 index fe7bd405cd..0000000000 --- a/src/apps/blazor/client/Components/EntityTable/EntityTable.razor.cs +++ /dev/null @@ -1,287 +0,0 @@ -using FSH.Starter.Blazor.Client.Components.Common; -using FSH.Starter.Blazor.Client.Components.Dialogs; -using FSH.Starter.Blazor.Infrastructure.Api; -using FSH.Starter.Blazor.Infrastructure.Auth; -using Mapster; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Authorization; -using MudBlazor; - -namespace FSH.Starter.Blazor.Client.Components.EntityTable; - -public partial class EntityTable - where TRequest : new() -{ - [Parameter] - [EditorRequired] - public EntityTableContext Context { get; set; } = default!; - - [Parameter] - public bool Loading { get; set; } - - [Parameter] - public string? SearchString { get; set; } - [Parameter] - public EventCallback SearchStringChanged { get; set; } - - [Parameter] - public RenderFragment? AdvancedSearchContent { get; set; } - - [Parameter] - public RenderFragment? ActionsContent { get; set; } - [Parameter] - public RenderFragment? ExtraActions { get; set; } - [Parameter] - public RenderFragment? ChildRowContent { get; set; } - - [Parameter] - public RenderFragment? EditFormContent { get; set; } - - [CascadingParameter] - protected Task AuthState { get; set; } = default!; - [Inject] - protected IAuthorizationService AuthService { get; set; } = default!; - - private bool _canSearch; - private bool _canCreate; - private bool _canUpdate; - private bool _canDelete; - private bool _canExport; - - private bool _advancedSearchExpanded; - - private MudTable _table = default!; - private IEnumerable? _entityList; - private int _totalItems; - - protected override async Task OnInitializedAsync() - { - var state = await AuthState; - _canSearch = await CanDoActionAsync(Context.SearchAction, state); - _canCreate = await CanDoActionAsync(Context.CreateAction, state); - _canUpdate = await CanDoActionAsync(Context.UpdateAction, state); - _canDelete = await CanDoActionAsync(Context.DeleteAction, state); - _canExport = await CanDoActionAsync(Context.ExportAction, state); - - await LocalLoadDataAsync(); - } - - public Task ReloadDataAsync() => - Context.IsClientContext - ? LocalLoadDataAsync() - : ServerLoadDataAsync(); - - private async Task CanDoActionAsync(string? action, AuthenticationState state) => - !string.IsNullOrWhiteSpace(action) && - (bool.TryParse(action, out bool isTrue) && isTrue || // check if action equals "True", then it's allowed - Context.EntityResource is { } resource && await AuthService.HasPermissionAsync(state.User, action, resource)); - - private bool HasActions => _canUpdate || _canDelete || Context.HasExtraActionsFunc is not null && Context.HasExtraActionsFunc(); - private bool CanUpdateEntity(TEntity entity) => _canUpdate && (Context.CanUpdateEntityFunc is null || Context.CanUpdateEntityFunc(entity)); - private bool CanDeleteEntity(TEntity entity) => _canDelete && (Context.CanDeleteEntityFunc is null || Context.CanDeleteEntityFunc(entity)); - - // Client side paging/filtering - private bool LocalSearch(TEntity entity) => - Context.ClientContext?.SearchFunc is { } searchFunc - ? searchFunc(SearchString, entity) - : string.IsNullOrWhiteSpace(SearchString); - - private async Task LocalLoadDataAsync() - { - if (Loading || Context.ClientContext is null) - { - return; - } - - Loading = true; - - if (await ApiHelper.ExecuteCallGuardedAsync( - () => Context.ClientContext.LoadDataFunc(), Toast, Navigation) - is List result) - { - _entityList = result; - } - - Loading = false; - } - - // Server Side paging/filtering - - private async Task OnSearchStringChanged(string? text = null) - { - await SearchStringChanged.InvokeAsync(SearchString); - - await ServerLoadDataAsync(); - } - - private async Task ServerLoadDataAsync() - { - if (Context.IsServerContext) - { - await _table.ReloadServerData(); - } - } - - private static bool GetBooleanValue(object valueFunc) - { - if (valueFunc is bool boolValue) - { - return boolValue; - } - return false; - } - - private Func>>? ServerReloadFunc => - Context.IsServerContext ? ServerReload : null; - - private async Task> ServerReload(TableState state, CancellationToken cancellationToken) - { - if (!Loading && Context.ServerContext is not null) - { - Loading = true; - - var filter = GetPaginationFilter(state); - - if (await ApiHelper.ExecuteCallGuardedAsync( - () => Context.ServerContext.SearchFunc(filter), Toast, Navigation) - is { } result) - { - _totalItems = result.TotalCount; - _entityList = result.Items; - } - - Loading = false; - } - - return new TableData { TotalItems = _totalItems, Items = _entityList }; - } - - - private PaginationFilter GetPaginationFilter(TableState state) - { - string[]? orderings = null; - if (!string.IsNullOrEmpty(state.SortLabel)) - { - orderings = state.SortDirection == SortDirection.None - ? new[] { $"{state.SortLabel}" } - : new[] { $"{state.SortLabel} {state.SortDirection}" }; - } - - var filter = new PaginationFilter - { - PageSize = state.PageSize, - PageNumber = state.Page + 1, - Keyword = SearchString, - OrderBy = orderings ?? Array.Empty() - }; - - if (!Context.AllColumnsChecked) - { - filter.AdvancedSearch = new() - { - Fields = Context.SearchFields, - Keyword = filter.Keyword - }; - filter.Keyword = null; - } - - return filter; - } - - private async Task InvokeModal(TEntity? entity = default) - { - bool isCreate = entity is null; - - var parameters = new DialogParameters() - { - { nameof(AddEditModal.ChildContent), EditFormContent }, - { nameof(AddEditModal.OnInitializedFunc), Context.EditFormInitializedFunc }, - { nameof(AddEditModal.IsCreate), isCreate } - }; - - Func saveFunc; - TRequest requestModel; - string title, successMessage; - - if (isCreate) - { - _ = Context.CreateFunc ?? throw new InvalidOperationException("CreateFunc can't be null!"); - - saveFunc = Context.CreateFunc; - - requestModel = - Context.GetDefaultsFunc is not null - && await ApiHelper.ExecuteCallGuardedAsync( - () => Context.GetDefaultsFunc(), Toast, Navigation) - is { } defaultsResult - ? defaultsResult - : new TRequest(); - - title = $"Create {Context.EntityName}"; - successMessage = $"{Context.EntityName} Created"; - } - else - { - _ = Context.IdFunc ?? throw new InvalidOperationException("IdFunc can't be null!"); - _ = Context.UpdateFunc ?? throw new InvalidOperationException("UpdateFunc can't be null!"); - - var id = Context.IdFunc(entity!); - - saveFunc = request => Context.UpdateFunc(id, request); - - requestModel = - Context.GetDetailsFunc is not null - && await ApiHelper.ExecuteCallGuardedAsync( - () => Context.GetDetailsFunc(id!), - Toast, Navigation) - is { } detailsResult - ? detailsResult - : entity!.Adapt(); - - title = $"Edit {Context.EntityName}"; - successMessage = $"{Context.EntityName}Updated"; - } - - parameters.Add(nameof(AddEditModal.SaveFunc), saveFunc); - parameters.Add(nameof(AddEditModal.RequestModel), requestModel); - parameters.Add(nameof(AddEditModal.Title), title); - parameters.Add(nameof(AddEditModal.SuccessMessage), successMessage); - - var dialog = DialogService.ShowModal>(parameters); - - Context.SetAddEditModalRef(dialog); - - var result = await dialog.Result; - - if (!result!.Canceled) - { - await ReloadDataAsync(); - } - } - - private async Task Delete(TEntity entity) - { - _ = Context.IdFunc ?? throw new InvalidOperationException("IdFunc can't be null!"); - TId id = Context.IdFunc(entity); - - string deleteContent = "You're sure you want to delete {0} with id '{1}'?"; - var parameters = new DialogParameters - { - { nameof(DeleteConfirmation.ContentText), string.Format(deleteContent, Context.EntityName, id) } - }; - var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small, FullWidth = true, BackdropClick = false }; - var dialog = DialogService.Show("Delete", parameters, options); - var result = await dialog.Result; - if (!result!.Canceled) - { - _ = Context.DeleteFunc ?? throw new InvalidOperationException("DeleteFunc can't be null!"); - - await ApiHelper.ExecuteCallGuardedAsync( - () => Context.DeleteFunc(id), - Toast); - - await ReloadDataAsync(); - } - } -} diff --git a/src/apps/blazor/client/Components/EntityTable/EntityTableContext.cs b/src/apps/blazor/client/Components/EntityTable/EntityTableContext.cs deleted file mode 100644 index 336e256ef9..0000000000 --- a/src/apps/blazor/client/Components/EntityTable/EntityTableContext.cs +++ /dev/null @@ -1,197 +0,0 @@ -using FSH.Starter.Shared.Authorization; -using MudBlazor; - -namespace FSH.Starter.Blazor.Client.Components.EntityTable; - -/// -/// Abstract base class for the initialization Context of the EntityTable Component. -/// -/// The type of the entity. -/// The type of the id of the entity. -/// The type of the Request which is used on the AddEditModal and which is sent with the CreateFunc and UpdateFunc. -public abstract class EntityTableContext -{ - /// - /// The columns you want to display on the table. - /// - public List> Fields { get; } - - /// - /// A function that returns the Id of the entity. This is only needed when using the CRUD functionality. - /// - public Func? IdFunc { get; } - - /// - /// A function that executes the GetDefaults method on the api (or supplies defaults locally) and returns - /// a Task of Result of TRequest. When not supplied, a TRequest is simply newed up. - /// No need to check for error messages or api exceptions. These are automatically handled by the component. - /// - public Func>? GetDefaultsFunc { get; } - - /// - /// A function that executes the Create method on the api with the supplied entity and returns a Task of Result. - /// No need to check for error messages or api exceptions. These are automatically handled by the component. - /// - public Func? CreateFunc { get; } - - /// - /// A function that executes the GetDetails method on the api with the supplied Id and returns a Task of Result of TRequest. - /// No need to check for error messages or api exceptions. These are automatically handled by the component. - /// When not supplied, the TEntity out of the _entityList is supplied using the IdFunc and converted using mapster. - /// - public Func>? GetDetailsFunc { get; } - - /// - /// A function that executes the Update method on the api with the supplied entity and returns a Task of Result. - /// When not supplied, the TEntity from the list is mapped to TCreateRequest using mapster. - /// No need to check for error messages or api exceptions. These are automatically handled by the component. - /// - public Func? UpdateFunc { get; } - - /// - /// A function that executes the Delete method on the api with the supplied entity id and returns a Task of Result. - /// No need to check for error messages or api exceptions. These are automatically handled by the component. - /// - public Func? DeleteFunc { get; } - - /// - /// The name of the entity. This is used in the title of the add/edit modal and delete confirmation. - /// - public string? EntityName { get; } - - /// - /// The plural name of the entity. This is used in the "Search for ..." placeholder. - /// - public string? EntityNamePlural { get; } - - /// - /// The FSHResource that is representing this entity. This is used in combination with the xxActions to check for permissions. - /// - public string? EntityResource { get; } - - /// - /// The FSHAction name of the search permission. This is FSHAction.Search by default. - /// When empty, no search functionality will be available. - /// When the string is "true", search funtionality will be enabled, - /// otherwise it will only be enabled if the user has permission for this action on the EntityResource. - /// - public string SearchAction { get; } - - /// - /// The permission name of the create permission. This is FSHAction.Create by default. - /// When empty, no create functionality will be available. - /// When the string "true", create funtionality will be enabled, - /// otherwise it will only be enabled if the user has permission for this action on the EntityResource. - /// - public string CreateAction { get; } - - /// - /// The permission name of the update permission. This is FSHAction.Update by default. - /// When empty, no update functionality will be available. - /// When the string is "true", update funtionality will be enabled, - /// otherwise it will only be enabled if the user has permission for this action on the EntityResource. - /// - public string UpdateAction { get; } - - /// - /// The permission name of the delete permission. This is FSHAction.Delete by default. - /// When empty, no delete functionality will be available. - /// When the string is "true", delete funtionality will be enabled, - /// otherwise it will only be enabled if the user has permission for this action on the EntityResource. - /// - public string DeleteAction { get; } - - /// - /// The permission name of the export permission. This is FSHAction.Export by default. - /// - public string ExportAction { get; } - - /// - /// Use this if you want to run initialization during OnInitialized of the AddEdit form. - /// - public Func? EditFormInitializedFunc { get; } - - /// - /// Use this if you want to check for permissions of content in the ExtraActions RenderFragment. - /// The extra actions won't be available when this returns false. - /// - public Func? HasExtraActionsFunc { get; set; } - - /// - /// Use this if you want to disable the update functionality for specific entities in the table. - /// - public Func? CanUpdateEntityFunc { get; set; } - - /// - /// Use this if you want to disable the delete functionality for specific entities in the table. - /// - public Func? CanDeleteEntityFunc { get; set; } - - public EntityTableContext( - List> fields, - Func? idFunc, - Func>? getDefaultsFunc, - Func? createFunc, - Func>? getDetailsFunc, - Func? updateFunc, - Func? deleteFunc, - string? entityName, - string? entityNamePlural, - string? entityResource, - string? searchAction, - string? createAction, - string? updateAction, - string? deleteAction, - string? exportAction, - Func? editFormInitializedFunc, - Func? hasExtraActionsFunc, - Func? canUpdateEntityFunc, - Func? canDeleteEntityFunc) - { - EntityResource = entityResource; - Fields = fields; - EntityName = entityName; - EntityNamePlural = entityNamePlural; - IdFunc = idFunc; - GetDefaultsFunc = getDefaultsFunc; - CreateFunc = createFunc; - GetDetailsFunc = getDetailsFunc; - UpdateFunc = updateFunc; - DeleteFunc = deleteFunc; - SearchAction = searchAction ?? FshActions.Search; - CreateAction = createAction ?? FshActions.Create; - UpdateAction = updateAction ?? FshActions.Update; - DeleteAction = deleteAction ?? FshActions.Delete; - ExportAction = exportAction ?? FshActions.Export; - EditFormInitializedFunc = editFormInitializedFunc; - HasExtraActionsFunc = hasExtraActionsFunc; - CanUpdateEntityFunc = canUpdateEntityFunc; - CanDeleteEntityFunc = canDeleteEntityFunc; - } - - // AddEdit modal - private IDialogReference? _addEditModalRef; - - internal void SetAddEditModalRef(IDialogReference dialog) => - _addEditModalRef = dialog; - - public IAddEditModal AddEditModal => - _addEditModalRef?.Dialog as IAddEditModal - ?? throw new InvalidOperationException("AddEditModal is only available when the modal is shown."); - - // Shortcuts - public EntityClientTableContext? ClientContext => this as EntityClientTableContext; - public EntityServerTableContext? ServerContext => this as EntityServerTableContext; - public bool IsClientContext => ClientContext is not null; - public bool IsServerContext => ServerContext is not null; - - // Advanced Search - public bool AllColumnsChecked => - Fields.All(f => f.CheckedForSearch); - public void AllColumnsCheckChanged(bool checkAll) => - Fields.ForEach(f => f.CheckedForSearch = checkAll); - public bool AdvancedSearchEnabled => - ServerContext?.EnableAdvancedSearch is true; - public List SearchFields => - Fields.Where(f => f.CheckedForSearch).Select(f => f.SortLabel).ToList(); -} diff --git a/src/apps/blazor/client/Components/EntityTable/IAddEditModal.cs b/src/apps/blazor/client/Components/EntityTable/IAddEditModal.cs deleted file mode 100644 index 3ba1ea09bd..0000000000 --- a/src/apps/blazor/client/Components/EntityTable/IAddEditModal.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace FSH.Starter.Blazor.Client.Components.EntityTable; - -public interface IAddEditModal -{ - TRequest RequestModel { get; } - bool IsCreate { get; } - void ForceRender(); -} \ No newline at end of file diff --git a/src/apps/blazor/client/Components/EntityTable/PaginationResponse.cs b/src/apps/blazor/client/Components/EntityTable/PaginationResponse.cs deleted file mode 100644 index 07451aced2..0000000000 --- a/src/apps/blazor/client/Components/EntityTable/PaginationResponse.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace FSH.Starter.Blazor.Client.Components.EntityTable; - -public class PaginationResponse -{ - public List Items { get; set; } = default!; - public int TotalCount { get; set; } - public int CurrentPage { get; set; } = 1; - public int PageSize { get; set; } = 10; -} diff --git a/src/apps/blazor/client/Components/FshValidation.cs b/src/apps/blazor/client/Components/FshValidation.cs deleted file mode 100644 index 1f3ba24ebb..0000000000 --- a/src/apps/blazor/client/Components/FshValidation.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Forms; - -namespace FSH.Starter.Blazor.Client.Components; - -// See https://docs.microsoft.com/en-us/aspnet/core/blazor/forms-validation?view=aspnetcore-6.0#server-validation-with-a-validator-component -public class FshValidation : ComponentBase -{ - private ValidationMessageStore? _messageStore; - - [CascadingParameter] - private EditContext? CurrentEditContext { get; set; } - - protected override void OnInitialized() - { - if (CurrentEditContext is null) - { - throw new InvalidOperationException( - $"{nameof(FshValidation)} requires a cascading " + - $"parameter of type {nameof(EditContext)}. " + - $"For example, you can use {nameof(FshValidation)} " + - $"inside an {nameof(EditForm)}."); - } - - _messageStore = new(CurrentEditContext); - - CurrentEditContext.OnValidationRequested += (s, e) => - _messageStore?.Clear(); - CurrentEditContext.OnFieldChanged += (s, e) => - _messageStore?.Clear(e.FieldIdentifier); - } - - public void DisplayErrors(IDictionary> errors) - { - if (CurrentEditContext is not null && errors is not null) - { - foreach (var err in errors) - { - _messageStore?.Add(CurrentEditContext.Field(err.Key), err.Value); - } - - CurrentEditContext.NotifyValidationStateChanged(); - } - } - - public void ClearErrors() - { - _messageStore?.Clear(); - CurrentEditContext?.NotifyValidationStateChanged(); - } -} \ No newline at end of file diff --git a/src/apps/blazor/client/Components/General/PageHeader.razor b/src/apps/blazor/client/Components/General/PageHeader.razor deleted file mode 100644 index 07fa0453bb..0000000000 --- a/src/apps/blazor/client/Components/General/PageHeader.razor +++ /dev/null @@ -1,16 +0,0 @@ -@using MudBlazor -
- @Title - @Header - @SubHeader -
-@code { - [Parameter] - public required string Title { get; set; } - - [Parameter] - public required string Header { get; set; } - - [Parameter] - public required string SubHeader { get; set; } -} diff --git a/src/apps/blazor/client/Components/PersonCard.razor b/src/apps/blazor/client/Components/PersonCard.razor deleted file mode 100644 index 15ea230760..0000000000 --- a/src/apps/blazor/client/Components/PersonCard.razor +++ /dev/null @@ -1,21 +0,0 @@ - - - - @if (string.IsNullOrEmpty(this.ImageUri)) - { - @FullName?.ToUpper().FirstOrDefault() - - } - else - { - - - - } - - - @FullName - @Email - - - diff --git a/src/apps/blazor/client/Components/PersonCard.razor.cs b/src/apps/blazor/client/Components/PersonCard.razor.cs deleted file mode 100644 index 23762f0e93..0000000000 --- a/src/apps/blazor/client/Components/PersonCard.razor.cs +++ /dev/null @@ -1,44 +0,0 @@ -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Authorization; -namespace FSH.Starter.Blazor.Client.Components; - -public partial class PersonCard -{ - [Parameter] - public string? Class { get; set; } - [Parameter] - public string? Style { get; set; } - - [CascadingParameter] - protected Task AuthState { get; set; } = default!; - - private string? UserId { get; set; } - private string? Email { get; set; } - private string? FullName { get; set; } - private string? ImageUri { get; set; } - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender) - { - await LoadUserData(); - } - } - - private async Task LoadUserData() - { - var user = (await AuthState).User; - if (user.Identity?.IsAuthenticated == true && string.IsNullOrEmpty(UserId)) - { - FullName = user.GetFullName(); - UserId = user.GetUserId(); - Email = user.GetEmail(); - if (user.GetImageUrl() != null) - { - ImageUri = user.GetImageUrl()!.ToString(); - } - StateHasChanged(); - } - } -} diff --git a/src/apps/blazor/client/Components/ThemeManager/ColorPanel.razor b/src/apps/blazor/client/Components/ThemeManager/ColorPanel.razor deleted file mode 100644 index 2b90017e72..0000000000 --- a/src/apps/blazor/client/Components/ThemeManager/ColorPanel.razor +++ /dev/null @@ -1,27 +0,0 @@ - - -
- @ColorType - - -
-
- - - - - @foreach (var color in Colors) - { - - -
-
-
-
- } -
-
-
-
-
\ No newline at end of file diff --git a/src/apps/blazor/client/Components/ThemeManager/ColorPanel.razor.cs b/src/apps/blazor/client/Components/ThemeManager/ColorPanel.razor.cs deleted file mode 100644 index 3f5bd8545f..0000000000 --- a/src/apps/blazor/client/Components/ThemeManager/ColorPanel.razor.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.AspNetCore.Components; -using MudBlazor; - -namespace FSH.Starter.Blazor.Client.Components.ThemeManager; - -public partial class ColorPanel -{ - [Parameter] - public List Colors { get; set; } = new(); - - [Parameter] - public string ColorType { get; set; } = string.Empty; - - [Parameter] - public Color CurrentColor { get; set; } - - [Parameter] - public EventCallback OnColorClicked { get; set; } - - protected async Task ColorClicked(string color) - { - await OnColorClicked.InvokeAsync(color); - } -} diff --git a/src/apps/blazor/client/Components/ThemeManager/DarkModePanel.razor b/src/apps/blazor/client/Components/ThemeManager/DarkModePanel.razor deleted file mode 100644 index c7a4e1e8db..0000000000 --- a/src/apps/blazor/client/Components/ThemeManager/DarkModePanel.razor +++ /dev/null @@ -1,17 +0,0 @@ - - -
- @if (_isDarkMode) - { - Switch to Light Mode - } - else - { - Switch to Dark Mode - } - -
-
-
\ No newline at end of file diff --git a/src/apps/blazor/client/Components/ThemeManager/DarkModePanel.razor.cs b/src/apps/blazor/client/Components/ThemeManager/DarkModePanel.razor.cs deleted file mode 100644 index 1a07a9be7b..0000000000 --- a/src/apps/blazor/client/Components/ThemeManager/DarkModePanel.razor.cs +++ /dev/null @@ -1,24 +0,0 @@ -using FSH.Starter.Blazor.Infrastructure.Preferences; -using Microsoft.AspNetCore.Components; - -namespace FSH.Starter.Blazor.Client.Components.ThemeManager; - -public partial class DarkModePanel -{ - private bool _isDarkMode; - - protected override async Task OnInitializedAsync() - { - if (await ClientPreferences.GetPreference() is not ClientPreference themePreference) themePreference = new ClientPreference(); - _isDarkMode = themePreference.IsDarkMode; - } - - [Parameter] - public EventCallback OnIconClicked { get; set; } - - private async Task ToggleDarkMode() - { - _isDarkMode = !_isDarkMode; - await OnIconClicked.InvokeAsync(_isDarkMode); - } -} diff --git a/src/apps/blazor/client/Components/ThemeManager/RadiusPanel.razor b/src/apps/blazor/client/Components/ThemeManager/RadiusPanel.razor deleted file mode 100644 index 56e42f21d8..0000000000 --- a/src/apps/blazor/client/Components/ThemeManager/RadiusPanel.razor +++ /dev/null @@ -1,15 +0,0 @@ - - -
- Border Radius - @Radius.ToString() - -
-
- - - @Radius.ToString() - -
\ No newline at end of file diff --git a/src/apps/blazor/client/Components/ThemeManager/RadiusPanel.razor.cs b/src/apps/blazor/client/Components/ThemeManager/RadiusPanel.razor.cs deleted file mode 100644 index 1c5fc62ab5..0000000000 --- a/src/apps/blazor/client/Components/ThemeManager/RadiusPanel.razor.cs +++ /dev/null @@ -1,28 +0,0 @@ -using FSH.Starter.Blazor.Infrastructure.Preferences; -using Microsoft.AspNetCore.Components; - -namespace FSH.Starter.Blazor.Client.Components.ThemeManager; - -public partial class RadiusPanel -{ - [Parameter] - public double Radius { get; set; } - - [Parameter] - public double MaxValue { get; set; } = 30; - - [Parameter] - public EventCallback OnSliderChanged { get; set; } - - protected override async Task OnInitializedAsync() - { - if (await ClientPreferences.GetPreference() is not ClientPreference themePreference) themePreference = new ClientPreference(); - Radius = themePreference.BorderRadius; - } - - private async Task ChangedSelection(ChangeEventArgs args) - { - Radius = int.Parse(args?.Value?.ToString() ?? "0"); - await OnSliderChanged.InvokeAsync(Radius); - } -} diff --git a/src/apps/blazor/client/Components/ThemeManager/TableCustomizationPanel.razor b/src/apps/blazor/client/Components/ThemeManager/TableCustomizationPanel.razor deleted file mode 100644 index e748aea63e..0000000000 --- a/src/apps/blazor/client/Components/ThemeManager/TableCustomizationPanel.razor +++ /dev/null @@ -1,19 +0,0 @@ - - -
- Table Customization - T - -
-
- - - - - - -
\ No newline at end of file diff --git a/src/apps/blazor/client/Components/ThemeManager/TableCustomizationPanel.razor.cs b/src/apps/blazor/client/Components/ThemeManager/TableCustomizationPanel.razor.cs deleted file mode 100644 index 3bc43580fd..0000000000 --- a/src/apps/blazor/client/Components/ThemeManager/TableCustomizationPanel.razor.cs +++ /dev/null @@ -1,74 +0,0 @@ -using FSH.Starter.Blazor.Infrastructure.Notifications; -using FSH.Starter.Blazor.Infrastructure.Preferences; -using Microsoft.AspNetCore.Components; - -namespace FSH.Starter.Blazor.Client.Components.ThemeManager; - -public partial class TableCustomizationPanel -{ - [Parameter] - public bool IsDense { get; set; } - [Parameter] - public bool IsStriped { get; set; } - [Parameter] - public bool HasBorder { get; set; } - [Parameter] - public bool IsHoverable { get; set; } - [Inject] - protected INotificationPublisher Notifications { get; set; } = default!; - - private FshTablePreference _tablePreference = new(); - - protected override async Task OnInitializedAsync() - { - if (await ClientPreferences.GetPreference() is ClientPreference clientPreference) - { - _tablePreference = clientPreference.TablePreference; - } - - IsDense = _tablePreference.IsDense; - IsStriped = _tablePreference.IsStriped; - HasBorder = _tablePreference.HasBorder; - IsHoverable = _tablePreference.IsHoverable; - } - - [Parameter] - public EventCallback OnDenseSwitchToggled { get; set; } - - [Parameter] - public EventCallback OnStripedSwitchToggled { get; set; } - - [Parameter] - public EventCallback OnBorderdedSwitchToggled { get; set; } - - [Parameter] - public EventCallback OnHoverableSwitchToggled { get; set; } - - private async Task ToggleDenseSwitch() - { - _tablePreference.IsDense = !_tablePreference.IsDense; - await OnDenseSwitchToggled.InvokeAsync(_tablePreference.IsDense); - await Notifications.PublishAsync(_tablePreference); - } - - private async Task ToggleStripedSwitch() - { - _tablePreference.IsStriped = !_tablePreference.IsStriped; - await OnStripedSwitchToggled.InvokeAsync(_tablePreference.IsStriped); - await Notifications.PublishAsync(_tablePreference); - } - - private async Task ToggleBorderedSwitch() - { - _tablePreference.HasBorder = !_tablePreference.HasBorder; - await OnBorderdedSwitchToggled.InvokeAsync(_tablePreference.HasBorder); - await Notifications.PublishAsync(_tablePreference); - } - - private async Task ToggleHoverableSwitch() - { - _tablePreference.IsHoverable = !_tablePreference.IsHoverable; - await OnHoverableSwitchToggled.InvokeAsync(_tablePreference.IsHoverable); - await Notifications.PublishAsync(_tablePreference); - } -} diff --git a/src/apps/blazor/client/Components/ThemeManager/ThemeButton.razor b/src/apps/blazor/client/Components/ThemeManager/ThemeButton.razor deleted file mode 100644 index 364ecc5773..0000000000 --- a/src/apps/blazor/client/Components/ThemeManager/ThemeButton.razor +++ /dev/null @@ -1,16 +0,0 @@ -
- - - -
- - \ No newline at end of file diff --git a/src/apps/blazor/client/Components/ThemeManager/ThemeButton.razor.cs b/src/apps/blazor/client/Components/ThemeManager/ThemeButton.razor.cs deleted file mode 100644 index d5eb45af40..0000000000 --- a/src/apps/blazor/client/Components/ThemeManager/ThemeButton.razor.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Web; - -namespace FSH.Starter.Blazor.Client.Components.ThemeManager; - -public partial class ThemeButton -{ - [Parameter] - public EventCallback OnClick { get; set; } -} diff --git a/src/apps/blazor/client/Components/ThemeManager/ThemeDrawer.razor b/src/apps/blazor/client/Components/ThemeManager/ThemeDrawer.razor deleted file mode 100644 index ee4022c274..0000000000 --- a/src/apps/blazor/client/Components/ThemeManager/ThemeDrawer.razor +++ /dev/null @@ -1,28 +0,0 @@ - - - - Theme Manager - - - - - -
- - - - - - - -
-
- \ No newline at end of file diff --git a/src/apps/blazor/client/Components/ThemeManager/ThemeDrawer.razor.cs b/src/apps/blazor/client/Components/ThemeManager/ThemeDrawer.razor.cs deleted file mode 100644 index a7373b86f8..0000000000 --- a/src/apps/blazor/client/Components/ThemeManager/ThemeDrawer.razor.cs +++ /dev/null @@ -1,96 +0,0 @@ -using FSH.Starter.Blazor.Infrastructure.Preferences; -using FSH.Starter.Blazor.Infrastructure.Themes; -using Microsoft.AspNetCore.Components; - -namespace FSH.Starter.Blazor.Client.Components.ThemeManager; - -public partial class ThemeDrawer -{ - [Parameter] - public bool ThemeDrawerOpen { get; set; } - - [Parameter] - public EventCallback ThemeDrawerOpenChanged { get; set; } - - [EditorRequired] - [Parameter] - public ClientPreference ThemePreference { get; set; } = default!; - - [EditorRequired] - [Parameter] - public EventCallback ThemePreferenceChanged { get; set; } - - private readonly List _colors = CustomColors.ThemeColors; - - private async Task UpdateThemePrimaryColor(string color) - { - if (ThemePreference is not null) - { - ThemePreference.PrimaryColor = color; - await ThemePreferenceChanged.InvokeAsync(ThemePreference); - } - } - - private async Task UpdateThemeSecondaryColor(string color) - { - if (ThemePreference is not null) - { - ThemePreference.SecondaryColor = color; - await ThemePreferenceChanged.InvokeAsync(ThemePreference); - } - } - - private async Task UpdateBorderRadius(double radius) - { - if (ThemePreference is not null) - { - ThemePreference.BorderRadius = radius; - await ThemePreferenceChanged.InvokeAsync(ThemePreference); - } - } - - private async Task ToggleDarkLightMode(bool isDarkMode) - { - if (ThemePreference is not null) - { - ThemePreference.IsDarkMode = isDarkMode; - await ThemePreferenceChanged.InvokeAsync(ThemePreference); - } - } - - private async Task ToggleEntityTableDense(bool isDense) - { - if (ThemePreference is not null) - { - ThemePreference.TablePreference.IsDense = isDense; - await ThemePreferenceChanged.InvokeAsync(ThemePreference); - } - } - - private async Task ToggleEntityTableStriped(bool isStriped) - { - if (ThemePreference is not null) - { - ThemePreference.TablePreference.IsStriped = isStriped; - await ThemePreferenceChanged.InvokeAsync(ThemePreference); - } - } - - private async Task ToggleEntityTableBorder(bool hasBorder) - { - if (ThemePreference is not null) - { - ThemePreference.TablePreference.HasBorder = hasBorder; - await ThemePreferenceChanged.InvokeAsync(ThemePreference); - } - } - - private async Task ToggleEntityTableHoverable(bool isHoverable) - { - if (ThemePreference is not null) - { - ThemePreference.TablePreference.IsHoverable = isHoverable; - await ThemePreferenceChanged.InvokeAsync(ThemePreference); - } - } -} diff --git a/src/apps/blazor/client/Directory.Packages.props b/src/apps/blazor/client/Directory.Packages.props deleted file mode 100644 index 5a7acff5d7..0000000000 --- a/src/apps/blazor/client/Directory.Packages.props +++ /dev/null @@ -1,22 +0,0 @@ - - - true - true - true - - - true - true - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/apps/blazor/client/Layout/BaseLayout.razor b/src/apps/blazor/client/Layout/BaseLayout.razor deleted file mode 100644 index bd368f82ff..0000000000 --- a/src/apps/blazor/client/Layout/BaseLayout.razor +++ /dev/null @@ -1,29 +0,0 @@ -@inherits LayoutComponentBase - - - - - - - - - - - - @Body - - - - - - - - - - @Body - - - - - \ No newline at end of file diff --git a/src/apps/blazor/client/Layout/BaseLayout.razor.cs b/src/apps/blazor/client/Layout/BaseLayout.razor.cs deleted file mode 100644 index 524a2923df..0000000000 --- a/src/apps/blazor/client/Layout/BaseLayout.razor.cs +++ /dev/null @@ -1,61 +0,0 @@ -using FSH.Starter.Blazor.Infrastructure.Preferences; -using FSH.Starter.Blazor.Infrastructure.Themes; -using MudBlazor; - -namespace FSH.Starter.Blazor.Client.Layout; - -public partial class BaseLayout -{ - private ClientPreference? _themePreference; - private MudTheme _currentTheme = new FshTheme(); - private bool _themeDrawerOpen; - private bool _rightToLeft; - private bool _isDarkMode; - - protected override async Task OnInitializedAsync() - { - _themePreference = await ClientPreferences.GetPreference() as ClientPreference; - if (_themePreference == null) _themePreference = new ClientPreference(); - SetCurrentTheme(_themePreference); - - Toast.Add("Like this project? ", Severity.Info, config => - { - config.BackgroundBlurred = true; - config.Icon = Icons.Custom.Brands.GitHub; - config.Action = "Star us on Github!"; - config.ActionColor = Color.Info; - config.OnClick = snackbar => - { - Navigation.NavigateTo("https://github.com/fullstackhero/dotnet-starter-kit"); - return Task.CompletedTask; - }; - }); - } - - private async Task ToggleDarkLightMode(bool isDarkMode) - { - if (_themePreference is not null) - { - _themePreference.IsDarkMode = isDarkMode; - await ThemePreferenceChanged(_themePreference); - } - } - - private async Task ThemePreferenceChanged(ClientPreference themePreference) - { - SetCurrentTheme(themePreference); - await ClientPreferences.SetPreference(themePreference); - } - - private void SetCurrentTheme(ClientPreference themePreference) - { - _isDarkMode = themePreference.IsDarkMode; - _currentTheme.PaletteLight.Primary = themePreference.PrimaryColor; - _currentTheme.PaletteLight.Secondary = themePreference.SecondaryColor; - _currentTheme.PaletteDark.Primary = themePreference.PrimaryColor; - _currentTheme.PaletteDark.Secondary = themePreference.SecondaryColor; - _currentTheme.LayoutProperties.DefaultBorderRadius = $"{themePreference.BorderRadius}px"; - _currentTheme.LayoutProperties.DefaultBorderRadius = $"{themePreference.BorderRadius}px"; - _rightToLeft = themePreference.IsRTL; - } -} diff --git a/src/apps/blazor/client/Layout/MainLayout.razor b/src/apps/blazor/client/Layout/MainLayout.razor deleted file mode 100644 index 520832e8cb..0000000000 --- a/src/apps/blazor/client/Layout/MainLayout.razor +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - fullstackhero - - - - - Sponsor - - - - Community - Discord - Facebook - - LinkedIn - Buy Me a Coffee! - - Open Collective - - Resources - Documentation - - - - - - - - - - - - -
- - -
- Community - Discord - Facebook - - HrefedIn - Resources - - MudBlazor Documentation - - Quick-Start Guide -
-
- - - - - -
- -
- - Account -
-
- -
- - Dashboard -
-
-
- - Logout - -
-
-
-
-
- - - - - - - @ChildContent - - - - - \ No newline at end of file diff --git a/src/apps/blazor/client/Layout/MainLayout.razor.cs b/src/apps/blazor/client/Layout/MainLayout.razor.cs deleted file mode 100644 index 2b24a45c4e..0000000000 --- a/src/apps/blazor/client/Layout/MainLayout.razor.cs +++ /dev/null @@ -1,55 +0,0 @@ -using FSH.Starter.Blazor.Infrastructure.Preferences; -using Microsoft.AspNetCore.Components; -using MudBlazor; - -namespace FSH.Starter.Blazor.Client.Layout; - -public partial class MainLayout -{ - [Parameter] - public RenderFragment ChildContent { get; set; } = default!; - [Parameter] - public EventCallback OnDarkModeToggle { get; set; } - [Parameter] - public EventCallback OnRightToLeftToggle { get; set; } - - private bool _drawerOpen; - private bool _isDarkMode; - - protected override async Task OnInitializedAsync() - { - if (await ClientPreferences.GetPreference() is ClientPreference preferences) - { - _drawerOpen = preferences.IsDrawerOpen; - _isDarkMode = preferences.IsDarkMode; - } - } - - public async Task ToggleDarkMode() - { - _isDarkMode = !_isDarkMode; - await OnDarkModeToggle.InvokeAsync(_isDarkMode); - } - - private async Task DrawerToggle() - { - _drawerOpen = await ClientPreferences.ToggleDrawerAsync(); - } - private void Logout() - { - var parameters = new DialogParameters - { - { nameof(Components.Dialogs.Logout.ContentText), "Do you want to logout from the system?"}, - { nameof(Components.Dialogs.Logout.ButtonText), "Logout"}, - { nameof(Components.Dialogs.Logout.Color), Color.Error} - }; - - var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small, FullWidth = true }; - DialogService.Show("Logout", parameters, options); - } - - private void Profile() - { - Navigation.NavigateTo("/identity/account"); - } -} diff --git a/src/apps/blazor/client/Layout/MainLayout.razor.css b/src/apps/blazor/client/Layout/MainLayout.razor.css deleted file mode 100644 index 1a95cc0551..0000000000 --- a/src/apps/blazor/client/Layout/MainLayout.razor.css +++ /dev/null @@ -1,81 +0,0 @@ -.page { - position: relative; - display: flex; - flex-direction: column; -} - -main { - flex: 1; -} - -.sidebar { - background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); -} - -.top-row { - background-color: #f7f7f7; - border-bottom: 1px solid #d6d5d5; - justify-content: flex-end; - height: 3.5rem; - display: flex; - align-items: center; -} - - .top-row ::deep a, .top-row ::deep .btn-link { - white-space: nowrap; - margin-left: 1.5rem; - text-decoration: none; - } - - .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { - text-decoration: underline; - } - - .top-row ::deep a:first-child { - overflow: hidden; - text-overflow: ellipsis; - } - -@media (max-width: 640.98px) { - .top-row { - justify-content: space-between; - } - - .top-row ::deep a, .top-row ::deep .btn-link { - margin-left: 0; - } -} - -@media (min-width: 641px) { - .page { - flex-direction: row; - } - - .sidebar { - width: 250px; - height: 100vh; - position: sticky; - top: 0; - } - - .top-row { - position: sticky; - top: 0; - z-index: 1; - } - - .top-row.auth ::deep a:first-child { - flex: 1; - text-align: right; - width: 0; - } - - .top-row, article { - padding-left: 2rem !important; - padding-right: 1.5rem !important; - } -} - -.fsh-shadow { - box-shadow: 0 30px 60px rgba(0,0,0,0.12) !important; -} \ No newline at end of file diff --git a/src/apps/blazor/client/Layout/NavMenu.razor b/src/apps/blazor/client/Layout/NavMenu.razor deleted file mode 100644 index 86ab42d0d2..0000000000 --- a/src/apps/blazor/client/Layout/NavMenu.razor +++ /dev/null @@ -1,32 +0,0 @@ - - - Start - Home - Counter - @if (_canViewAuditTrails) - { - Audit Trail - } - Modules - - Products - Brands - - Todos - @if (CanViewAdministrationGroup) - { - Administration - @if (_canViewUsers) - { - Users - } - @if (_canViewRoles) - { - Roles - } - @if (_canViewTenants) - { - Tenants - } - } - diff --git a/src/apps/blazor/client/Layout/NavMenu.razor.cs b/src/apps/blazor/client/Layout/NavMenu.razor.cs deleted file mode 100644 index 41b598a485..0000000000 --- a/src/apps/blazor/client/Layout/NavMenu.razor.cs +++ /dev/null @@ -1,40 +0,0 @@ -using FSH.Starter.Blazor.Infrastructure.Auth; -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Authorization; - -namespace FSH.Starter.Blazor.Client.Layout; - -public partial class NavMenu -{ - [CascadingParameter] - protected Task AuthState { get; set; } = default!; - [Inject] - protected IAuthorizationService AuthService { get; set; } = default!; - - private bool _canViewHangfire; - private bool _canViewDashboard; - private bool _canViewRoles; - private bool _canViewUsers; - private bool _canViewProducts; - private bool _canViewBrands; - private bool _canViewTodos; - private bool _canViewTenants; - private bool _canViewAuditTrails; - private bool CanViewAdministrationGroup => _canViewUsers || _canViewRoles || _canViewTenants; - - protected override async Task OnParametersSetAsync() - { - var user = (await AuthState).User; - _canViewHangfire = await AuthService.HasPermissionAsync(user, FshActions.View, FshResources.Hangfire); - _canViewDashboard = await AuthService.HasPermissionAsync(user, FshActions.View, FshResources.Dashboard); - _canViewRoles = await AuthService.HasPermissionAsync(user, FshActions.View, FshResources.Roles); - _canViewUsers = await AuthService.HasPermissionAsync(user, FshActions.View, FshResources.Users); - _canViewProducts = await AuthService.HasPermissionAsync(user, FshActions.View, FshResources.Products); - _canViewBrands = await AuthService.HasPermissionAsync(user, FshActions.View, FshResources.Brands); - _canViewTodos = await AuthService.HasPermissionAsync(user, FshActions.View, FshResources.Todos); - _canViewTenants = await AuthService.HasPermissionAsync(user, FshActions.View, FshResources.Tenants); - _canViewAuditTrails = await AuthService.HasPermissionAsync(user, FshActions.View, FshResources.AuditTrails); - } -} diff --git a/src/apps/blazor/client/Layout/NavMenu.razor.css b/src/apps/blazor/client/Layout/NavMenu.razor.css deleted file mode 100644 index 881d128a5f..0000000000 --- a/src/apps/blazor/client/Layout/NavMenu.razor.css +++ /dev/null @@ -1,83 +0,0 @@ -.navbar-toggler { - background-color: rgba(255, 255, 255, 0.1); -} - -.top-row { - height: 3.5rem; - background-color: rgba(0,0,0,0.4); -} - -.navbar-brand { - font-size: 1.1rem; -} - -.bi { - display: inline-block; - position: relative; - width: 1.25rem; - height: 1.25rem; - margin-right: 0.75rem; - top: -1px; - background-size: cover; -} - -.bi-house-door-fill-nav-menu { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E"); -} - -.bi-plus-square-fill-nav-menu { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E"); -} - -.bi-list-nested-nav-menu { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E"); -} - -.nav-item { - font-size: 0.9rem; - padding-bottom: 0.5rem; -} - - .nav-item:first-of-type { - padding-top: 1rem; - } - - .nav-item:last-of-type { - padding-bottom: 1rem; - } - - .nav-item ::deep a { - color: #d7d7d7; - border-radius: 4px; - height: 3rem; - display: flex; - align-items: center; - line-height: 3rem; - } - -.nav-item ::deep a.active { - background-color: rgba(255,255,255,0.37); - color: white; -} - -.nav-item ::deep a:hover { - background-color: rgba(255,255,255,0.1); - color: white; -} - -@media (min-width: 641px) { - .navbar-toggler { - display: none; - } - - .collapse { - /* Never collapse the sidebar for wide screens */ - display: block; - } - - .nav-scrollable { - /* Allow sidebar to scroll for tall menus */ - height: calc(100vh - 3.5rem); - overflow-y: auto; - } -} diff --git a/src/apps/blazor/client/Layout/NotFound.razor b/src/apps/blazor/client/Layout/NotFound.razor deleted file mode 100644 index 6fe4b2ce57..0000000000 --- a/src/apps/blazor/client/Layout/NotFound.razor +++ /dev/null @@ -1,47 +0,0 @@ -@inherits LayoutComponentBase - - - - -
- - - - - - - - - - - - - - - - - Not Found -
- Go Home -
-
-
- -@code{ - -} \ No newline at end of file diff --git a/src/apps/blazor/client/Layout/NotFound.razor.cs b/src/apps/blazor/client/Layout/NotFound.razor.cs deleted file mode 100644 index 674565ed3e..0000000000 --- a/src/apps/blazor/client/Layout/NotFound.razor.cs +++ /dev/null @@ -1,34 +0,0 @@ -using FSH.Starter.Blazor.Infrastructure.Themes; -using FSH.Starter.Blazor.Infrastructure.Preferences; -using MudBlazor; - -namespace FSH.Starter.Blazor.Client.Layout; - -public partial class NotFound -{ - private ClientPreference? _themePreference; - private MudTheme _theme = new FshTheme(); - private bool _isDarkMode; - - protected override async Task OnInitializedAsync() - { - _themePreference = await ClientPreferences.GetPreference() as ClientPreference; - if (_themePreference == null) _themePreference = new ClientPreference(); - SetCurrentTheme(_themePreference); - } - - private void SetCurrentTheme(ClientPreference themePreference) - { - _isDarkMode = themePreference.IsDarkMode; - //_currentTheme = new FshTheme(); - //if (themePreference.IsDarkMode) - //{ - // _currentTheme. - //} - //_currentTheme.Palette.Primary = themePreference.PrimaryColor; - //_currentTheme.Palette.Secondary = themePreference.SecondaryColor; - //_currentTheme.LayoutProperties.DefaultBorderRadius = $"{themePreference.BorderRadius}px"; - //_currentTheme.LayoutProperties.DefaultBorderRadius = $"{themePreference.BorderRadius}px"; - //_rightToLeft = themePreference.IsRTL; - } -} diff --git a/src/apps/blazor/client/Pages/Auth/ForgotPassword.razor b/src/apps/blazor/client/Pages/Auth/ForgotPassword.razor deleted file mode 100644 index e2b35c86af..0000000000 --- a/src/apps/blazor/client/Pages/Auth/ForgotPassword.razor +++ /dev/null @@ -1,41 +0,0 @@ -@page "/forgot-password" -@attribute [AllowAnonymous] - -Forgot Password - - - - - - - - -
- Forgot Password? - - We can help you by resetting your password. -
-
-
- - - - - - - - - - - - - - - Forgot Password - -
-
\ No newline at end of file diff --git a/src/apps/blazor/client/Pages/Auth/ForgotPassword.razor.cs b/src/apps/blazor/client/Pages/Auth/ForgotPassword.razor.cs deleted file mode 100644 index 419185b954..0000000000 --- a/src/apps/blazor/client/Pages/Auth/ForgotPassword.razor.cs +++ /dev/null @@ -1,30 +0,0 @@ -using FSH.Starter.Blazor.Client.Components; -using FSH.Starter.Blazor.Infrastructure.Api; -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Components; - -namespace FSH.Starter.Blazor.Client.Pages.Auth; - -public partial class ForgotPassword -{ - private readonly ForgotPasswordCommand _forgotPasswordRequest = new(); - private FshValidation? _customValidation; - private bool BusySubmitting { get; set; } - - [Inject] - private IApiClient UsersClient { get; set; } = default!; - - private string Tenant { get; set; } = TenantConstants.Root.Id; - - private async Task SubmitAsync() - { - BusySubmitting = true; - - await ApiHelper.ExecuteCallGuardedAsync( - () => UsersClient.ForgotPasswordEndpointAsync(Tenant, _forgotPasswordRequest), - Toast, - _customValidation); - - BusySubmitting = false; - } -} diff --git a/src/apps/blazor/client/Pages/Auth/Login.razor b/src/apps/blazor/client/Pages/Auth/Login.razor deleted file mode 100644 index ed00d61dba..0000000000 --- a/src/apps/blazor/client/Pages/Auth/Login.razor +++ /dev/null @@ -1,46 +0,0 @@ -@page "/login" -@attribute [AllowAnonymous] -@inject IAuthenticationService authService - -Login - -
- Sign In - - Enter your credentials to get started. - -
-
- - - - - - - - - - - - - - - - Register? - - - Forgot password? - - - Sign In - - - Fill Administrator Credentials - - - \ No newline at end of file diff --git a/src/apps/blazor/client/Pages/Auth/Login.razor.cs b/src/apps/blazor/client/Pages/Auth/Login.razor.cs deleted file mode 100644 index 1bd6d7bff1..0000000000 --- a/src/apps/blazor/client/Pages/Auth/Login.razor.cs +++ /dev/null @@ -1,71 +0,0 @@ -using FSH.Starter.Blazor.Client.Components; -using FSH.Starter.Blazor.Infrastructure.Api; -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Authorization; -using MudBlazor; - -namespace FSH.Starter.Blazor.Client.Pages.Auth; - -public partial class Login() -{ - [CascadingParameter] - public Task AuthState { get; set; } = default!; - - private FshValidation? _customValidation; - - public bool BusySubmitting { get; set; } - - private readonly TokenGenerationCommand _tokenRequest = new(); - private string TenantId { get; set; } = string.Empty; - private bool _passwordVisibility; - private InputType _passwordInput = InputType.Password; - private string _passwordInputIcon = Icons.Material.Filled.VisibilityOff; - - protected override async Task OnInitializedAsync() - { - var authState = await AuthState; - if (authState.User.Identity?.IsAuthenticated is true) - { - Navigation.NavigateTo("/"); - } - } - - private void TogglePasswordVisibility() - { - if (_passwordVisibility) - { - _passwordVisibility = false; - _passwordInputIcon = Icons.Material.Filled.VisibilityOff; - _passwordInput = InputType.Password; - } - else - { - _passwordVisibility = true; - _passwordInputIcon = Icons.Material.Filled.Visibility; - _passwordInput = InputType.Text; - } - } - - private void FillAdministratorCredentials() - { - _tokenRequest.Email = TenantConstants.Root.EmailAddress; - _tokenRequest.Password = TenantConstants.DefaultPassword; - TenantId = TenantConstants.Root.Id; - } - - private async Task SubmitAsync() - { - BusySubmitting = true; - - if (await ApiHelper.ExecuteCallGuardedAsync( - () => authService.LoginAsync(TenantId, _tokenRequest), - Toast, - _customValidation)) - { - Toast.Add($"Logged in as {_tokenRequest.Email}", Severity.Info); - } - - BusySubmitting = false; - } -} diff --git a/src/apps/blazor/client/Pages/Auth/Logout.razor b/src/apps/blazor/client/Pages/Auth/Logout.razor deleted file mode 100644 index c02ac1597b..0000000000 --- a/src/apps/blazor/client/Pages/Auth/Logout.razor +++ /dev/null @@ -1,11 +0,0 @@ -@page "/logout" -@attribute [AllowAnonymous] -@inject IAuthenticationService AuthService - -@code{ - protected override async Task OnInitializedAsync() - { - await AuthService.LogoutAsync(); - Toast.Add("Logged out", Severity.Error); - } -} \ No newline at end of file diff --git a/src/apps/blazor/client/Pages/Auth/SelfRegister.razor b/src/apps/blazor/client/Pages/Auth/SelfRegister.razor deleted file mode 100644 index 82f71886ce..0000000000 --- a/src/apps/blazor/client/Pages/Auth/SelfRegister.razor +++ /dev/null @@ -1,71 +0,0 @@ -@page "/register" -@attribute [AllowAnonymous] - -Register - - - - - - - - -
-
- - Register - - Enter your details below to set up your new account -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Register - - -
-
\ No newline at end of file diff --git a/src/apps/blazor/client/Pages/Auth/SelfRegister.razor.cs b/src/apps/blazor/client/Pages/Auth/SelfRegister.razor.cs deleted file mode 100644 index 1659c773ab..0000000000 --- a/src/apps/blazor/client/Pages/Auth/SelfRegister.razor.cs +++ /dev/null @@ -1,57 +0,0 @@ -using FSH.Starter.Blazor.Client.Components; -using FSH.Starter.Blazor.Infrastructure.Api; -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Components; -using MudBlazor; - -namespace FSH.Starter.Blazor.Client.Pages.Auth; - -public partial class SelfRegister -{ - private readonly RegisterUserCommand _createUserRequest = new(); - private FshValidation? _customValidation; - private bool BusySubmitting { get; set; } - - [Inject] - private IApiClient UsersClient { get; set; } = default!; - - private string Tenant { get; set; } = TenantConstants.Root.Id; - - private bool _passwordVisibility; - private InputType _passwordInput = InputType.Password; - private string _passwordInputIcon = Icons.Material.Filled.VisibilityOff; - - private async Task SubmitAsync() - { - BusySubmitting = true; - - var response = await ApiHelper.ExecuteCallGuardedAsync( - () => UsersClient.SelfRegisterUserEndpointAsync(Tenant, _createUserRequest), - Toast, Navigation, - _customValidation); - - if (response != null) - { - Toast.Add($"user {response.UserId} registered.", Severity.Success); - Navigation.NavigateTo("/login"); - } - - BusySubmitting = false; - } - - private void TogglePasswordVisibility() - { - if (_passwordVisibility) - { - _passwordVisibility = false; - _passwordInputIcon = Icons.Material.Filled.VisibilityOff; - _passwordInput = InputType.Password; - } - else - { - _passwordVisibility = true; - _passwordInputIcon = Icons.Material.Filled.Visibility; - _passwordInput = InputType.Text; - } - } -} diff --git a/src/apps/blazor/client/Pages/Catalog/Brands.razor b/src/apps/blazor/client/Pages/Catalog/Brands.razor deleted file mode 100644 index e805ff3798..0000000000 --- a/src/apps/blazor/client/Pages/Catalog/Brands.razor +++ /dev/null @@ -1,44 +0,0 @@ -@page "/catalog/brands" - - - - - - - - - - - @if (!Context.AddEditModal.IsCreate) - { - - - - } - - - - - - - - -
- @if(!Context.AddEditModal.IsCreate) - { - - View - - - - Delete - - } -
-
-
-
- -
diff --git a/src/apps/blazor/client/Pages/Catalog/Brands.razor.cs b/src/apps/blazor/client/Pages/Catalog/Brands.razor.cs deleted file mode 100644 index 846f2985f4..0000000000 --- a/src/apps/blazor/client/Pages/Catalog/Brands.razor.cs +++ /dev/null @@ -1,50 +0,0 @@ -using FSH.Starter.Blazor.Client.Components.EntityTable; -using FSH.Starter.Blazor.Infrastructure.Api; -using FSH.Starter.Shared.Authorization; -using Mapster; -using Microsoft.AspNetCore.Components; - -namespace FSH.Starter.Blazor.Client.Pages.Catalog; - -public partial class Brands -{ - [Inject] - protected IApiClient _client { get; set; } = default!; - - protected EntityServerTableContext Context { get; set; } = default!; - - private EntityTable _table = default!; - - protected override void OnInitialized() => - Context = new( - entityName: "Brand", - entityNamePlural: "Brands", - entityResource: FshResources.Brands, - fields: new() - { - new(brand => brand.Id, "Id", "Id"), - new(brand => brand.Name, "Name", "Name"), - new(brand => brand.Description, "Description", "Description") - }, - enableAdvancedSearch: true, - idFunc: brand => brand.Id!.Value, - searchFunc: async filter => - { - var brandFilter = filter.Adapt(); - var result = await _client.SearchBrandsEndpointAsync("1", brandFilter); - return result.Adapt>(); - }, - createFunc: async brand => - { - await _client.CreateBrandEndpointAsync("1", brand.Adapt()); - }, - updateFunc: async (id, brand) => - { - await _client.UpdateBrandEndpointAsync("1", id, brand.Adapt()); - }, - deleteFunc: async id => await _client.DeleteBrandEndpointAsync("1", id)); -} - -public class BrandViewModel : UpdateBrandCommand -{ -} diff --git a/src/apps/blazor/client/Pages/Catalog/Products.razor b/src/apps/blazor/client/Pages/Catalog/Products.razor deleted file mode 100644 index f3cb893b1d..0000000000 --- a/src/apps/blazor/client/Pages/Catalog/Products.razor +++ /dev/null @@ -1,64 +0,0 @@ -@page "/catalog/products" - - - - - - - - All Brands - @foreach (var brand in _brands) - { - @brand.Name - } - - Minimum Rate: @_searchMinimumRate.ToString() - Maximum Rate: @_searchMaximumRate.ToString() - - - - @if (!Context.AddEditModal.IsCreate) - { - - - - } - - - - - - - - - - - - @foreach (var brand in _brands) - { - @brand.Name - } - - - - -
- @if(!Context.AddEditModal.IsCreate) - { - - View - - - - Delete - - } -
-
-
-
- -
\ No newline at end of file diff --git a/src/apps/blazor/client/Pages/Catalog/Products.razor.cs b/src/apps/blazor/client/Pages/Catalog/Products.razor.cs deleted file mode 100644 index 46266197cd..0000000000 --- a/src/apps/blazor/client/Pages/Catalog/Products.razor.cs +++ /dev/null @@ -1,108 +0,0 @@ -using FSH.Starter.Blazor.Client.Components.EntityTable; -using FSH.Starter.Blazor.Infrastructure.Api; -using FSH.Starter.Shared.Authorization; -using Mapster; -using Microsoft.AspNetCore.Components; - -namespace FSH.Starter.Blazor.Client.Pages.Catalog; - -public partial class Products -{ - [Inject] - protected IApiClient _client { get; set; } = default!; - - protected EntityServerTableContext Context { get; set; } = default!; - - private EntityTable _table = default!; - - private List _brands = new(); - - protected override async Task OnInitializedAsync() - { - Context = new( - entityName: "Product", - entityNamePlural: "Products", - entityResource: FshResources.Products, - fields: new() - { - new(prod => prod.Id,"Id", "Id"), - new(prod => prod.Name,"Name", "Name"), - new(prod => prod.Description, "Description", "Description"), - new(prod => prod.Price, "Price", "Price"), - new(prod => prod.Brand?.Name, "Brand", "Brand") - }, - enableAdvancedSearch: true, - idFunc: prod => prod.Id!.Value, - searchFunc: async filter => - { - var productFilter = filter.Adapt(); - productFilter.MinimumRate = Convert.ToDouble(SearchMinimumRate); - productFilter.MaximumRate = Convert.ToDouble(SearchMaximumRate); - productFilter.BrandId = SearchBrandId; - var result = await _client.SearchProductsEndpointAsync("1", productFilter); - return result.Adapt>(); - }, - createFunc: async prod => - { - await _client.CreateProductEndpointAsync("1", prod.Adapt()); - }, - updateFunc: async (id, prod) => - { - await _client.UpdateProductEndpointAsync("1", id, prod.Adapt()); - }, - deleteFunc: async id => await _client.DeleteProductEndpointAsync("1", id)); - - await LoadBrandsAsync(); - } - - private async Task LoadBrandsAsync() - { - if (_brands.Count == 0) - { - var response = await _client.SearchBrandsEndpointAsync("1", new SearchBrandsCommand()); - if (response?.Items != null) - { - _brands = response.Items.ToList(); - } - } - } - - // Advanced Search - - private Guid? _searchBrandId; - private Guid? SearchBrandId - { - get => _searchBrandId; - set - { - _searchBrandId = value; - _ = _table.ReloadDataAsync(); - } - } - - private decimal _searchMinimumRate; - private decimal SearchMinimumRate - { - get => _searchMinimumRate; - set - { - _searchMinimumRate = value; - _ = _table.ReloadDataAsync(); - } - } - - private decimal _searchMaximumRate = 9999; - private decimal SearchMaximumRate - { - get => _searchMaximumRate; - set - { - _searchMaximumRate = value; - _ = _table.ReloadDataAsync(); - } - } -} - -public class ProductViewModel : UpdateProductCommand -{ -} diff --git a/src/apps/blazor/client/Pages/Counter.razor b/src/apps/blazor/client/Pages/Counter.razor deleted file mode 100644 index e1ba5df4d9..0000000000 --- a/src/apps/blazor/client/Pages/Counter.razor +++ /dev/null @@ -1,18 +0,0 @@ -@page "/counter" - -Counter - -Counter - -Current count: @currentCount - -Click me - -@code { - private int currentCount = 0; - - private void IncrementCount() - { - currentCount++; - } -} diff --git a/src/apps/blazor/client/Pages/Home.razor b/src/apps/blazor/client/Pages/Home.razor deleted file mode 100644 index 4ab0981f44..0000000000 --- a/src/apps/blazor/client/Pages/Home.razor +++ /dev/null @@ -1,95 +0,0 @@ -@page "/" -@using System.Security.Claims - - - - -
- -
-
- - The best way to start a fullstack .NET 9 Web App. - - - - fullstackhero's - .NET 9 Starter Kit - - - - - Built with the goodness of MudBlazor Component Library - - - - -
- Get Started - Star on GitHub -
-
- - Version 2.0 - - - - - In case you are stuck anywhere or have any queries regarding this implementation, I have compiled a Quick Start Guide for you reference. - Read The Guide - - - - - - - - - Here are few articles that should help you get started with Blazor. - - - - - - - - - - - - Application Claims of the currently logged in user - - @if (Claims is not null) - { - @foreach (var claim in Claims) - { - - - @claim.Type - - @claim.Value - - } - } - - - - - - Liked this Boilerplate? Star us on Github! - -
-
- -@code { - [CascadingParameter] - public Task AuthState { get; set; } = default!; - - public IEnumerable? Claims { get; set; } - - protected override async Task OnInitializedAsync() - { - var authState = await AuthState; - Claims = authState.User.Claims; - } -} \ No newline at end of file diff --git a/src/apps/blazor/client/Pages/Identity/Account/Account.razor b/src/apps/blazor/client/Pages/Identity/Account/Account.razor deleted file mode 100644 index 0e7f7d13a3..0000000000 --- a/src/apps/blazor/client/Pages/Identity/Account/Account.razor +++ /dev/null @@ -1,31 +0,0 @@ -@page "/identity/account" - - - - - - - - @if (!SecurityTabHidden) - { - - - - } - - -@code -{ - [Inject] - public IAuthenticationService AuthService { get; set; } = default!; - - public bool SecurityTabHidden { get; set; } = false; - - protected override void OnInitialized() - { - // if (AuthService.ProviderType == AuthProvider.AzureAd) - // { - // SecurityTabHidden = true; - // } - } -} \ No newline at end of file diff --git a/src/apps/blazor/client/Pages/Identity/Account/Profile.razor b/src/apps/blazor/client/Pages/Identity/Account/Profile.razor deleted file mode 100644 index 63ce561c98..0000000000 --- a/src/apps/blazor/client/Pages/Identity/Account/Profile.razor +++ /dev/null @@ -1,84 +0,0 @@ - - - - -
- @if (!string.IsNullOrEmpty(_imageUrl)) - { - - - - } - else - { - @_firstLetterOfName - } -
- @_profileModel.FirstName @_profileModel.LastName - @_profileModel.Email -
- - -
-
- - - - - - Profile Details - - - - - - - - - - - - - - - - - - - - - - Save Changes - - - - -
\ No newline at end of file diff --git a/src/apps/blazor/client/Pages/Identity/Account/Profile.razor.cs b/src/apps/blazor/client/Pages/Identity/Account/Profile.razor.cs deleted file mode 100644 index f979d3f162..0000000000 --- a/src/apps/blazor/client/Pages/Identity/Account/Profile.razor.cs +++ /dev/null @@ -1,102 +0,0 @@ -using FSH.Starter.Blazor.Client.Components; -using FSH.Starter.Blazor.Client.Components.Dialogs; -using FSH.Starter.Blazor.Infrastructure.Api; -using FSH.Starter.Blazor.Infrastructure.Auth; -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Authorization; -using Microsoft.AspNetCore.Components.Forms; -using MudBlazor; - -namespace FSH.Starter.Blazor.Client.Pages.Identity.Account; - -public partial class Profile -{ - [CascadingParameter] - protected Task AuthState { get; set; } = default!; - [Inject] - protected IAuthenticationService AuthService { get; set; } = default!; - [Inject] - protected IApiClient PersonalClient { get; set; } = default!; - - private readonly UpdateUserCommand _profileModel = new(); - - private string? _imageUrl; - private string? _userId; - private char _firstLetterOfName; - - private FshValidation? _customValidation; - - protected override async Task OnInitializedAsync() - { - if ((await AuthState).User is { } user) - { - _userId = user.GetUserId(); - _profileModel.Email = user.GetEmail() ?? string.Empty; - _profileModel.FirstName = user.GetFirstName() ?? string.Empty; - _profileModel.LastName = user.GetSurname() ?? string.Empty; - _profileModel.PhoneNumber = user.GetPhoneNumber(); - if (user.GetImageUrl() != null) - { - _imageUrl = user.GetImageUrl()!.ToString(); - } - if (_userId is not null) _profileModel.Id = _userId; - } - - if (_profileModel.FirstName?.Length > 0) - { - _firstLetterOfName = _profileModel.FirstName.ToUpper().FirstOrDefault(); - } - } - - private async Task UpdateProfileAsync() - { - if (await ApiHelper.ExecuteCallGuardedAsync( - () => PersonalClient.UpdateUserEndpointAsync(_profileModel), Toast, _customValidation)) - { - Toast.Add("Your Profile has been updated. Please Login again to Continue.", Severity.Success); - await AuthService.ReLoginAsync(Navigation.Uri); - } - } - - private async Task UploadFiles(InputFileChangeEventArgs e) - { - var file = e.File; - if (file is not null) - { - string? extension = Path.GetExtension(file.Name); - if (!AppConstants.SupportedImageFormats.Contains(extension.ToLower())) - { - Toast.Add("Image Format Not Supported.", Severity.Error); - return; - } - - string? fileName = $"{_userId}-{Guid.NewGuid():N}"; - fileName = fileName[..Math.Min(fileName.Length, 90)]; - var imageFile = await file.RequestImageFileAsync(AppConstants.StandardImageFormat, AppConstants.MaxImageWidth, AppConstants.MaxImageHeight); - byte[]? buffer = new byte[imageFile.Size]; - await imageFile.OpenReadStream(AppConstants.MaxAllowedSize).ReadAsync(buffer); - string? base64String = $"data:{AppConstants.StandardImageFormat};base64,{Convert.ToBase64String(buffer)}"; - _profileModel.Image = new FileUploadCommand() { Name = fileName, Data = base64String, Extension = extension }; - - await UpdateProfileAsync(); - } - } - - public async Task RemoveImageAsync() - { - string deleteContent = "You're sure you want to delete your Profile Image?"; - var parameters = new DialogParameters - { - { nameof(DeleteConfirmation.ContentText), deleteContent } - }; - var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small, FullWidth = true, BackdropClick = false }; - var dialog = await DialogService.ShowAsync("Delete", parameters, options); - var result = await dialog.Result; - if (!result!.Canceled) - { - _profileModel.DeleteCurrentImage = true; - await UpdateProfileAsync(); - } - } -} diff --git a/src/apps/blazor/client/Pages/Identity/Account/Security.razor b/src/apps/blazor/client/Pages/Identity/Account/Security.razor deleted file mode 100644 index 4e6a3841bb..0000000000 --- a/src/apps/blazor/client/Pages/Identity/Account/Security.razor +++ /dev/null @@ -1,39 +0,0 @@ - - - - - Change Password - - - - - - - - - - - - - - - - - - - - Change Password - - - \ No newline at end of file diff --git a/src/apps/blazor/client/Pages/Identity/Account/Security.razor.cs b/src/apps/blazor/client/Pages/Identity/Account/Security.razor.cs deleted file mode 100644 index e9dcd200da..0000000000 --- a/src/apps/blazor/client/Pages/Identity/Account/Security.razor.cs +++ /dev/null @@ -1,71 +0,0 @@ -using FSH.Starter.Blazor.Client.Components; -using FSH.Starter.Blazor.Infrastructure.Api; -using Microsoft.AspNetCore.Components; -using MudBlazor; - -namespace FSH.Starter.Blazor.Client.Pages.Identity.Account; - -public partial class Security -{ - [Inject] - public IApiClient PersonalClient { get; set; } = default!; - - private readonly ChangePasswordCommand _passwordModel = new(); - - private FshValidation? _customValidation; - - private async Task ChangePasswordAsync() - { - if (await ApiHelper.ExecuteCallGuardedAsync( - () => PersonalClient.ChangePasswordEndpointAsync(_passwordModel), - Toast, - _customValidation, - "Password Changed!")) - { - _passwordModel.Password = string.Empty; - _passwordModel.NewPassword = string.Empty; - _passwordModel.ConfirmNewPassword = string.Empty; - } - } - - private bool _currentPasswordVisibility; - private InputType _currentPasswordInput = InputType.Password; - private string _currentPasswordInputIcon = Icons.Material.Filled.VisibilityOff; - private bool _newPasswordVisibility; - private InputType _newPasswordInput = InputType.Password; - private string _newPasswordInputIcon = Icons.Material.Filled.VisibilityOff; - - private void TogglePasswordVisibility(bool newPassword) - { - if (newPassword) - { - if (_newPasswordVisibility) - { - _newPasswordVisibility = false; - _newPasswordInputIcon = Icons.Material.Filled.VisibilityOff; - _newPasswordInput = InputType.Password; - } - else - { - _newPasswordVisibility = true; - _newPasswordInputIcon = Icons.Material.Filled.Visibility; - _newPasswordInput = InputType.Text; - } - } - else - { - if (_currentPasswordVisibility) - { - _currentPasswordVisibility = false; - _currentPasswordInputIcon = Icons.Material.Filled.VisibilityOff; - _currentPasswordInput = InputType.Password; - } - else - { - _currentPasswordVisibility = true; - _currentPasswordInputIcon = Icons.Material.Filled.Visibility; - _currentPasswordInput = InputType.Text; - } - } - } -} diff --git a/src/apps/blazor/client/Pages/Identity/Roles/RolePermissions.razor b/src/apps/blazor/client/Pages/Identity/Roles/RolePermissions.razor deleted file mode 100644 index 0124ba98f9..0000000000 --- a/src/apps/blazor/client/Pages/Identity/Roles/RolePermissions.razor +++ /dev/null @@ -1,73 +0,0 @@ -@page "/identity/roles/{Id}/permissions" - - - -@if (!_loaded) -{ - -} -else -{ - - @foreach (var group in _groupedRoleClaims.Keys) - { - var selectedRoleClaimsInGroup = _groupedRoleClaims[group].Where(c => c.Enabled).ToList(); - var allRoleClaimsInGroup = _groupedRoleClaims[group].ToList(); - - - -
- - Back - - @if (_canEditRoleClaims) - { - Update Permissions - - } -
- - @if (_canSearchRoleClaims) - { - - - } -
- - - - Permission Name - - - - Description - - - Status - - - - - - - - - - - - - - - - - - -
-
- } -
-} \ No newline at end of file diff --git a/src/apps/blazor/client/Pages/Identity/Roles/RolePermissions.razor.cs b/src/apps/blazor/client/Pages/Identity/Roles/RolePermissions.razor.cs deleted file mode 100644 index 9f92a0e566..0000000000 --- a/src/apps/blazor/client/Pages/Identity/Roles/RolePermissions.razor.cs +++ /dev/null @@ -1,109 +0,0 @@ -using FSH.Starter.Blazor.Client.Components; -using FSH.Starter.Blazor.Infrastructure.Api; -using FSH.Starter.Blazor.Infrastructure.Auth; -using FSH.Starter.Shared.Authorization; -using Mapster; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Authorization; -using MudBlazor; - -namespace FSH.Starter.Blazor.Client.Pages.Identity.Roles; - -public partial class RolePermissions -{ - [Parameter] - public string Id { get; set; } = default!; // from route - [CascadingParameter] - protected Task AuthState { get; set; } = default!; - [Inject] - protected IAuthorizationService AuthService { get; set; } = default!; - [Inject] - protected IApiClient RolesClient { get; set; } = default!; - - private Dictionary> _groupedRoleClaims = default!; - - public string _title = string.Empty; - public string _description = string.Empty; - - private string _searchString = string.Empty; - - private bool _canEditRoleClaims; - private bool _canSearchRoleClaims; - private bool _loaded; - - static RolePermissions() => TypeAdapterConfig.NewConfig().MapToConstructor(true); - - protected override async Task OnInitializedAsync() - { - var state = await AuthState; - - _canEditRoleClaims = await AuthService.HasPermissionAsync(state.User, FshActions.Update, FshResources.RoleClaims); - _canSearchRoleClaims = await AuthService.HasPermissionAsync(state.User, FshActions.View, FshResources.RoleClaims); - - if (await ApiHelper.ExecuteCallGuardedAsync( - () => RolesClient.GetRolePermissionsEndpointAsync(Id), Toast, Navigation) - is RoleDto role && role.Permissions is not null) - { - _title = string.Format("{0} Permissions", role.Name); - _description = string.Format("Manage {0} Role Permissions", role.Name); - - var permissions = state.User.GetTenant() == TenantConstants.Root.Id - ? FshPermissions.All - : FshPermissions.Admin; - - _groupedRoleClaims = permissions - .GroupBy(p => p.Resource) - .ToDictionary(g => g.Key, g => g.Select(p => - { - var permission = p.Adapt(); - permission.Enabled = role.Permissions.Contains(permission.Name); - return permission; - }).ToList()); - } - - _loaded = true; - } - - private Color GetGroupBadgeColor(int selected, int all) - { - if (selected == 0) - return Color.Error; - - if (selected == all) - return Color.Success; - - return Color.Info; - } - - private async Task SaveAsync() - { - var allPermissions = _groupedRoleClaims.Values.SelectMany(a => a); - var selectedPermissions = allPermissions.Where(a => a.Enabled); - var request = new UpdatePermissionsCommand() - { - RoleId = Id, - Permissions = selectedPermissions.Where(x => x.Enabled).Select(x => x.Name).ToList(), - }; - await ApiHelper.ExecuteCallGuardedAsync( - () => RolesClient.UpdateRolePermissionsEndpointAsync(request.RoleId, request), - Toast, - successMessage: "Updated Permissions."); - Navigation.NavigateTo("/identity/roles"); - } - - private bool Search(PermissionViewModel permission) => - string.IsNullOrWhiteSpace(_searchString) - || permission.Name.Contains(_searchString, StringComparison.OrdinalIgnoreCase) is true - || permission.Description.Contains(_searchString, StringComparison.OrdinalIgnoreCase) is true; -} - -public record PermissionViewModel : FshPermission -{ - public bool Enabled { get; set; } - - public PermissionViewModel(string Description, string Action, string Resource, bool IsBasic = false, bool IsRoot = false) - : base(Description, Action, Resource, IsBasic, IsRoot) - { - } -} diff --git a/src/apps/blazor/client/Pages/Identity/Roles/Roles.razor b/src/apps/blazor/client/Pages/Identity/Roles/Roles.razor deleted file mode 100644 index 55f909cdbd..0000000000 --- a/src/apps/blazor/client/Pages/Identity/Roles/Roles.razor +++ /dev/null @@ -1,28 +0,0 @@ -@page "/identity/roles" - - - - - - @if (_canViewRoleClaims) - { - Manage Permission - } - - - @if (!Context.AddEditModal.IsCreate) - { - - - - } - - - - - - - - - \ No newline at end of file diff --git a/src/apps/blazor/client/Pages/Identity/Roles/Roles.razor.cs b/src/apps/blazor/client/Pages/Identity/Roles/Roles.razor.cs deleted file mode 100644 index 848384182c..0000000000 --- a/src/apps/blazor/client/Pages/Identity/Roles/Roles.razor.cs +++ /dev/null @@ -1,60 +0,0 @@ -using FSH.Starter.Blazor.Client.Components.EntityTable; -using FSH.Starter.Blazor.Infrastructure.Api; -using FSH.Starter.Blazor.Infrastructure.Auth; -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Authorization; - -namespace FSH.Starter.Blazor.Client.Pages.Identity.Roles; - -public partial class Roles -{ - [CascadingParameter] - protected Task AuthState { get; set; } = default!; - [Inject] - protected IAuthorizationService AuthService { get; set; } = default!; - [Inject] - private IApiClient RolesClient { get; set; } = default!; - - protected EntityClientTableContext Context { get; set; } = default!; - - private bool _canViewRoleClaims; - - protected override async Task OnInitializedAsync() - { - var state = await AuthState; - _canViewRoleClaims = await AuthService.HasPermissionAsync(state.User, FshActions.View, FshResources.RoleClaims); - - Context = new( - entityName: "Role", - entityNamePlural: "Roles", - entityResource: FshResources.Roles, - searchAction: FshActions.View, - fields: new() - { - new(role => role.Id, "Id"), - new(role => role.Name,"Name"), - new(role => role.Description, "Description") - }, - idFunc: role => role.Id, - loadDataFunc: async () => (await RolesClient.GetRolesEndpointAsync()).ToList(), - searchFunc: (searchString, role) => - string.IsNullOrWhiteSpace(searchString) - || role.Name?.Contains(searchString, StringComparison.OrdinalIgnoreCase) == true - || role.Description?.Contains(searchString, StringComparison.OrdinalIgnoreCase) == true, - createFunc: async role => await RolesClient.CreateOrUpdateRoleEndpointAsync(role), - updateFunc: async (_, role) => await RolesClient.CreateOrUpdateRoleEndpointAsync(role), - deleteFunc: async id => await RolesClient.DeleteRoleEndpointAsync(id!), - hasExtraActionsFunc: () => _canViewRoleClaims, - canUpdateEntityFunc: e => !FshRoles.IsDefault(e.Name!), - canDeleteEntityFunc: e => !FshRoles.IsDefault(e.Name!), - exportAction: string.Empty); - } - - private void ManagePermissions(string? roleId) - { - ArgumentNullException.ThrowIfNull(roleId, nameof(roleId)); - Navigation.NavigateTo($"/identity/roles/{roleId}/permissions"); - } -} diff --git a/src/apps/blazor/client/Pages/Identity/Users/Audit.razor b/src/apps/blazor/client/Pages/Identity/Users/Audit.razor deleted file mode 100644 index 8da26afad7..0000000000 --- a/src/apps/blazor/client/Pages/Identity/Users/Audit.razor +++ /dev/null @@ -1,122 +0,0 @@ -@page "/identity/users/{Id:guid}/audit-trail" -@page "/identity/audit-trail" - - - - - - - - @((context.ShowDetails == true) ? "Hide" : "Show") Trail Details - - - - @if (context.ShowDetails) - { - - - - - - Details for Audit Trail with Id : @context.Id - - - - - - @if (!string.IsNullOrEmpty(context.ModifiedProperties)) - { - - - - - } - @if (!string.IsNullOrEmpty(context.PrimaryKey)) - { - - - - - } - @if (!string.IsNullOrEmpty(context.PreviousValues)) - { - - - - - } - @if (!string.IsNullOrEmpty(context.NewValues)) - { - - - - - } - -
Modified Properties - - @foreach (var column in context.ModifiedProperties.Trim('[').Trim(']').Split(',')) - { - @column.Replace('"', ' ').Trim() - } - -
Primary Key - - @context.PrimaryKey?.Trim('{').Trim('}').Replace('"', ' ').Trim() - -
Previous Values - - - @foreach (var value in context.PreviousValues.Trim('{').Trim('}').Split(',')) - { - @if (_searchInOldValues) - { - - - - } - else - { - @value.Replace('"', ' ').Trim() - } - } - -
Current Values - - - @foreach (var value in context.NewValues.Trim('{').Trim('}').Split(',')) - { - @if (_searchInNewValues) - { - - - - } - else - { - @value.Replace('"', ' ').Trim() - } - } - -
-
-
- -
- } -
- -
- -@code { - private RenderFragment DateFieldTemplate => trail => __builder => - { - - @trail.DateTime.ToString("dd-MMMM-yyyy hh:mm tt") - - - @trail.UTCTime.ToString("dd-MMMM-yyyy hh:mm tt") (UTC) - - }; -} \ No newline at end of file diff --git a/src/apps/blazor/client/Pages/Identity/Users/Audit.razor.cs b/src/apps/blazor/client/Pages/Identity/Users/Audit.razor.cs deleted file mode 100644 index 89b6a39923..0000000000 --- a/src/apps/blazor/client/Pages/Identity/Users/Audit.razor.cs +++ /dev/null @@ -1,89 +0,0 @@ -using FSH.Starter.Blazor.Client.Components.EntityTable; -using FSH.Starter.Blazor.Infrastructure.Api; -using FSH.Starter.Shared.Authorization; -using Mapster; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Authorization; -using MudBlazor; - -namespace FSH.Starter.Blazor.Client.Pages.Identity.Users; - -public partial class Audit -{ - [Inject] - private IApiClient ApiClient { get; set; } = default!; - [CascadingParameter] - protected Task AuthState { get; set; } = default!; - [Parameter] - public Guid Id { get; set; } - - protected EntityClientTableContext Context { get; set; } = default!; - - private string? _searchString; - private string? _subHeader; - private MudDateRangePicker _dateRangePicker = default!; - private DateRange? _dateRange; - private bool _searchInOldValues; - private bool _searchInNewValues; - private List _trails = new(); - - // Configure Automapper - static Audit() => - TypeAdapterConfig.NewConfig().Map( - dest => dest.UTCTime, - src => DateTime.SpecifyKind(src.DateTime, DateTimeKind.Utc).ToLocalTime()); - - - - protected override async Task OnInitializedAsync() - { - if (Id == Guid.Empty) - { - var state = await AuthState; - if (state != null) - { - Id = new Guid(state.User.GetUserId()!); - } - } - _subHeader = $"Audit Trail for User {Id}"; - Context = new( - entityNamePlural: "Trails", - searchAction: true.ToString(), - fields: new() - { - new(audit => audit.Id,"Id"), - new(audit => audit.Entity, "Entity"), - new(audit => audit.DateTime, "Date", Template: DateFieldTemplate), - new(audit => audit.Operation, "Operation") - }, - loadDataFunc: async () => _trails = (await ApiClient.GetUserAuditTrailEndpointAsync(Id)).Adapt>(), - searchFunc: (searchString, trail) => - (string.IsNullOrWhiteSpace(searchString) // check Search String - || trail.Entity?.Contains(searchString, StringComparison.OrdinalIgnoreCase) == true - || (_searchInOldValues && - trail.PreviousValues?.Contains(searchString, StringComparison.OrdinalIgnoreCase) == true) - || (_searchInNewValues && - trail.NewValues?.Contains(searchString, StringComparison.OrdinalIgnoreCase) == true)) - && ((_dateRange?.Start is null && _dateRange?.End is null) // check Date Range - || (_dateRange?.Start is not null && _dateRange.End is null && trail.DateTime >= _dateRange.Start) - || (_dateRange?.Start is null && _dateRange?.End is not null && trail.DateTime <= _dateRange.End + new TimeSpan(0, 11, 59, 59, 999)) - || (trail.DateTime >= _dateRange!.Start && trail.DateTime <= _dateRange.End + new TimeSpan(0, 11, 59, 59, 999))), - hasExtraActionsFunc: () => true); - } - - private void ShowBtnPress(Guid id) - { - var trail = _trails.First(f => f.Id == id); - trail.ShowDetails = !trail.ShowDetails; - foreach (var otherTrail in _trails.Except(new[] { trail })) - { - otherTrail.ShowDetails = false; - } - } - - public class RelatedAuditTrail : AuditTrail - { - public bool ShowDetails { get; set; } - public DateTime UTCTime { get; set; } - } -} diff --git a/src/apps/blazor/client/Pages/Identity/Users/UserProfile.razor b/src/apps/blazor/client/Pages/Identity/Users/UserProfile.razor deleted file mode 100644 index 6da18c6e3e..0000000000 --- a/src/apps/blazor/client/Pages/Identity/Users/UserProfile.razor +++ /dev/null @@ -1,147 +0,0 @@ -@page "/identity/users/{Id}/profile" - - - -@if (!_loaded) -{ - -} -else -{ - - - @if (_canToggleUserStatus) - { - - - - - Administrator Settings. - This is an Administrator Only View. - - - - - - - Save Changes - - - - - - } - - - - -
- @if (_imageUrl != null) - { - - - - } - else - { - @_firstLetterOfName - - } -
- @_firstName @_lastName - @_email -
- -
- @if (_imageUrl != null) - { - - View - - } -
- -
-
-
- - - - - Public Profile - - - - - - @_firstName - - - @_lastName - - - @_phoneNumber - - - - @_email - - - - - -
-} - -@code -{ -public class CustomStringToBoolConverter : BoolConverter - { - - public CustomStringToBoolConverter() - { - SetFunc = OnSet; - GetFunc = OnGet; - } - private string TrueString = "User Active"; - private string FalseString = "no, at all"; - private string NullString = "I don't know"; - - private string OnGet(bool? value) - { - try - { - return (value == true) ? TrueString : FalseString; - } - catch (Exception e) - { - UpdateGetError("Conversion error: " + e.Message); - return NullString; - } - } - - private bool? OnSet(string arg) - { - if (arg == null) - return null; - try - { - if (arg == TrueString) - return true; - if (arg == FalseString) - return false; - else - return null; - } - catch (FormatException e) - { - UpdateSetError("Conversion error: " + e.Message); - return null; - } - } - - } -} \ No newline at end of file diff --git a/src/apps/blazor/client/Pages/Identity/Users/UserProfile.razor.cs b/src/apps/blazor/client/Pages/Identity/Users/UserProfile.razor.cs deleted file mode 100644 index e46bc35faa..0000000000 --- a/src/apps/blazor/client/Pages/Identity/Users/UserProfile.razor.cs +++ /dev/null @@ -1,73 +0,0 @@ -using FSH.Starter.Blazor.Client.Components; -using FSH.Starter.Blazor.Infrastructure.Api; -using FSH.Starter.Blazor.Infrastructure.Auth; -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Authorization; - -namespace FSH.Starter.Blazor.Client.Pages.Identity.Users; - -public partial class UserProfile -{ - [CascadingParameter] - protected Task AuthState { get; set; } = default!; - [Inject] - protected IAuthorizationService AuthService { get; set; } = default!; - [Inject] - protected IApiClient UsersClient { get; set; } = default!; - - [Parameter] - public string? Id { get; set; } - [Parameter] - public string? Title { get; set; } - [Parameter] - public string? Description { get; set; } - - private bool _active; - private bool _emailConfirmed; - private char _firstLetterOfName; - private string? _firstName; - private string? _lastName; - private string? _phoneNumber; - private string? _email; - private Uri? _imageUrl; - private bool _loaded; - private bool _canToggleUserStatus; - - private async Task ToggleUserStatus() - { - var request = new ToggleUserStatusCommand { ActivateUser = _active, UserId = Id }; - await ApiHelper.ExecuteCallGuardedAsync(() => UsersClient.ToggleUserStatusEndpointAsync(Id!, request), Toast); - Navigation.NavigateTo("/identity/users"); - } - - [Parameter] - public string? ImageUrl { get; set; } - - protected override async Task OnInitializedAsync() - { - if (await ApiHelper.ExecuteCallGuardedAsync( - () => UsersClient.GetUserEndpointAsync(Id!), Toast, Navigation) - is UserDetail user) - { - _firstName = user.FirstName; - _lastName = user.LastName; - _email = user.Email; - _phoneNumber = user.PhoneNumber; - _active = user.IsActive; - _emailConfirmed = user.EmailConfirmed; - _imageUrl = user.ImageUrl; - Title = $"{_firstName} {_lastName}'s Profile"; - Description = _email; - if (_firstName?.Length > 0) - { - _firstLetterOfName = _firstName.ToUpperInvariant().FirstOrDefault(); - } - } - - var state = await AuthState; - _canToggleUserStatus = await AuthService.HasPermissionAsync(state.User, FshActions.Update, FshResources.Users); - _loaded = true; - } -} diff --git a/src/apps/blazor/client/Pages/Identity/Users/UserRoles.razor b/src/apps/blazor/client/Pages/Identity/Users/UserRoles.razor deleted file mode 100644 index 9684ead141..0000000000 --- a/src/apps/blazor/client/Pages/Identity/Users/UserRoles.razor +++ /dev/null @@ -1,66 +0,0 @@ -@page "/identity/users/{Id}/roles" - - - -@if (!_loaded) -{ - -} -else -{ - - -
- - Back - - @if (_canEditUsers) - { - - Update - - } -
- - @if (_canSearchRoles) - { - - - } -
- - - Role Name - - - - - Description - - - - - Status - - - - - - - - - - - - - - - - - - -
-} \ No newline at end of file diff --git a/src/apps/blazor/client/Pages/Identity/Users/UserRoles.razor.cs b/src/apps/blazor/client/Pages/Identity/Users/UserRoles.razor.cs deleted file mode 100644 index 66972a46a2..0000000000 --- a/src/apps/blazor/client/Pages/Identity/Users/UserRoles.razor.cs +++ /dev/null @@ -1,78 +0,0 @@ -using FSH.Starter.Blazor.Client.Components; -using FSH.Starter.Blazor.Infrastructure.Api; -using FSH.Starter.Blazor.Infrastructure.Auth; -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Authorization; - -namespace FSH.Starter.Blazor.Client.Pages.Identity.Users; - -public partial class UserRoles -{ - [Parameter] - public string? Id { get; set; } - [CascadingParameter] - protected Task AuthState { get; set; } = default!; - [Inject] - protected IAuthorizationService AuthService { get; set; } = default!; - [Inject] - protected IApiClient UsersClient { get; set; } = default!; - - private List _userRolesList = default!; - - private string _title = string.Empty; - private string _description = string.Empty; - - private string _searchString = string.Empty; - - private bool _canEditUsers; - private bool _canSearchRoles; - private bool _loaded; - - protected override async Task OnInitializedAsync() - { - var state = await AuthState; - - _canEditUsers = await AuthService.HasPermissionAsync(state.User, FshActions.Update, FshResources.Users); - _canSearchRoles = await AuthService.HasPermissionAsync(state.User, FshActions.View, FshResources.UserRoles); - - if (await ApiHelper.ExecuteCallGuardedAsync( - () => UsersClient.GetUserEndpointAsync(Id!), Toast, Navigation) - is UserDetail user) - { - _title = $"{user.FirstName} {user.LastName}'s Roles"; - _description = string.Format("Manage {0} {1}'s Roles", user.FirstName, user.LastName); - - if (await ApiHelper.ExecuteCallGuardedAsync( - () => UsersClient.GetUserRolesEndpointAsync(user.Id.ToString()), Toast, Navigation) - is ICollection response) - { - _userRolesList = response.ToList(); - } - } - - _loaded = true; - } - - private async Task SaveAsync() - { - var request = new AssignUserRoleCommand() - { - UserRoles = _userRolesList - }; - - Console.WriteLine($"roles : {request.UserRoles.Count}"); - - await ApiHelper.ExecuteCallGuardedAsync( - () => UsersClient.AssignRolesToUserEndpointAsync(Id, request), - Toast, - successMessage: "updated user roles"); - - Navigation.NavigateTo("/identity/users"); - } - - private bool Search(UserRoleDetail userRole) => - string.IsNullOrWhiteSpace(_searchString) - || userRole.RoleName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) is true; -} diff --git a/src/apps/blazor/client/Pages/Identity/Users/Users.razor b/src/apps/blazor/client/Pages/Identity/Users/Users.razor deleted file mode 100644 index 1986717c19..0000000000 --- a/src/apps/blazor/client/Pages/Identity/Users/Users.razor +++ /dev/null @@ -1,47 +0,0 @@ -@page "/identity/users" - - - - - - View Profile - @if (_canViewRoles) - { - Manage Roles - } - @if (_canViewAuditTrails) - { - View Audit Trails - } - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/apps/blazor/client/Pages/Identity/Users/Users.razor.cs b/src/apps/blazor/client/Pages/Identity/Users/Users.razor.cs deleted file mode 100644 index 24da0adff1..0000000000 --- a/src/apps/blazor/client/Pages/Identity/Users/Users.razor.cs +++ /dev/null @@ -1,99 +0,0 @@ -using FSH.Starter.Blazor.Client.Components.EntityTable; -using FSH.Starter.Blazor.Infrastructure.Api; -using FSH.Starter.Blazor.Infrastructure.Auth; -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Authorization; -using MudBlazor; - -namespace FSH.Starter.Blazor.Client.Pages.Identity.Users; - -public partial class Users -{ - [CascadingParameter] - protected Task AuthState { get; set; } = default!; - [Inject] - protected IAuthorizationService AuthService { get; set; } = default!; - - [Inject] - protected IApiClient UsersClient { get; set; } = default!; - - protected EntityClientTableContext Context { get; set; } = default!; - - private bool _canExportUsers; - private bool _canViewAuditTrails; - private bool _canViewRoles; - - // Fields for editform - protected string Password { get; set; } = string.Empty; - protected string ConfirmPassword { get; set; } = string.Empty; - - private bool _passwordVisibility; - private InputType _passwordInput = InputType.Password; - private string _passwordInputIcon = Icons.Material.Filled.VisibilityOff; - - protected override async Task OnInitializedAsync() - { - var user = (await AuthState).User; - _canExportUsers = await AuthService.HasPermissionAsync(user, FshActions.Export, FshResources.Users); - _canViewRoles = await AuthService.HasPermissionAsync(user, FshActions.View, FshResources.UserRoles); - _canViewAuditTrails = await AuthService.HasPermissionAsync(user, FshActions.View, FshResources.AuditTrails); - - Context = new( - entityName: "User", - entityNamePlural: "Users", - entityResource: FshResources.Users, - searchAction: FshActions.View, - updateAction: string.Empty, - deleteAction: string.Empty, - fields: new() - { - new(user => user.FirstName,"First Name"), - new(user => user.LastName, "Last Name"), - new(user => user.UserName, "UserName"), - new(user => user.Email, "Email"), - new(user => user.PhoneNumber, "PhoneNumber"), - new(user => user.EmailConfirmed, "Email Confirmation", Type: typeof(bool)), - new(user => user.IsActive, "Active", Type: typeof(bool)) - }, - idFunc: user => user.Id, - loadDataFunc: async () => (await UsersClient.GetUsersListEndpointAsync()).ToList(), - searchFunc: (searchString, user) => - string.IsNullOrWhiteSpace(searchString) - || user.FirstName?.Contains(searchString, StringComparison.OrdinalIgnoreCase) == true - || user.LastName?.Contains(searchString, StringComparison.OrdinalIgnoreCase) == true - || user.Email?.Contains(searchString, StringComparison.OrdinalIgnoreCase) == true - || user.PhoneNumber?.Contains(searchString, StringComparison.OrdinalIgnoreCase) == true - || user.UserName?.Contains(searchString, StringComparison.OrdinalIgnoreCase) == true, - createFunc: user => UsersClient.RegisterUserEndpointAsync(user), - hasExtraActionsFunc: () => true, - exportAction: string.Empty); - } - - private void ViewProfile(in Guid userId) => - Navigation.NavigateTo($"/identity/users/{userId}/profile"); - - private void ManageRoles(in Guid userId) => - Navigation.NavigateTo($"/identity/users/{userId}/roles"); - private void ViewAuditTrails(in Guid userId) => - Navigation.NavigateTo($"/identity/users/{userId}/audit-trail"); - - private void TogglePasswordVisibility() - { - if (_passwordVisibility) - { - _passwordVisibility = false; - _passwordInputIcon = Icons.Material.Filled.VisibilityOff; - _passwordInput = InputType.Password; - } - else - { - _passwordVisibility = true; - _passwordInputIcon = Icons.Material.Filled.Visibility; - _passwordInput = InputType.Text; - } - - Context.AddEditModal.ForceRender(); - } -} diff --git a/src/apps/blazor/client/Pages/Multitenancy/Tenants.razor b/src/apps/blazor/client/Pages/Multitenancy/Tenants.razor deleted file mode 100644 index a51e8e0239..0000000000 --- a/src/apps/blazor/client/Pages/Multitenancy/Tenants.razor +++ /dev/null @@ -1,86 +0,0 @@ -@page "/tenants" - -@inject IAuthenticationService Authentication - - - - - - - - - - - - - - - - - - - - @if(_canUpgrade) - { - Upgrade Subscription - } - - @((context.ShowDetails == true) ? "Hide" : "Show") Tenant Details - - @if (_canModify) - { - @if (!context.IsActive) - { - Activate Tenant - } - else - { - Deactivate Tenant - } - } - - - - @if (context.ShowDetails) - { - - - - - - Details for Tenant : - @context.Id - - - - - - - - @if(string.IsNullOrEmpty(context.ConnectionString?.Trim())) - { - Shared Database - } - else - { - - - } - - -
Connection String - - @context.ConnectionString?.Trim() - -
-
-
- -
- } -
- -
\ No newline at end of file diff --git a/src/apps/blazor/client/Pages/Multitenancy/Tenants.razor.cs b/src/apps/blazor/client/Pages/Multitenancy/Tenants.razor.cs deleted file mode 100644 index 3dbc8d41b4..0000000000 --- a/src/apps/blazor/client/Pages/Multitenancy/Tenants.razor.cs +++ /dev/null @@ -1,121 +0,0 @@ -using FSH.Starter.Blazor.Client.Components; -using FSH.Starter.Blazor.Client.Components.EntityTable; -using FSH.Starter.Blazor.Infrastructure.Api; -using FSH.Starter.Blazor.Infrastructure.Auth; -using FSH.Starter.Shared.Authorization; -using Mapster; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Authorization; -using MudBlazor; - -namespace FSH.Starter.Blazor.Client.Pages.Multitenancy; - -public partial class Tenants -{ - [Inject] - private IApiClient ApiClient { get; set; } = default!; - private string? _searchString; - protected EntityClientTableContext Context { get; set; } = default!; - private List _tenants = new(); - public EntityTable EntityTable { get; set; } = default!; - [CascadingParameter] - protected Task AuthState { get; set; } = default!; - [Inject] - protected IAuthorizationService AuthService { get; set; } = default!; - - private bool _canUpgrade; - private bool _canModify; - - protected override async Task OnInitializedAsync() - { - Context = new( - entityName: "Tenant", - entityNamePlural: "Tenants", - entityResource: FshResources.Tenants, - searchAction: FshActions.View, - deleteAction: string.Empty, - updateAction: string.Empty, - fields: new() - { - new(tenant => tenant.Id, "Id"), - new(tenant => tenant.Name, "Name"), - new(tenant => tenant.AdminEmail, "Admin Email"), - new(tenant => tenant.ValidUpto.ToString("MMM dd, yyyy"), "Valid Upto"), - new(tenant => tenant.IsActive, "Active", Type: typeof(bool)) - }, - loadDataFunc: async () => _tenants = (await ApiClient.GetTenantsEndpointAsync()).Adapt>(), - searchFunc: (searchString, tenantDto) => - string.IsNullOrWhiteSpace(searchString) - || tenantDto.Name.Contains(searchString, StringComparison.OrdinalIgnoreCase), - createFunc: tenant => ApiClient.CreateTenantEndpointAsync(tenant.Adapt()), - hasExtraActionsFunc: () => true, - exportAction: string.Empty); - - var state = await AuthState; - _canUpgrade = await AuthService.HasPermissionAsync(state.User, FshActions.UpgradeSubscription, FshResources.Tenants); - _canModify = await AuthService.HasPermissionAsync(state.User, FshActions.Update, FshResources.Tenants); - } - - private void ViewTenantDetails(string id) - { - var tenant = _tenants.First(f => f.Id == id); - tenant.ShowDetails = !tenant.ShowDetails; - foreach (var otherTenants in _tenants.Except(new[] { tenant })) - { - otherTenants.ShowDetails = false; - } - } - - private async Task ViewUpgradeSubscriptionModalAsync(string id) - { - var tenant = _tenants.First(f => f.Id == id); - var parameters = new DialogParameters - { - { - nameof(UpgradeSubscriptionModal.Request), - new UpgradeSubscriptionCommand - { - Tenant = tenant.Id, - ExtendedExpiryDate = tenant.ValidUpto - } - } - }; - var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small, FullWidth = true, BackdropClick = false }; - var dialog = DialogService.Show("Upgrade Subscription", parameters, options); - var result = await dialog.Result; - if (!result.Canceled) - { - await EntityTable.ReloadDataAsync(); - } - } - - private async Task DeactivateTenantAsync(string id) - { - if (await ApiHelper.ExecuteCallGuardedAsync( - () => ApiClient.DisableTenantEndpointAsync(id), - Toast, Navigation, - null, - "Tenant Deactivated.") is not null) - { - await EntityTable.ReloadDataAsync(); - } - } - - private async Task ActivateTenantAsync(string id) - { - if (await ApiHelper.ExecuteCallGuardedAsync( - () => ApiClient.ActivateTenantEndpointAsync(id), - Toast, Navigation, - null, - "Tenant Activated.") is not null) - { - await EntityTable.ReloadDataAsync(); - } - } - - public class TenantViewModel : TenantDetail - { - public bool ShowDetails { get; set; } - } -} diff --git a/src/apps/blazor/client/Pages/Multitenancy/UpgradeSubscriptionModal.razor b/src/apps/blazor/client/Pages/Multitenancy/UpgradeSubscriptionModal.razor deleted file mode 100644 index f91a7b5975..0000000000 --- a/src/apps/blazor/client/Pages/Multitenancy/UpgradeSubscriptionModal.razor +++ /dev/null @@ -1,57 +0,0 @@ -@inject IApiClient TenantsClient - - - - - - - Upgrade Subscription - - - - - - - - - - - - - - - - - Cancel - Upgrade - - - - -@code -{ - [Parameter] public UpgradeSubscriptionCommand Request { get; set; } = new(); - [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!; - DateTime? date = DateTime.Today; - - protected override void OnInitialized() => - date = Request.ExtendedExpiryDate; - - private async Task UpgradeSubscriptionAsync() - { - Request.ExtendedExpiryDate = date.HasValue ? date.Value : Request.ExtendedExpiryDate; - if (await ApiHelper.ExecuteCallGuardedAsync( - () => TenantsClient.UpgradeSubscriptionEndpointAsync(Request), - Toast, Navigation, - null, - "Upgraded Subscription.") is not null) - { - MudDialog.Close(); - } - } - - public void Cancel() - { - MudDialog.Cancel(); - } -} \ No newline at end of file diff --git a/src/apps/blazor/client/Pages/Todos/Todos.razor b/src/apps/blazor/client/Pages/Todos/Todos.razor deleted file mode 100644 index 9c3ce5d704..0000000000 --- a/src/apps/blazor/client/Pages/Todos/Todos.razor +++ /dev/null @@ -1,39 +0,0 @@ -@page "/todos" - - - - - - @if (!Context.AddEditModal.IsCreate) - { - - - - } - - - - - - - - -
- @if(!Context.AddEditModal.IsCreate) - { - - View - - - - Delete - - } -
-
-
-
- -
\ No newline at end of file diff --git a/src/apps/blazor/client/Pages/Todos/Todos.razor.cs b/src/apps/blazor/client/Pages/Todos/Todos.razor.cs deleted file mode 100644 index dfc6111c5c..0000000000 --- a/src/apps/blazor/client/Pages/Todos/Todos.razor.cs +++ /dev/null @@ -1,51 +0,0 @@ -using FSH.Starter.Blazor.Client.Components.EntityTable; -using FSH.Starter.Blazor.Infrastructure.Api; -using FSH.Starter.Shared.Authorization; -using Mapster; -using Microsoft.AspNetCore.Components; - -namespace FSH.Starter.Blazor.Client.Pages.Todos; - -public partial class Todos -{ - [Inject] - protected IApiClient ApiClient { get; set; } = default!; - - protected EntityServerTableContext Context { get; set; } = default!; - - private EntityTable _table = default!; - - protected override void OnInitialized() => - Context = new( - entityName: "Todos", - entityNamePlural: "Todos", - entityResource: FshResources.Todos, - fields: new() - { - new(prod => prod.Id,"Id", "Id"), - new(prod => prod.Title,"Title", "Title"), - new(prod => prod.Note, "Note", "Note") - }, - enableAdvancedSearch: false, - idFunc: prod => prod.Id!.Value, - searchFunc: async filter => - { - var todoFilter = filter.Adapt(); - - var result = await ApiClient.GetTodoListEndpointAsync("1", todoFilter); - return result.Adapt>(); - }, - createFunc: async todo => - { - await ApiClient.CreateTodoEndpointAsync("1", todo.Adapt()); - }, - updateFunc: async (id, todo) => - { - await ApiClient.UpdateTodoEndpointAsync("1", id, todo.Adapt()); - }, - deleteFunc: async id => await ApiClient.DeleteTodoEndpointAsync("1", id)); -} - -public class TodoViewModel : UpdateTodoCommand -{ -} diff --git a/src/apps/blazor/client/Program.cs b/src/apps/blazor/client/Program.cs deleted file mode 100644 index c1026795e2..0000000000 --- a/src/apps/blazor/client/Program.cs +++ /dev/null @@ -1,11 +0,0 @@ -using FSH.Starter.Blazor.Client; -using FSH.Starter.Blazor.Infrastructure; -using Microsoft.AspNetCore.Components.Web; -using Microsoft.AspNetCore.Components.WebAssembly.Hosting; - -var builder = WebAssemblyHostBuilder.CreateDefault(args); -builder.RootComponents.Add("#app"); -builder.RootComponents.Add("head::after"); -builder.Services.AddClientServices(builder.Configuration); - -await builder.Build().RunAsync(); diff --git a/src/apps/blazor/client/Properties/launchSettings.json b/src/apps/blazor/client/Properties/launchSettings.json deleted file mode 100644 index 11f084657d..0000000000 --- a/src/apps/blazor/client/Properties/launchSettings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "http://json.schemastore.org/launchsettings.json", - "profiles": { - "https": { - "commandName": "Project", - "dotnetRunMessages": false, - "launchBrowser": true, - "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", - "applicationUrl": "https://localhost:7100;http://localhost:5100", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} \ No newline at end of file diff --git a/src/apps/blazor/client/_Imports.razor b/src/apps/blazor/client/_Imports.razor deleted file mode 100644 index 198b0183ed..0000000000 --- a/src/apps/blazor/client/_Imports.razor +++ /dev/null @@ -1,34 +0,0 @@ -@using System.Net.Http -@using System.Net.Http.Json -@using FSH.Starter.Blazor.Infrastructure.Preferences -@using Microsoft.AspNetCore.Components.Authorization -@using Microsoft.AspNetCore.Components.Forms -@using Microsoft.AspNetCore.Components.Routing -@using Microsoft.AspNetCore.Components.Web -@using Microsoft.AspNetCore.Components.Web.Virtualization -@using Microsoft.AspNetCore.Components.WebAssembly.Http -@using Microsoft.JSInterop -@using FSH.Starter.Blazor.Client -@using FSH.Starter.Blazor.Client.Layout -@using FSH.Starter.Blazor.Client.Components -@using FSH.Starter.Blazor.Client.Components.General -@using FSH.Starter.Blazor.Client.Components.Dialogs -@using FSH.Starter.Blazor.Client.Components.Common -@using FSH.Starter.Blazor.Client.Components.EntityTable -@using FSH.Starter.Blazor.Infrastructure.Auth -@using MudBlazor -@using Blazored.LocalStorage -@using FSH.Starter.Blazor.Infrastructure.Api -@using FSH.Starter.Blazor.Client.Components.ThemeManager; - -@using FSH.Starter.Blazor.Client.Pages.Auth - -@using Microsoft.AspNetCore.Authorization - -@attribute [Authorize] - -@inject NavigationManager Navigation -@inject ISnackbar Toast -@inject IDialogService DialogService -@inject IConfiguration Config -@inject IClientPreferenceManager ClientPreferences diff --git a/src/apps/blazor/client/wwwroot/appsettings.json b/src/apps/blazor/client/wwwroot/appsettings.json deleted file mode 100644 index f5a8477dca..0000000000 --- a/src/apps/blazor/client/wwwroot/appsettings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "ApiBaseUrl": "https://localhost:7000/" -} \ No newline at end of file diff --git a/src/apps/blazor/client/wwwroot/appsettings.json.TEMPLATE b/src/apps/blazor/client/wwwroot/appsettings.json.TEMPLATE deleted file mode 100644 index 42be8ab4f6..0000000000 --- a/src/apps/blazor/client/wwwroot/appsettings.json.TEMPLATE +++ /dev/null @@ -1,4 +0,0 @@ -{ - "ApiBaseUrl": "${FSHStarterBlazorClient_ApiBaseUrl}" -} - diff --git a/src/apps/blazor/client/wwwroot/css/fsh.css b/src/apps/blazor/client/wwwroot/css/fsh.css deleted file mode 100644 index 495fdb0900..0000000000 --- a/src/apps/blazor/client/wwwroot/css/fsh.css +++ /dev/null @@ -1,7 +0,0 @@ -.mud-navmenu.mud-navmenu-default .mud-nav-link.active:not(.mud-nav-link-disabled) { - color: inherit; -} - -.mud-list { - border: 1px solid var(--mud-palette-lines-default) -} \ No newline at end of file diff --git a/src/apps/blazor/client/wwwroot/favicon.png b/src/apps/blazor/client/wwwroot/favicon.png deleted file mode 100644 index 8422b59695..0000000000 Binary files a/src/apps/blazor/client/wwwroot/favicon.png and /dev/null differ diff --git a/src/apps/blazor/client/wwwroot/full-stack-hero-logo.png b/src/apps/blazor/client/wwwroot/full-stack-hero-logo.png deleted file mode 100644 index 05bdf45be1..0000000000 Binary files a/src/apps/blazor/client/wwwroot/full-stack-hero-logo.png and /dev/null differ diff --git a/src/apps/blazor/client/wwwroot/icon-192.png b/src/apps/blazor/client/wwwroot/icon-192.png deleted file mode 100644 index 166f56da76..0000000000 Binary files a/src/apps/blazor/client/wwwroot/icon-192.png and /dev/null differ diff --git a/src/apps/blazor/client/wwwroot/icon-512.png b/src/apps/blazor/client/wwwroot/icon-512.png deleted file mode 100644 index c2dd4842dc..0000000000 Binary files a/src/apps/blazor/client/wwwroot/icon-512.png and /dev/null differ diff --git a/src/apps/blazor/client/wwwroot/index.html b/src/apps/blazor/client/wwwroot/index.html deleted file mode 100644 index 8b67346c22..0000000000 --- a/src/apps/blazor/client/wwwroot/index.html +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - FSH.Starter.Blazor - - - - - - - - - - - -
- - -
-
-
-
-
- -
-
-
- -
- An unhandled error has occurred. - Reload - 🗙 -
- - - - - - diff --git a/src/apps/blazor/client/wwwroot/manifest.webmanifest b/src/apps/blazor/client/wwwroot/manifest.webmanifest deleted file mode 100644 index d2a6b40078..0000000000 --- a/src/apps/blazor/client/wwwroot/manifest.webmanifest +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "FSH.Starter.Blazor", - "short_name": "FSH.Starter.Blazor", - "id": "./", - "start_url": "./", - "display": "standalone", - "background_color": "#ffffff", - "theme_color": "#03173d", - "prefer_related_applications": false, - "icons": [ - { - "src": "icon-512.png", - "type": "image/png", - "sizes": "512x512" - }, - { - "src": "icon-192.png", - "type": "image/png", - "sizes": "192x192" - } - ] -} diff --git a/src/apps/blazor/client/wwwroot/service-worker.js b/src/apps/blazor/client/wwwroot/service-worker.js deleted file mode 100644 index fe614daee0..0000000000 --- a/src/apps/blazor/client/wwwroot/service-worker.js +++ /dev/null @@ -1,4 +0,0 @@ -// In development, always fetch from the network and do not enable offline support. -// This is because caching would make development more difficult (changes would not -// be reflected on the first load after each change). -self.addEventListener('fetch', () => { }); diff --git a/src/apps/blazor/client/wwwroot/service-worker.published.js b/src/apps/blazor/client/wwwroot/service-worker.published.js deleted file mode 100644 index 1f7f543fa5..0000000000 --- a/src/apps/blazor/client/wwwroot/service-worker.published.js +++ /dev/null @@ -1,55 +0,0 @@ -// Caution! Be sure you understand the caveats before publishing an application with -// offline support. See https://aka.ms/blazor-offline-considerations - -self.importScripts('./service-worker-assets.js'); -self.addEventListener('install', event => event.waitUntil(onInstall(event))); -self.addEventListener('activate', event => event.waitUntil(onActivate(event))); -self.addEventListener('fetch', event => event.respondWith(onFetch(event))); - -const cacheNamePrefix = 'offline-cache-'; -const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`; -const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/ ]; -const offlineAssetsExclude = [ /^service-worker\.js$/ ]; - -// Replace with your base path if you are hosting on a subfolder. Ensure there is a trailing '/'. -const base = "/"; -const baseUrl = new URL(base, self.origin); -const manifestUrlList = self.assetsManifest.assets.map(asset => new URL(asset.url, baseUrl).href); - -async function onInstall(event) { - console.info('Service worker: Install'); - - // Fetch and cache all matching items from the assets manifest - const assetsRequests = self.assetsManifest.assets - .filter(asset => offlineAssetsInclude.some(pattern => pattern.test(asset.url))) - .filter(asset => !offlineAssetsExclude.some(pattern => pattern.test(asset.url))) - .map(asset => new Request(asset.url, { integrity: asset.hash, cache: 'no-cache' })); - await caches.open(cacheName).then(cache => cache.addAll(assetsRequests)); -} - -async function onActivate(event) { - console.info('Service worker: Activate'); - - // Delete unused caches - const cacheKeys = await caches.keys(); - await Promise.all(cacheKeys - .filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName) - .map(key => caches.delete(key))); -} - -async function onFetch(event) { - let cachedResponse = null; - if (event.request.method === 'GET') { - // For all navigation requests, try to serve index.html from cache, - // unless that request is for an offline resource. - // If you need some URLs to be server-rendered, edit the following check to exclude those URLs - const shouldServeIndexHtml = event.request.mode === 'navigate' - && !manifestUrlList.some(url => url === event.request.url); - - const request = shouldServeIndexHtml ? 'index.html' : event.request; - const cache = await caches.open(cacheName); - cachedResponse = await cache.match(request); - } - - return cachedResponse || fetch(event.request); -} diff --git a/src/apps/blazor/infrastructure/Api/ApiClient.cs b/src/apps/blazor/infrastructure/Api/ApiClient.cs deleted file mode 100644 index 0de5930cbf..0000000000 --- a/src/apps/blazor/infrastructure/Api/ApiClient.cs +++ /dev/null @@ -1,6267 +0,0 @@ -//---------------------- -// -// Generated using the NSwag toolchain v14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org) -// -//---------------------- - -#nullable enable - -#pragma warning disable 108 // Disable "CS0108 '{derivedDto}.ToJson()' hides inherited member '{dtoBase}.ToJson()'. Use the new keyword if hiding was intended." -#pragma warning disable 114 // Disable "CS0114 '{derivedDto}.RaisePropertyChanged(String)' hides inherited member 'dtoBase.RaisePropertyChanged(String)'. To make the current member override that implementation, add the override keyword. Otherwise add the new keyword." -#pragma warning disable 472 // Disable "CS0472 The result of the expression is always 'false' since a value of type 'Int32' is never equal to 'null' of type 'Int32?' -#pragma warning disable 612 // Disable "CS0612 '...' is obsolete" -#pragma warning disable 649 // Disable "CS0649 Field is never assigned to, and will always have its default value null" -#pragma warning disable 1573 // Disable "CS1573 Parameter '...' has no matching param tag in the XML comment for ... -#pragma warning disable 1591 // Disable "CS1591 Missing XML comment for publicly visible type or member ..." -#pragma warning disable 8073 // Disable "CS8073 The result of the expression is always 'false' since a value of type 'T' is never equal to 'null' of type 'T?'" -#pragma warning disable 3016 // Disable "CS3016 Arrays as attribute arguments is not CLS-compliant" -#pragma warning disable 8603 // Disable "CS8603 Possible null reference return" -#pragma warning disable 8604 // Disable "CS8604 Possible null reference argument for parameter" -#pragma warning disable 8625 // Disable "CS8625 Cannot convert null literal to non-nullable reference type" -#pragma warning disable 8765 // Disable "CS8765 Nullability of type of parameter doesn't match overridden member (possibly because of nullability attributes)." - -namespace FSH.Starter.Blazor.Infrastructure.Api -{ - using System = global::System; - - [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial interface IApiClient - { - /// - /// creates a brand - /// - /// - /// creates a brand - /// - /// The requested API version - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task CreateBrandEndpointAsync(string version, CreateBrandCommand body); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// creates a brand - /// - /// - /// creates a brand - /// - /// The requested API version - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task CreateBrandEndpointAsync(string version, CreateBrandCommand body, System.Threading.CancellationToken cancellationToken); - - /// - /// gets brand by id - /// - /// - /// gets brand by id - /// - /// The requested API version - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task GetBrandEndpointAsync(string version, System.Guid id); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// gets brand by id - /// - /// - /// gets brand by id - /// - /// The requested API version - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task GetBrandEndpointAsync(string version, System.Guid id, System.Threading.CancellationToken cancellationToken); - - /// - /// update a brand - /// - /// - /// update a brand - /// - /// The requested API version - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task UpdateBrandEndpointAsync(string version, System.Guid id, UpdateBrandCommand body); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// update a brand - /// - /// - /// update a brand - /// - /// The requested API version - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task UpdateBrandEndpointAsync(string version, System.Guid id, UpdateBrandCommand body, System.Threading.CancellationToken cancellationToken); - - /// - /// deletes brand by id - /// - /// - /// deletes brand by id - /// - /// The requested API version - /// No Content - /// A server side error occurred. - System.Threading.Tasks.Task DeleteBrandEndpointAsync(string version, System.Guid id); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// deletes brand by id - /// - /// - /// deletes brand by id - /// - /// The requested API version - /// No Content - /// A server side error occurred. - System.Threading.Tasks.Task DeleteBrandEndpointAsync(string version, System.Guid id, System.Threading.CancellationToken cancellationToken); - - /// - /// Gets a list of brands - /// - /// - /// Gets a list of brands with pagination and filtering support - /// - /// The requested API version - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task SearchBrandsEndpointAsync(string version, SearchBrandsCommand body); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Gets a list of brands - /// - /// - /// Gets a list of brands with pagination and filtering support - /// - /// The requested API version - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task SearchBrandsEndpointAsync(string version, SearchBrandsCommand body, System.Threading.CancellationToken cancellationToken); - - /// - /// creates a product - /// - /// - /// creates a product - /// - /// The requested API version - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task CreateProductEndpointAsync(string version, CreateProductCommand body); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// creates a product - /// - /// - /// creates a product - /// - /// The requested API version - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task CreateProductEndpointAsync(string version, CreateProductCommand body, System.Threading.CancellationToken cancellationToken); - - /// - /// gets product by id - /// - /// - /// gets prodct by id - /// - /// The requested API version - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task GetProductEndpointAsync(string version, System.Guid id); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// gets product by id - /// - /// - /// gets prodct by id - /// - /// The requested API version - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task GetProductEndpointAsync(string version, System.Guid id, System.Threading.CancellationToken cancellationToken); - - /// - /// update a product - /// - /// - /// update a product - /// - /// The requested API version - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task UpdateProductEndpointAsync(string version, System.Guid id, UpdateProductCommand body); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// update a product - /// - /// - /// update a product - /// - /// The requested API version - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task UpdateProductEndpointAsync(string version, System.Guid id, UpdateProductCommand body, System.Threading.CancellationToken cancellationToken); - - /// - /// deletes product by id - /// - /// - /// deletes product by id - /// - /// The requested API version - /// No Content - /// A server side error occurred. - System.Threading.Tasks.Task DeleteProductEndpointAsync(string version, System.Guid id); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// deletes product by id - /// - /// - /// deletes product by id - /// - /// The requested API version - /// No Content - /// A server side error occurred. - System.Threading.Tasks.Task DeleteProductEndpointAsync(string version, System.Guid id, System.Threading.CancellationToken cancellationToken); - - /// - /// Gets a list of products - /// - /// - /// Gets a list of products with pagination and filtering support - /// - /// The requested API version - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task SearchProductsEndpointAsync(string version, SearchProductsCommand body); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Gets a list of products - /// - /// - /// Gets a list of products with pagination and filtering support - /// - /// The requested API version - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task SearchProductsEndpointAsync(string version, SearchProductsCommand body, System.Threading.CancellationToken cancellationToken); - - /// - /// Get role details by ID - /// - /// - /// Retrieve the details of a role by its ID. - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task GetRoleByIdEndpointAsync(string id); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Get role details by ID - /// - /// - /// Retrieve the details of a role by its ID. - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task GetRoleByIdEndpointAsync(string id, System.Threading.CancellationToken cancellationToken); - - /// - /// Delete a role by ID - /// - /// - /// Remove a role from the system by its ID. - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task DeleteRoleEndpointAsync(string id); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Delete a role by ID - /// - /// - /// Remove a role from the system by its ID. - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task DeleteRoleEndpointAsync(string id, System.Threading.CancellationToken cancellationToken); - - /// - /// Get a list of all roles - /// - /// - /// Retrieve a list of all roles available in the system. - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task> GetRolesEndpointAsync(); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Get a list of all roles - /// - /// - /// Retrieve a list of all roles available in the system. - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task> GetRolesEndpointAsync(System.Threading.CancellationToken cancellationToken); - - /// - /// Create or update a role - /// - /// - /// Create a new role or update an existing role. - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task CreateOrUpdateRoleEndpointAsync(CreateOrUpdateRoleCommand body); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Create or update a role - /// - /// - /// Create a new role or update an existing role. - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task CreateOrUpdateRoleEndpointAsync(CreateOrUpdateRoleCommand body, System.Threading.CancellationToken cancellationToken); - - /// - /// get role permissions - /// - /// - /// get role permissions - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task GetRolePermissionsEndpointAsync(string id); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// get role permissions - /// - /// - /// get role permissions - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task GetRolePermissionsEndpointAsync(string id, System.Threading.CancellationToken cancellationToken); - - /// - /// update role permissions - /// - /// - /// update role permissions - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task UpdateRolePermissionsEndpointAsync(string id, UpdatePermissionsCommand body); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// update role permissions - /// - /// - /// update role permissions - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task UpdateRolePermissionsEndpointAsync(string id, UpdatePermissionsCommand body, System.Threading.CancellationToken cancellationToken); - - /// - /// creates a tenant - /// - /// - /// creates a tenant - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task CreateTenantEndpointAsync(CreateTenantCommand body); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// creates a tenant - /// - /// - /// creates a tenant - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task CreateTenantEndpointAsync(CreateTenantCommand body, System.Threading.CancellationToken cancellationToken); - - /// - /// get tenants - /// - /// - /// get tenants - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task> GetTenantsEndpointAsync(); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// get tenants - /// - /// - /// get tenants - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task> GetTenantsEndpointAsync(System.Threading.CancellationToken cancellationToken); - - /// - /// get tenant by id - /// - /// - /// get tenant by id - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task GetTenantByIdEndpointAsync(string id); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// get tenant by id - /// - /// - /// get tenant by id - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task GetTenantByIdEndpointAsync(string id, System.Threading.CancellationToken cancellationToken); - - /// - /// upgrade tenant subscription - /// - /// - /// upgrade tenant subscription - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task UpgradeSubscriptionEndpointAsync(UpgradeSubscriptionCommand body); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// upgrade tenant subscription - /// - /// - /// upgrade tenant subscription - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task UpgradeSubscriptionEndpointAsync(UpgradeSubscriptionCommand body, System.Threading.CancellationToken cancellationToken); - - /// - /// activate tenant - /// - /// - /// activate tenant - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task ActivateTenantEndpointAsync(string id); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// activate tenant - /// - /// - /// activate tenant - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task ActivateTenantEndpointAsync(string id, System.Threading.CancellationToken cancellationToken); - - /// - /// activate tenant - /// - /// - /// activate tenant - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task DisableTenantEndpointAsync(string id); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// activate tenant - /// - /// - /// activate tenant - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task DisableTenantEndpointAsync(string id, System.Threading.CancellationToken cancellationToken); - - /// - /// Creates a todo item - /// - /// - /// Creates a todo item - /// - /// The requested API version - /// Created - /// A server side error occurred. - System.Threading.Tasks.Task CreateTodoEndpointAsync(string version, CreateTodoCommand body); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Creates a todo item - /// - /// - /// Creates a todo item - /// - /// The requested API version - /// Created - /// A server side error occurred. - System.Threading.Tasks.Task CreateTodoEndpointAsync(string version, CreateTodoCommand body, System.Threading.CancellationToken cancellationToken); - - /// - /// gets todo item by id - /// - /// - /// gets todo item by id - /// - /// The requested API version - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task GetTodoEndpointAsync(string version, System.Guid id); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// gets todo item by id - /// - /// - /// gets todo item by id - /// - /// The requested API version - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task GetTodoEndpointAsync(string version, System.Guid id, System.Threading.CancellationToken cancellationToken); - - /// - /// Updates a todo item - /// - /// - /// Updated a todo item - /// - /// The requested API version - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task UpdateTodoEndpointAsync(string version, System.Guid id, UpdateTodoCommand body); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Updates a todo item - /// - /// - /// Updated a todo item - /// - /// The requested API version - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task UpdateTodoEndpointAsync(string version, System.Guid id, UpdateTodoCommand body, System.Threading.CancellationToken cancellationToken); - - /// - /// Deletes a todo item - /// - /// - /// Deleted a todo item - /// - /// The requested API version - /// No Content - /// A server side error occurred. - System.Threading.Tasks.Task DeleteTodoEndpointAsync(string version, System.Guid id); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Deletes a todo item - /// - /// - /// Deleted a todo item - /// - /// The requested API version - /// No Content - /// A server side error occurred. - System.Threading.Tasks.Task DeleteTodoEndpointAsync(string version, System.Guid id, System.Threading.CancellationToken cancellationToken); - - /// - /// Gets a list of todo items with paging support - /// - /// - /// Gets a list of todo items with paging support - /// - /// The requested API version - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task GetTodoListEndpointAsync(string version, PaginationFilter body); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Gets a list of todo items with paging support - /// - /// - /// Gets a list of todo items with paging support - /// - /// The requested API version - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task GetTodoListEndpointAsync(string version, PaginationFilter body, System.Threading.CancellationToken cancellationToken); - - /// - /// refresh JWTs - /// - /// - /// refresh JWTs - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task RefreshTokenEndpointAsync(string tenant, RefreshTokenCommand body); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// refresh JWTs - /// - /// - /// refresh JWTs - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task RefreshTokenEndpointAsync(string tenant, RefreshTokenCommand body, System.Threading.CancellationToken cancellationToken); - - /// - /// generate JWTs - /// - /// - /// generate JWTs - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task TokenGenerationEndpointAsync(string tenant, TokenGenerationCommand body); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// generate JWTs - /// - /// - /// generate JWTs - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task TokenGenerationEndpointAsync(string tenant, TokenGenerationCommand body, System.Threading.CancellationToken cancellationToken); - - /// - /// register user - /// - /// - /// register user - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task RegisterUserEndpointAsync(RegisterUserCommand body); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// register user - /// - /// - /// register user - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task RegisterUserEndpointAsync(RegisterUserCommand body, System.Threading.CancellationToken cancellationToken); - - /// - /// self register user - /// - /// - /// self register user - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task SelfRegisterUserEndpointAsync(string tenant, RegisterUserCommand body); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// self register user - /// - /// - /// self register user - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task SelfRegisterUserEndpointAsync(string tenant, RegisterUserCommand body, System.Threading.CancellationToken cancellationToken); - - /// - /// update user profile - /// - /// - /// update user profile - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task UpdateUserEndpointAsync(UpdateUserCommand body); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// update user profile - /// - /// - /// update user profile - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task UpdateUserEndpointAsync(UpdateUserCommand body, System.Threading.CancellationToken cancellationToken); - - /// - /// Get current user information based on token - /// - /// - /// Get current user information based on token - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task GetMeEndpointAsync(); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Get current user information based on token - /// - /// - /// Get current user information based on token - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task GetMeEndpointAsync(System.Threading.CancellationToken cancellationToken); - - /// - /// get users list - /// - /// - /// get users list - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task> GetUsersListEndpointAsync(); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// get users list - /// - /// - /// get users list - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task> GetUsersListEndpointAsync(System.Threading.CancellationToken cancellationToken); - - /// - /// delete user profile - /// - /// - /// delete user profile - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task DeleteUserEndpointAsync(string id); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// delete user profile - /// - /// - /// delete user profile - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task DeleteUserEndpointAsync(string id, System.Threading.CancellationToken cancellationToken); - - /// - /// Get user profile by ID - /// - /// - /// Get another user's profile details by user ID. - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task GetUserEndpointAsync(string id); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Get user profile by ID - /// - /// - /// Get another user's profile details by user ID. - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task GetUserEndpointAsync(string id, System.Threading.CancellationToken cancellationToken); - - /// - /// Forgot password - /// - /// - /// Generates a password reset token and sends it via email. - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task ForgotPasswordEndpointAsync(string tenant, ForgotPasswordCommand body); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Forgot password - /// - /// - /// Generates a password reset token and sends it via email. - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task ForgotPasswordEndpointAsync(string tenant, ForgotPasswordCommand body, System.Threading.CancellationToken cancellationToken); - - /// - /// Changes password - /// - /// - /// Change password - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task ChangePasswordEndpointAsync(ChangePasswordCommand body); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Changes password - /// - /// - /// Change password - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task ChangePasswordEndpointAsync(ChangePasswordCommand body, System.Threading.CancellationToken cancellationToken); - - /// - /// Reset password - /// - /// - /// Resets the password using the token and new password provided. - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task ResetPasswordEndpointAsync(string tenant, ResetPasswordCommand body); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Reset password - /// - /// - /// Resets the password using the token and new password provided. - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task ResetPasswordEndpointAsync(string tenant, ResetPasswordCommand body, System.Threading.CancellationToken cancellationToken); - - /// - /// Get current user permissions - /// - /// - /// Get current user permissions - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task> GetUserPermissionsAsync(); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Get current user permissions - /// - /// - /// Get current user permissions - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task> GetUserPermissionsAsync(System.Threading.CancellationToken cancellationToken); - - /// - /// Toggle a user's active status - /// - /// - /// Toggle a user's active status - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task ToggleUserStatusEndpointAsync(string id, ToggleUserStatusCommand body); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Toggle a user's active status - /// - /// - /// Toggle a user's active status - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task ToggleUserStatusEndpointAsync(string id, ToggleUserStatusCommand body, System.Threading.CancellationToken cancellationToken); - - /// - /// assign roles - /// - /// - /// assign roles - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task AssignRolesToUserEndpointAsync(string id, AssignUserRoleCommand body); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// assign roles - /// - /// - /// assign roles - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task AssignRolesToUserEndpointAsync(string id, AssignUserRoleCommand body, System.Threading.CancellationToken cancellationToken); - - /// - /// get user roles - /// - /// - /// get user roles - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task> GetUserRolesEndpointAsync(string id); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// get user roles - /// - /// - /// get user roles - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task> GetUserRolesEndpointAsync(string id, System.Threading.CancellationToken cancellationToken); - - /// - /// Get user's audit trail details - /// - /// - /// Get user's audit trail details. - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task> GetUserAuditTrailEndpointAsync(System.Guid id); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Get user's audit trail details - /// - /// - /// Get user's audit trail details. - /// - /// OK - /// A server side error occurred. - System.Threading.Tasks.Task> GetUserAuditTrailEndpointAsync(System.Guid id, System.Threading.CancellationToken cancellationToken); - - } - - [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class ApiClient : IApiClient - { - private System.Net.Http.HttpClient _httpClient; - private static System.Lazy _settings = new System.Lazy(CreateSerializerSettings, true); - private System.Text.Json.JsonSerializerOptions _instanceSettings; - - #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. - public ApiClient(System.Net.Http.HttpClient httpClient) - #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. - { - _httpClient = httpClient; - Initialize(); - } - - private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() - { - var settings = new System.Text.Json.JsonSerializerOptions(); - UpdateJsonSerializerSettings(settings); - return settings; - } - - protected System.Text.Json.JsonSerializerOptions JsonSerializerSettings { get { return _instanceSettings ?? _settings.Value; } } - - static partial void UpdateJsonSerializerSettings(System.Text.Json.JsonSerializerOptions settings); - - partial void Initialize(); - - partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, string url); - partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, System.Text.StringBuilder urlBuilder); - partial void ProcessResponse(System.Net.Http.HttpClient client, System.Net.Http.HttpResponseMessage response); - - /// - /// creates a brand - /// - /// - /// creates a brand - /// - /// The requested API version - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task CreateBrandEndpointAsync(string version, CreateBrandCommand body) - { - return CreateBrandEndpointAsync(version, body, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// creates a brand - /// - /// - /// creates a brand - /// - /// The requested API version - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task CreateBrandEndpointAsync(string version, CreateBrandCommand body, System.Threading.CancellationToken cancellationToken) - { - if (version == null) - throw new System.ArgumentNullException("version"); - - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/v{version}/catalog/brands" - urlBuilder_.Append("api/v"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(version, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/catalog/brands"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// gets brand by id - /// - /// - /// gets brand by id - /// - /// The requested API version - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task GetBrandEndpointAsync(string version, System.Guid id) - { - return GetBrandEndpointAsync(version, id, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// gets brand by id - /// - /// - /// gets brand by id - /// - /// The requested API version - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task GetBrandEndpointAsync(string version, System.Guid id, System.Threading.CancellationToken cancellationToken) - { - if (version == null) - throw new System.ArgumentNullException("version"); - - if (id == null) - throw new System.ArgumentNullException("id"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("GET"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/v{version}/catalog/brands/{id}" - urlBuilder_.Append("api/v"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(version, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/catalog/brands/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// update a brand - /// - /// - /// update a brand - /// - /// The requested API version - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task UpdateBrandEndpointAsync(string version, System.Guid id, UpdateBrandCommand body) - { - return UpdateBrandEndpointAsync(version, id, body, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// update a brand - /// - /// - /// update a brand - /// - /// The requested API version - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task UpdateBrandEndpointAsync(string version, System.Guid id, UpdateBrandCommand body, System.Threading.CancellationToken cancellationToken) - { - if (version == null) - throw new System.ArgumentNullException("version"); - - if (id == null) - throw new System.ArgumentNullException("id"); - - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("PUT"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/v{version}/catalog/brands/{id}" - urlBuilder_.Append("api/v"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(version, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/catalog/brands/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// deletes brand by id - /// - /// - /// deletes brand by id - /// - /// The requested API version - /// No Content - /// A server side error occurred. - public virtual System.Threading.Tasks.Task DeleteBrandEndpointAsync(string version, System.Guid id) - { - return DeleteBrandEndpointAsync(version, id, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// deletes brand by id - /// - /// - /// deletes brand by id - /// - /// The requested API version - /// No Content - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task DeleteBrandEndpointAsync(string version, System.Guid id, System.Threading.CancellationToken cancellationToken) - { - if (version == null) - throw new System.ArgumentNullException("version"); - - if (id == null) - throw new System.ArgumentNullException("id"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("DELETE"); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/v{version}/catalog/brands/{id}" - urlBuilder_.Append("api/v"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(version, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/catalog/brands/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 204) - { - return; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// Gets a list of brands - /// - /// - /// Gets a list of brands with pagination and filtering support - /// - /// The requested API version - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task SearchBrandsEndpointAsync(string version, SearchBrandsCommand body) - { - return SearchBrandsEndpointAsync(version, body, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Gets a list of brands - /// - /// - /// Gets a list of brands with pagination and filtering support - /// - /// The requested API version - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task SearchBrandsEndpointAsync(string version, SearchBrandsCommand body, System.Threading.CancellationToken cancellationToken) - { - if (version == null) - throw new System.ArgumentNullException("version"); - - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/v{version}/catalog/brands/search" - urlBuilder_.Append("api/v"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(version, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/catalog/brands/search"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// creates a product - /// - /// - /// creates a product - /// - /// The requested API version - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task CreateProductEndpointAsync(string version, CreateProductCommand body) - { - return CreateProductEndpointAsync(version, body, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// creates a product - /// - /// - /// creates a product - /// - /// The requested API version - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task CreateProductEndpointAsync(string version, CreateProductCommand body, System.Threading.CancellationToken cancellationToken) - { - if (version == null) - throw new System.ArgumentNullException("version"); - - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/v{version}/catalog/products" - urlBuilder_.Append("api/v"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(version, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/catalog/products"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// gets product by id - /// - /// - /// gets prodct by id - /// - /// The requested API version - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task GetProductEndpointAsync(string version, System.Guid id) - { - return GetProductEndpointAsync(version, id, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// gets product by id - /// - /// - /// gets prodct by id - /// - /// The requested API version - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task GetProductEndpointAsync(string version, System.Guid id, System.Threading.CancellationToken cancellationToken) - { - if (version == null) - throw new System.ArgumentNullException("version"); - - if (id == null) - throw new System.ArgumentNullException("id"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("GET"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/v{version}/catalog/products/{id}" - urlBuilder_.Append("api/v"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(version, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/catalog/products/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// update a product - /// - /// - /// update a product - /// - /// The requested API version - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task UpdateProductEndpointAsync(string version, System.Guid id, UpdateProductCommand body) - { - return UpdateProductEndpointAsync(version, id, body, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// update a product - /// - /// - /// update a product - /// - /// The requested API version - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task UpdateProductEndpointAsync(string version, System.Guid id, UpdateProductCommand body, System.Threading.CancellationToken cancellationToken) - { - if (version == null) - throw new System.ArgumentNullException("version"); - - if (id == null) - throw new System.ArgumentNullException("id"); - - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("PUT"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/v{version}/catalog/products/{id}" - urlBuilder_.Append("api/v"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(version, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/catalog/products/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// deletes product by id - /// - /// - /// deletes product by id - /// - /// The requested API version - /// No Content - /// A server side error occurred. - public virtual System.Threading.Tasks.Task DeleteProductEndpointAsync(string version, System.Guid id) - { - return DeleteProductEndpointAsync(version, id, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// deletes product by id - /// - /// - /// deletes product by id - /// - /// The requested API version - /// No Content - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task DeleteProductEndpointAsync(string version, System.Guid id, System.Threading.CancellationToken cancellationToken) - { - if (version == null) - throw new System.ArgumentNullException("version"); - - if (id == null) - throw new System.ArgumentNullException("id"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("DELETE"); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/v{version}/catalog/products/{id}" - urlBuilder_.Append("api/v"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(version, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/catalog/products/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 204) - { - return; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// Gets a list of products - /// - /// - /// Gets a list of products with pagination and filtering support - /// - /// The requested API version - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task SearchProductsEndpointAsync(string version, SearchProductsCommand body) - { - return SearchProductsEndpointAsync(version, body, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Gets a list of products - /// - /// - /// Gets a list of products with pagination and filtering support - /// - /// The requested API version - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task SearchProductsEndpointAsync(string version, SearchProductsCommand body, System.Threading.CancellationToken cancellationToken) - { - if (version == null) - throw new System.ArgumentNullException("version"); - - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/v{version}/catalog/products/search" - urlBuilder_.Append("api/v"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(version, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/catalog/products/search"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// Get role details by ID - /// - /// - /// Retrieve the details of a role by its ID. - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task GetRoleByIdEndpointAsync(string id) - { - return GetRoleByIdEndpointAsync(id, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Get role details by ID - /// - /// - /// Retrieve the details of a role by its ID. - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task GetRoleByIdEndpointAsync(string id, System.Threading.CancellationToken cancellationToken) - { - if (id == null) - throw new System.ArgumentNullException("id"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("GET"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/roles/{id}" - urlBuilder_.Append("api/roles/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// Delete a role by ID - /// - /// - /// Remove a role from the system by its ID. - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task DeleteRoleEndpointAsync(string id) - { - return DeleteRoleEndpointAsync(id, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Delete a role by ID - /// - /// - /// Remove a role from the system by its ID. - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task DeleteRoleEndpointAsync(string id, System.Threading.CancellationToken cancellationToken) - { - if (id == null) - throw new System.ArgumentNullException("id"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("DELETE"); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/roles/{id}" - urlBuilder_.Append("api/roles/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - return; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// Get a list of all roles - /// - /// - /// Retrieve a list of all roles available in the system. - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task> GetRolesEndpointAsync() - { - return GetRolesEndpointAsync(System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Get a list of all roles - /// - /// - /// Retrieve a list of all roles available in the system. - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task> GetRolesEndpointAsync(System.Threading.CancellationToken cancellationToken) - { - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("GET"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/roles" - urlBuilder_.Append("api/roles"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// Create or update a role - /// - /// - /// Create a new role or update an existing role. - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task CreateOrUpdateRoleEndpointAsync(CreateOrUpdateRoleCommand body) - { - return CreateOrUpdateRoleEndpointAsync(body, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Create or update a role - /// - /// - /// Create a new role or update an existing role. - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task CreateOrUpdateRoleEndpointAsync(CreateOrUpdateRoleCommand body, System.Threading.CancellationToken cancellationToken) - { - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/roles" - urlBuilder_.Append("api/roles"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// get role permissions - /// - /// - /// get role permissions - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task GetRolePermissionsEndpointAsync(string id) - { - return GetRolePermissionsEndpointAsync(id, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// get role permissions - /// - /// - /// get role permissions - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task GetRolePermissionsEndpointAsync(string id, System.Threading.CancellationToken cancellationToken) - { - if (id == null) - throw new System.ArgumentNullException("id"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("GET"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/roles/{id}/permissions" - urlBuilder_.Append("api/roles/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/permissions"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// update role permissions - /// - /// - /// update role permissions - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task UpdateRolePermissionsEndpointAsync(string id, UpdatePermissionsCommand body) - { - return UpdateRolePermissionsEndpointAsync(id, body, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// update role permissions - /// - /// - /// update role permissions - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task UpdateRolePermissionsEndpointAsync(string id, UpdatePermissionsCommand body, System.Threading.CancellationToken cancellationToken) - { - if (id == null) - throw new System.ArgumentNullException("id"); - - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("PUT"); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/roles/{id}/permissions" - urlBuilder_.Append("api/roles/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/permissions"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - return; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// creates a tenant - /// - /// - /// creates a tenant - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task CreateTenantEndpointAsync(CreateTenantCommand body) - { - return CreateTenantEndpointAsync(body, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// creates a tenant - /// - /// - /// creates a tenant - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task CreateTenantEndpointAsync(CreateTenantCommand body, System.Threading.CancellationToken cancellationToken) - { - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/tenants" - urlBuilder_.Append("api/tenants"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// get tenants - /// - /// - /// get tenants - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task> GetTenantsEndpointAsync() - { - return GetTenantsEndpointAsync(System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// get tenants - /// - /// - /// get tenants - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task> GetTenantsEndpointAsync(System.Threading.CancellationToken cancellationToken) - { - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("GET"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/tenants" - urlBuilder_.Append("api/tenants"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// get tenant by id - /// - /// - /// get tenant by id - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task GetTenantByIdEndpointAsync(string id) - { - return GetTenantByIdEndpointAsync(id, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// get tenant by id - /// - /// - /// get tenant by id - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task GetTenantByIdEndpointAsync(string id, System.Threading.CancellationToken cancellationToken) - { - if (id == null) - throw new System.ArgumentNullException("id"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("GET"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/tenants/{id}" - urlBuilder_.Append("api/tenants/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// upgrade tenant subscription - /// - /// - /// upgrade tenant subscription - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task UpgradeSubscriptionEndpointAsync(UpgradeSubscriptionCommand body) - { - return UpgradeSubscriptionEndpointAsync(body, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// upgrade tenant subscription - /// - /// - /// upgrade tenant subscription - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task UpgradeSubscriptionEndpointAsync(UpgradeSubscriptionCommand body, System.Threading.CancellationToken cancellationToken) - { - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/tenants/upgrade" - urlBuilder_.Append("api/tenants/upgrade"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// activate tenant - /// - /// - /// activate tenant - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task ActivateTenantEndpointAsync(string id) - { - return ActivateTenantEndpointAsync(id, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// activate tenant - /// - /// - /// activate tenant - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task ActivateTenantEndpointAsync(string id, System.Threading.CancellationToken cancellationToken) - { - if (id == null) - throw new System.ArgumentNullException("id"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/tenants/{id}/activate" - urlBuilder_.Append("api/tenants/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/activate"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// activate tenant - /// - /// - /// activate tenant - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task DisableTenantEndpointAsync(string id) - { - return DisableTenantEndpointAsync(id, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// activate tenant - /// - /// - /// activate tenant - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task DisableTenantEndpointAsync(string id, System.Threading.CancellationToken cancellationToken) - { - if (id == null) - throw new System.ArgumentNullException("id"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/tenants/{id}/deactivate" - urlBuilder_.Append("api/tenants/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/deactivate"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// Creates a todo item - /// - /// - /// Creates a todo item - /// - /// The requested API version - /// Created - /// A server side error occurred. - public virtual System.Threading.Tasks.Task CreateTodoEndpointAsync(string version, CreateTodoCommand body) - { - return CreateTodoEndpointAsync(version, body, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Creates a todo item - /// - /// - /// Creates a todo item - /// - /// The requested API version - /// Created - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task CreateTodoEndpointAsync(string version, CreateTodoCommand body, System.Threading.CancellationToken cancellationToken) - { - if (version == null) - throw new System.ArgumentNullException("version"); - - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/v{version}/todos" - urlBuilder_.Append("api/v"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(version, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/todos"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 201) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// gets todo item by id - /// - /// - /// gets todo item by id - /// - /// The requested API version - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task GetTodoEndpointAsync(string version, System.Guid id) - { - return GetTodoEndpointAsync(version, id, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// gets todo item by id - /// - /// - /// gets todo item by id - /// - /// The requested API version - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task GetTodoEndpointAsync(string version, System.Guid id, System.Threading.CancellationToken cancellationToken) - { - if (version == null) - throw new System.ArgumentNullException("version"); - - if (id == null) - throw new System.ArgumentNullException("id"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("GET"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/v{version}/todos/{id}" - urlBuilder_.Append("api/v"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(version, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/todos/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// Updates a todo item - /// - /// - /// Updated a todo item - /// - /// The requested API version - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task UpdateTodoEndpointAsync(string version, System.Guid id, UpdateTodoCommand body) - { - return UpdateTodoEndpointAsync(version, id, body, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Updates a todo item - /// - /// - /// Updated a todo item - /// - /// The requested API version - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task UpdateTodoEndpointAsync(string version, System.Guid id, UpdateTodoCommand body, System.Threading.CancellationToken cancellationToken) - { - if (version == null) - throw new System.ArgumentNullException("version"); - - if (id == null) - throw new System.ArgumentNullException("id"); - - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("PUT"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/v{version}/todos/{id}" - urlBuilder_.Append("api/v"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(version, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/todos/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// Deletes a todo item - /// - /// - /// Deleted a todo item - /// - /// The requested API version - /// No Content - /// A server side error occurred. - public virtual System.Threading.Tasks.Task DeleteTodoEndpointAsync(string version, System.Guid id) - { - return DeleteTodoEndpointAsync(version, id, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Deletes a todo item - /// - /// - /// Deleted a todo item - /// - /// The requested API version - /// No Content - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task DeleteTodoEndpointAsync(string version, System.Guid id, System.Threading.CancellationToken cancellationToken) - { - if (version == null) - throw new System.ArgumentNullException("version"); - - if (id == null) - throw new System.ArgumentNullException("id"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("DELETE"); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/v{version}/todos/{id}" - urlBuilder_.Append("api/v"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(version, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/todos/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 204) - { - return; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// Gets a list of todo items with paging support - /// - /// - /// Gets a list of todo items with paging support - /// - /// The requested API version - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task GetTodoListEndpointAsync(string version, PaginationFilter body) - { - return GetTodoListEndpointAsync(version, body, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Gets a list of todo items with paging support - /// - /// - /// Gets a list of todo items with paging support - /// - /// The requested API version - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task GetTodoListEndpointAsync(string version, PaginationFilter body, System.Threading.CancellationToken cancellationToken) - { - if (version == null) - throw new System.ArgumentNullException("version"); - - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/v{version}/todos/search" - urlBuilder_.Append("api/v"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(version, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/todos/search"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// refresh JWTs - /// - /// - /// refresh JWTs - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task RefreshTokenEndpointAsync(string tenant, RefreshTokenCommand body) - { - return RefreshTokenEndpointAsync(tenant, body, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// refresh JWTs - /// - /// - /// refresh JWTs - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task RefreshTokenEndpointAsync(string tenant, RefreshTokenCommand body, System.Threading.CancellationToken cancellationToken) - { - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - - if (tenant == null) - throw new System.ArgumentNullException("tenant"); - request_.Headers.TryAddWithoutValidation("tenant", ConvertToString(tenant, System.Globalization.CultureInfo.InvariantCulture)); - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/token/refresh" - urlBuilder_.Append("api/token/refresh"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// generate JWTs - /// - /// - /// generate JWTs - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task TokenGenerationEndpointAsync(string tenant, TokenGenerationCommand body) - { - return TokenGenerationEndpointAsync(tenant, body, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// generate JWTs - /// - /// - /// generate JWTs - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task TokenGenerationEndpointAsync(string tenant, TokenGenerationCommand body, System.Threading.CancellationToken cancellationToken) - { - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - - if (tenant == null) - throw new System.ArgumentNullException("tenant"); - request_.Headers.TryAddWithoutValidation("tenant", ConvertToString(tenant, System.Globalization.CultureInfo.InvariantCulture)); - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/token" - urlBuilder_.Append("api/token"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// register user - /// - /// - /// register user - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task RegisterUserEndpointAsync(RegisterUserCommand body) - { - return RegisterUserEndpointAsync(body, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// register user - /// - /// - /// register user - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task RegisterUserEndpointAsync(RegisterUserCommand body, System.Threading.CancellationToken cancellationToken) - { - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/users/register" - urlBuilder_.Append("api/users/register"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// self register user - /// - /// - /// self register user - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task SelfRegisterUserEndpointAsync(string tenant, RegisterUserCommand body) - { - return SelfRegisterUserEndpointAsync(tenant, body, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// self register user - /// - /// - /// self register user - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task SelfRegisterUserEndpointAsync(string tenant, RegisterUserCommand body, System.Threading.CancellationToken cancellationToken) - { - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - - if (tenant == null) - throw new System.ArgumentNullException("tenant"); - request_.Headers.TryAddWithoutValidation("tenant", ConvertToString(tenant, System.Globalization.CultureInfo.InvariantCulture)); - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/users/self-register" - urlBuilder_.Append("api/users/self-register"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// update user profile - /// - /// - /// update user profile - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task UpdateUserEndpointAsync(UpdateUserCommand body) - { - return UpdateUserEndpointAsync(body, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// update user profile - /// - /// - /// update user profile - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task UpdateUserEndpointAsync(UpdateUserCommand body, System.Threading.CancellationToken cancellationToken) - { - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("PUT"); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/users/profile" - urlBuilder_.Append("api/users/profile"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - return; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// Get current user information based on token - /// - /// - /// Get current user information based on token - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task GetMeEndpointAsync() - { - return GetMeEndpointAsync(System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Get current user information based on token - /// - /// - /// Get current user information based on token - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task GetMeEndpointAsync(System.Threading.CancellationToken cancellationToken) - { - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("GET"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/users/profile" - urlBuilder_.Append("api/users/profile"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// get users list - /// - /// - /// get users list - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task> GetUsersListEndpointAsync() - { - return GetUsersListEndpointAsync(System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// get users list - /// - /// - /// get users list - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task> GetUsersListEndpointAsync(System.Threading.CancellationToken cancellationToken) - { - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("GET"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/users" - urlBuilder_.Append("api/users"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// delete user profile - /// - /// - /// delete user profile - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task DeleteUserEndpointAsync(string id) - { - return DeleteUserEndpointAsync(id, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// delete user profile - /// - /// - /// delete user profile - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task DeleteUserEndpointAsync(string id, System.Threading.CancellationToken cancellationToken) - { - if (id == null) - throw new System.ArgumentNullException("id"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("DELETE"); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/users/{id}" - urlBuilder_.Append("api/users/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - return; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// Get user profile by ID - /// - /// - /// Get another user's profile details by user ID. - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task GetUserEndpointAsync(string id) - { - return GetUserEndpointAsync(id, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Get user profile by ID - /// - /// - /// Get another user's profile details by user ID. - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task GetUserEndpointAsync(string id, System.Threading.CancellationToken cancellationToken) - { - if (id == null) - throw new System.ArgumentNullException("id"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("GET"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/users/{id}" - urlBuilder_.Append("api/users/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// Forgot password - /// - /// - /// Generates a password reset token and sends it via email. - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task ForgotPasswordEndpointAsync(string tenant, ForgotPasswordCommand body) - { - return ForgotPasswordEndpointAsync(tenant, body, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Forgot password - /// - /// - /// Generates a password reset token and sends it via email. - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task ForgotPasswordEndpointAsync(string tenant, ForgotPasswordCommand body, System.Threading.CancellationToken cancellationToken) - { - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - - if (tenant == null) - throw new System.ArgumentNullException("tenant"); - request_.Headers.TryAddWithoutValidation("tenant", ConvertToString(tenant, System.Globalization.CultureInfo.InvariantCulture)); - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/users/forgot-password" - urlBuilder_.Append("api/users/forgot-password"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - return; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// Changes password - /// - /// - /// Change password - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task ChangePasswordEndpointAsync(ChangePasswordCommand body) - { - return ChangePasswordEndpointAsync(body, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Changes password - /// - /// - /// Change password - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task ChangePasswordEndpointAsync(ChangePasswordCommand body, System.Threading.CancellationToken cancellationToken) - { - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/users/change-password" - urlBuilder_.Append("api/users/change-password"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - return; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// Reset password - /// - /// - /// Resets the password using the token and new password provided. - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task ResetPasswordEndpointAsync(string tenant, ResetPasswordCommand body) - { - return ResetPasswordEndpointAsync(tenant, body, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Reset password - /// - /// - /// Resets the password using the token and new password provided. - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task ResetPasswordEndpointAsync(string tenant, ResetPasswordCommand body, System.Threading.CancellationToken cancellationToken) - { - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - - if (tenant == null) - throw new System.ArgumentNullException("tenant"); - request_.Headers.TryAddWithoutValidation("tenant", ConvertToString(tenant, System.Globalization.CultureInfo.InvariantCulture)); - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/users/reset-password" - urlBuilder_.Append("api/users/reset-password"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - return; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// Get current user permissions - /// - /// - /// Get current user permissions - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task> GetUserPermissionsAsync() - { - return GetUserPermissionsAsync(System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Get current user permissions - /// - /// - /// Get current user permissions - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task> GetUserPermissionsAsync(System.Threading.CancellationToken cancellationToken) - { - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("GET"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/users/permissions" - urlBuilder_.Append("api/users/permissions"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// Toggle a user's active status - /// - /// - /// Toggle a user's active status - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task ToggleUserStatusEndpointAsync(string id, ToggleUserStatusCommand body) - { - return ToggleUserStatusEndpointAsync(id, body, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Toggle a user's active status - /// - /// - /// Toggle a user's active status - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task ToggleUserStatusEndpointAsync(string id, ToggleUserStatusCommand body, System.Threading.CancellationToken cancellationToken) - { - if (id == null) - throw new System.ArgumentNullException("id"); - - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/users/{id}/toggle-status" - urlBuilder_.Append("api/users/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/toggle-status"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - return; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// assign roles - /// - /// - /// assign roles - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task AssignRolesToUserEndpointAsync(string id, AssignUserRoleCommand body) - { - return AssignRolesToUserEndpointAsync(id, body, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// assign roles - /// - /// - /// assign roles - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task AssignRolesToUserEndpointAsync(string id, AssignUserRoleCommand body, System.Threading.CancellationToken cancellationToken) - { - if (id == null) - throw new System.ArgumentNullException("id"); - - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/users/{id}/roles" - urlBuilder_.Append("api/users/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/roles"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - return; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// get user roles - /// - /// - /// get user roles - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task> GetUserRolesEndpointAsync(string id) - { - return GetUserRolesEndpointAsync(id, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// get user roles - /// - /// - /// get user roles - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task> GetUserRolesEndpointAsync(string id, System.Threading.CancellationToken cancellationToken) - { - if (id == null) - throw new System.ArgumentNullException("id"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("GET"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/users/{id}/roles" - urlBuilder_.Append("api/users/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/roles"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// - /// Get user's audit trail details - /// - /// - /// Get user's audit trail details. - /// - /// OK - /// A server side error occurred. - public virtual System.Threading.Tasks.Task> GetUserAuditTrailEndpointAsync(System.Guid id) - { - return GetUserAuditTrailEndpointAsync(id, System.Threading.CancellationToken.None); - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Get user's audit trail details - /// - /// - /// Get user's audit trail details. - /// - /// OK - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task> GetUserAuditTrailEndpointAsync(System.Guid id, System.Threading.CancellationToken cancellationToken) - { - if (id == null) - throw new System.ArgumentNullException("id"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("GET"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/users/{id}/audit-trails" - urlBuilder_.Append("api/users/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/audit-trails"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - protected struct ObjectResponseResult - { - public ObjectResponseResult(T responseObject, string responseText) - { - this.Object = responseObject; - this.Text = responseText; - } - - public T Object { get; } - - public string Text { get; } - } - - public bool ReadResponseAsString { get; set; } - - protected virtual async System.Threading.Tasks.Task> ReadObjectResponseAsync(System.Net.Http.HttpResponseMessage response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Threading.CancellationToken cancellationToken) - { - if (response == null || response.Content == null) - { - return new ObjectResponseResult(default(T)!, string.Empty); - } - - if (ReadResponseAsString) - { - var responseText = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - try - { - var typedBody = System.Text.Json.JsonSerializer.Deserialize(responseText, JsonSerializerSettings); - return new ObjectResponseResult(typedBody!, responseText); - } - catch (System.Text.Json.JsonException exception) - { - var message = "Could not deserialize the response body string as " + typeof(T).FullName + "."; - throw new ApiException(message, (int)response.StatusCode, responseText, headers, exception); - } - } - else - { - try - { - using (var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false)) - { - var typedBody = await System.Text.Json.JsonSerializer.DeserializeAsync(responseStream, JsonSerializerSettings, cancellationToken).ConfigureAwait(false); - return new ObjectResponseResult(typedBody!, string.Empty); - } - } - catch (System.Text.Json.JsonException exception) - { - var message = "Could not deserialize the response body stream as " + typeof(T).FullName + "."; - throw new ApiException(message, (int)response.StatusCode, string.Empty, headers, exception); - } - } - } - - private string ConvertToString(object? value, System.Globalization.CultureInfo cultureInfo) - { - if (value == null) - { - return ""; - } - - if (value is System.Enum) - { - var name = System.Enum.GetName(value.GetType(), value); - if (name != null) - { - var field = System.Reflection.IntrospectionExtensions.GetTypeInfo(value.GetType()).GetDeclaredField(name); - if (field != null) - { - var attribute = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(field, typeof(System.Runtime.Serialization.EnumMemberAttribute)) - as System.Runtime.Serialization.EnumMemberAttribute; - if (attribute != null) - { - return attribute.Value != null ? attribute.Value : name; - } - } - - var converted = System.Convert.ToString(System.Convert.ChangeType(value, System.Enum.GetUnderlyingType(value.GetType()), cultureInfo)); - return converted == null ? string.Empty : converted; - } - } - else if (value is bool) - { - return System.Convert.ToString((bool)value, cultureInfo).ToLowerInvariant(); - } - else if (value is byte[]) - { - return System.Convert.ToBase64String((byte[]) value); - } - else if (value is string[]) - { - return string.Join(",", (string[])value); - } - else if (value.GetType().IsArray) - { - var valueArray = (System.Array)value; - var valueTextArray = new string[valueArray.Length]; - for (var i = 0; i < valueArray.Length; i++) - { - valueTextArray[i] = ConvertToString(valueArray.GetValue(i), cultureInfo); - } - return string.Join(",", valueTextArray); - } - - var result = System.Convert.ToString(value, cultureInfo); - return result == null ? "" : result; - } - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class ActivateTenantResponse - { - - [System.Text.Json.Serialization.JsonPropertyName("status")] - public string? Status { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class AssignUserRoleCommand - { - - [System.Text.Json.Serialization.JsonPropertyName("userRoles")] - public System.Collections.Generic.ICollection? UserRoles { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class AuditTrail - { - - [System.Text.Json.Serialization.JsonPropertyName("id")] - public System.Guid Id { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("userId")] - public System.Guid UserId { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("operation")] - public string? Operation { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("entity")] - public string? Entity { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("dateTime")] - public System.DateTime DateTime { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("previousValues")] - public string? PreviousValues { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("newValues")] - public string? NewValues { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("modifiedProperties")] - public string? ModifiedProperties { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("primaryKey")] - public string? PrimaryKey { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class BrandResponse - { - - [System.Text.Json.Serialization.JsonPropertyName("id")] - public System.Guid? Id { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("name")] - public string? Name { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("description")] - public string? Description { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class BrandResponsePagedList - { - - [System.Text.Json.Serialization.JsonPropertyName("items")] - public System.Collections.Generic.ICollection? Items { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("pageNumber")] - public int PageNumber { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("pageSize")] - public int PageSize { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("totalCount")] - public int TotalCount { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("totalPages")] - public int TotalPages { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("hasPrevious")] - public bool HasPrevious { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("hasNext")] - public bool HasNext { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class ChangePasswordCommand - { - - [System.Text.Json.Serialization.JsonPropertyName("password")] - public string? Password { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("newPassword")] - public string? NewPassword { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("confirmNewPassword")] - public string? ConfirmNewPassword { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class CreateBrandCommand - { - - [System.Text.Json.Serialization.JsonPropertyName("name")] - public string? Name { get; set; } = "Sample Brand"; - - [System.Text.Json.Serialization.JsonPropertyName("description")] - public string? Description { get; set; } = "Descriptive Description"; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class CreateBrandResponse - { - - [System.Text.Json.Serialization.JsonPropertyName("id")] - public System.Guid? Id { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class CreateOrUpdateRoleCommand - { - - [System.Text.Json.Serialization.JsonPropertyName("id")] - public string? Id { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("name")] - public string? Name { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("description")] - public string? Description { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class CreateProductCommand - { - - [System.Text.Json.Serialization.JsonPropertyName("name")] - public string? Name { get; set; } = "Sample Product"; - - [System.Text.Json.Serialization.JsonPropertyName("price")] - public double Price { get; set; } = 10D; - - [System.Text.Json.Serialization.JsonPropertyName("description")] - public string? Description { get; set; } = "Descriptive Description"; - - [System.Text.Json.Serialization.JsonPropertyName("brandId")] - public System.Guid? BrandId { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class CreateProductResponse - { - - [System.Text.Json.Serialization.JsonPropertyName("id")] - public System.Guid? Id { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class CreateTenantCommand - { - - [System.Text.Json.Serialization.JsonPropertyName("id")] - public string? Id { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("name")] - public string? Name { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("connectionString")] - public string? ConnectionString { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("adminEmail")] - public string? AdminEmail { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("issuer")] - public string? Issuer { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class CreateTenantResponse - { - - [System.Text.Json.Serialization.JsonPropertyName("id")] - public string? Id { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class CreateTodoCommand - { - - [System.Text.Json.Serialization.JsonPropertyName("title")] - public string? Title { get; set; } = "Hello World!"; - - [System.Text.Json.Serialization.JsonPropertyName("note")] - public string? Note { get; set; } = "Important Note."; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class CreateTodoResponse - { - - [System.Text.Json.Serialization.JsonPropertyName("id")] - public System.Guid? Id { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class DisableTenantResponse - { - - [System.Text.Json.Serialization.JsonPropertyName("status")] - public string? Status { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class FileUploadCommand - { - - [System.Text.Json.Serialization.JsonPropertyName("name")] - public string? Name { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("extension")] - public string? Extension { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("data")] - public string? Data { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Filter - { - - [System.Text.Json.Serialization.JsonPropertyName("logic")] - public string? Logic { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("filters")] - public System.Collections.Generic.ICollection? Filters { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("field")] - public string? Field { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("operator")] - public string? Operator { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("value")] - public object? Value { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class ForgotPasswordCommand - { - - [System.Text.Json.Serialization.JsonPropertyName("email")] - public string? Email { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class GetTodoResponse - { - - [System.Text.Json.Serialization.JsonPropertyName("id")] - public System.Guid? Id { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("title")] - public string? Title { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("note")] - public string? Note { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class PaginationFilter - { - - [System.Text.Json.Serialization.JsonPropertyName("advancedSearch")] - public Search AdvancedSearch { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("keyword")] - public string? Keyword { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("advancedFilter")] - public Filter AdvancedFilter { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("pageNumber")] - public int PageNumber { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("pageSize")] - public int PageSize { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("orderBy")] - public System.Collections.Generic.ICollection? OrderBy { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class ProductResponse - { - - [System.Text.Json.Serialization.JsonPropertyName("id")] - public System.Guid? Id { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("name")] - public string? Name { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("description")] - public string? Description { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("price")] - public double Price { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("brand")] - public BrandResponse Brand { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class ProductResponsePagedList - { - - [System.Text.Json.Serialization.JsonPropertyName("items")] - public System.Collections.Generic.ICollection? Items { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("pageNumber")] - public int PageNumber { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("pageSize")] - public int PageSize { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("totalCount")] - public int TotalCount { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("totalPages")] - public int TotalPages { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("hasPrevious")] - public bool HasPrevious { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("hasNext")] - public bool HasNext { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class RefreshTokenCommand - { - - [System.Text.Json.Serialization.JsonPropertyName("token")] - public string? Token { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("refreshToken")] - public string? RefreshToken { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class RegisterUserCommand - { - - [System.Text.Json.Serialization.JsonPropertyName("firstName")] - public string? FirstName { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("lastName")] - public string? LastName { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("email")] - public string? Email { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("userName")] - public string? UserName { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("password")] - public string? Password { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("confirmPassword")] - public string? ConfirmPassword { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("phoneNumber")] - public string? PhoneNumber { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class RegisterUserResponse - { - - [System.Text.Json.Serialization.JsonPropertyName("userId")] - public string? UserId { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class ResetPasswordCommand - { - - [System.Text.Json.Serialization.JsonPropertyName("email")] - public string? Email { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("password")] - public string? Password { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("token")] - public string? Token { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class RoleDto - { - - [System.Text.Json.Serialization.JsonPropertyName("id")] - public string? Id { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("name")] - public string? Name { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("description")] - public string? Description { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("permissions")] - public System.Collections.Generic.ICollection? Permissions { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Search - { - - [System.Text.Json.Serialization.JsonPropertyName("fields")] - public System.Collections.Generic.ICollection? Fields { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("keyword")] - public string? Keyword { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class SearchBrandsCommand - { - - [System.Text.Json.Serialization.JsonPropertyName("advancedSearch")] - public Search AdvancedSearch { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("keyword")] - public string? Keyword { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("advancedFilter")] - public Filter AdvancedFilter { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("pageNumber")] - public int PageNumber { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("pageSize")] - public int PageSize { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("orderBy")] - public System.Collections.Generic.ICollection? OrderBy { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("name")] - public string? Name { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("description")] - public string? Description { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class SearchProductsCommand - { - - [System.Text.Json.Serialization.JsonPropertyName("advancedSearch")] - public Search AdvancedSearch { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("keyword")] - public string? Keyword { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("advancedFilter")] - public Filter AdvancedFilter { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("pageNumber")] - public int PageNumber { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("pageSize")] - public int PageSize { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("orderBy")] - public System.Collections.Generic.ICollection? OrderBy { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("brandId")] - public System.Guid? BrandId { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("minimumRate")] - public double? MinimumRate { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("maximumRate")] - public double? MaximumRate { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class TenantDetail - { - - [System.Text.Json.Serialization.JsonPropertyName("id")] - public string? Id { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("name")] - public string? Name { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("connectionString")] - public string? ConnectionString { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("adminEmail")] - public string? AdminEmail { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("isActive")] - public bool IsActive { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("validUpto")] - public System.DateTime ValidUpto { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("issuer")] - public string? Issuer { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class TodoDto - { - - [System.Text.Json.Serialization.JsonPropertyName("id")] - public System.Guid? Id { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("title")] - public string? Title { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("note")] - public string? Note { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class TodoDtoPagedList - { - - [System.Text.Json.Serialization.JsonPropertyName("items")] - public System.Collections.Generic.ICollection? Items { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("pageNumber")] - public int PageNumber { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("pageSize")] - public int PageSize { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("totalCount")] - public int TotalCount { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("totalPages")] - public int TotalPages { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("hasPrevious")] - public bool HasPrevious { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("hasNext")] - public bool HasNext { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class ToggleUserStatusCommand - { - - [System.Text.Json.Serialization.JsonPropertyName("activateUser")] - public bool ActivateUser { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("userId")] - public string? UserId { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class TokenGenerationCommand - { - - [System.Text.Json.Serialization.JsonPropertyName("email")] - public string? Email { get; set; } = "admin@root.com"; - - [System.Text.Json.Serialization.JsonPropertyName("password")] - public string? Password { get; set; } = "123Pa$$word!"; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class TokenResponse - { - - [System.Text.Json.Serialization.JsonPropertyName("token")] - public string? Token { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("refreshToken")] - public string? RefreshToken { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("refreshTokenExpiryTime")] - public System.DateTime RefreshTokenExpiryTime { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class UpdateBrandCommand - { - - [System.Text.Json.Serialization.JsonPropertyName("id")] - public System.Guid Id { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("name")] - public string? Name { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("description")] - public string? Description { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class UpdateBrandResponse - { - - [System.Text.Json.Serialization.JsonPropertyName("id")] - public System.Guid? Id { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class UpdatePermissionsCommand - { - - [System.Text.Json.Serialization.JsonPropertyName("roleId")] - public string? RoleId { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("permissions")] - public System.Collections.Generic.ICollection? Permissions { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class UpdateProductCommand - { - - [System.Text.Json.Serialization.JsonPropertyName("id")] - public System.Guid Id { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("name")] - public string? Name { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("price")] - public double Price { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("description")] - public string? Description { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("brandId")] - public System.Guid? BrandId { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class UpdateProductResponse - { - - [System.Text.Json.Serialization.JsonPropertyName("id")] - public System.Guid? Id { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class UpdateTodoCommand - { - - [System.Text.Json.Serialization.JsonPropertyName("id")] - public System.Guid Id { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("title")] - public string? Title { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("note")] - public string? Note { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class UpdateTodoResponse - { - - [System.Text.Json.Serialization.JsonPropertyName("id")] - public System.Guid? Id { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class UpdateUserCommand - { - - [System.Text.Json.Serialization.JsonPropertyName("id")] - public string? Id { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("firstName")] - public string? FirstName { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("lastName")] - public string? LastName { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("phoneNumber")] - public string? PhoneNumber { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("email")] - public string? Email { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("image")] - public FileUploadCommand Image { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("deleteCurrentImage")] - public bool DeleteCurrentImage { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class UpgradeSubscriptionCommand - { - - [System.Text.Json.Serialization.JsonPropertyName("tenant")] - public string? Tenant { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("extendedExpiryDate")] - public System.DateTime ExtendedExpiryDate { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class UpgradeSubscriptionResponse - { - - [System.Text.Json.Serialization.JsonPropertyName("newValidity")] - public System.DateTime NewValidity { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("tenant")] - public string? Tenant { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class UserDetail - { - - [System.Text.Json.Serialization.JsonPropertyName("id")] - public System.Guid Id { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("userName")] - public string? UserName { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("firstName")] - public string? FirstName { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("lastName")] - public string? LastName { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("email")] - public string? Email { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("isActive")] - public bool IsActive { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("emailConfirmed")] - public bool EmailConfirmed { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("phoneNumber")] - public string? PhoneNumber { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("imageUrl")] - public System.Uri? ImageUrl { get; set; } = default!; - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class UserRoleDetail - { - - [System.Text.Json.Serialization.JsonPropertyName("roleId")] - public string? RoleId { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("roleName")] - public string? RoleName { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("description")] - public string? Description { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("enabled")] - public bool Enabled { get; set; } = default!; - - } - - - - [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class ApiException : System.Exception - { - public int StatusCode { get; private set; } - - public string? Response { get; private set; } - - public System.Collections.Generic.IReadOnlyDictionary> Headers { get; private set; } - - public ApiException(string message, int statusCode, string? response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Exception? innerException) - : base(message + "\n\nStatus: " + statusCode + "\nResponse: \n" + ((response == null) ? "(null)" : response.Substring(0, response.Length >= 512 ? 512 : response.Length)), innerException) - { - StatusCode = statusCode; - Response = response; - Headers = headers; - } - - public override string ToString() - { - return string.Format("HTTP Response: \n\n{0}\n\n{1}", Response, base.ToString()); - } - } - - [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class ApiException : ApiException - { - public TResult Result { get; private set; } - - public ApiException(string message, int statusCode, string? response, System.Collections.Generic.IReadOnlyDictionary> headers, TResult result, System.Exception? innerException) - : base(message, statusCode, response, headers, innerException) - { - Result = result; - } - } - -} - -#pragma warning restore 108 -#pragma warning restore 114 -#pragma warning restore 472 -#pragma warning restore 612 -#pragma warning restore 1573 -#pragma warning restore 1591 -#pragma warning restore 8073 -#pragma warning restore 3016 -#pragma warning restore 8603 -#pragma warning restore 8604 -#pragma warning restore 8625 \ No newline at end of file diff --git a/src/apps/blazor/infrastructure/Api/nswag.json b/src/apps/blazor/infrastructure/Api/nswag.json deleted file mode 100644 index 4d3fb1c43c..0000000000 --- a/src/apps/blazor/infrastructure/Api/nswag.json +++ /dev/null @@ -1,99 +0,0 @@ -{ - "runtime": "Net80", - "defaultVariables": null, - "documentGenerator": { - "fromDocument": { - "json": "", - "url": "https://localhost:7000/swagger/v1/swagger.json", - "output": null, - "newLineBehavior": "Auto" - } - }, - "codeGenerators": { - "openApiToCSharpClient": { - "clientBaseClass": null, - "configurationClass": null, - "generateClientClasses": true, - "generateClientInterfaces": true, - "injectHttpClient": true, - "disposeHttpClient": false, - "protectedMethods": [], - "generateExceptionClasses": true, - "exceptionClass": "ApiException", - "wrapDtoExceptions": true, - "useHttpClientCreationMethod": false, - "httpClientType": "System.Net.Http.HttpClient", - "useHttpRequestMessageCreationMethod": false, - "useBaseUrl": false, - "generateBaseUrlProperty": true, - "generateSyncMethods": false, - "generatePrepareRequestAndProcessResponseAsAsyncMethods": false, - "exposeJsonSerializerSettings": false, - "clientClassAccessModifier": "public", - "typeAccessModifier": "public", - "generateContractsOutput": false, - "contractsNamespace": null, - "contractsOutputFilePath": null, - "parameterDateTimeFormat": "s", - "parameterDateFormat": "yyyy-MM-dd", - "generateUpdateJsonSerializerSettingsMethod": true, - "useRequestAndResponseSerializationSettings": false, - "serializeTypeInformation": false, - "queryNullValue": "", - "className": "ApiClient", - "operationGenerationMode": "MultipleClientsFromOperationId", - "additionalNamespaceUsages": [], - "additionalContractNamespaceUsages": [], - "generateOptionalParameters": false, - "generateJsonMethods": false, - "enforceFlagEnums": false, - "parameterArrayType": "System.Collections.Generic.IEnumerable", - "parameterDictionaryType": "System.Collections.Generic.IDictionary", - "responseArrayType": "System.Collections.Generic.ICollection", - "responseDictionaryType": "System.Collections.Generic.IDictionary", - "wrapResponses": false, - "wrapResponseMethods": [], - "generateResponseClasses": true, - "responseClass": "SwaggerResponse", - "namespace": "FSH.Starter.Blazor.Infrastructure.Api", - "requiredPropertiesMustBeDefined": true, - "dateType": "System.DateTimeOffset", - "jsonConverters": null, - "anyType": "object", - "dateTimeType": "System.DateTime", - "timeType": "System.TimeSpan", - "timeSpanType": "System.TimeSpan", - "arrayType": "System.Collections.Generic.ICollection", - "arrayInstanceType": "System.Collections.ObjectModel.Collection", - "dictionaryType": "System.Collections.Generic.IDictionary", - "dictionaryInstanceType": "System.Collections.Generic.Dictionary", - "arrayBaseType": "System.Collections.ObjectModel.Collection", - "dictionaryBaseType": "System.Collections.Generic.Dictionary", - "classStyle": "Poco", - "jsonLibrary": "SystemTextJson", - "generateDefaultValues": true, - "generateDataAnnotations": true, - "excludedTypeNames": [], - "excludedParameterNames": [], - "handleReferences": false, - "generateImmutableArrayProperties": false, - "generateImmutableDictionaryProperties": false, - "jsonSerializerSettingsTransformationMethod": null, - "inlineNamedArrays": false, - "inlineNamedDictionaries": false, - "inlineNamedTuples": true, - "inlineNamedAny": false, - "generateDtoTypes": true, - "generateOptionalPropertiesAsNullable": false, - "generateNullableReferenceTypes": true, - "templateDirectory": null, - "typeNameGeneratorType": null, - "propertyNameGeneratorType": null, - "enumNameGeneratorType": null, - "serviceHost": null, - "serviceSchemes": null, - "output": "ApiClient.cs", - "newLineBehavior": "Auto" - } - } -} \ No newline at end of file diff --git a/src/apps/blazor/infrastructure/Auth/AuthorizationServiceExtensions.cs b/src/apps/blazor/infrastructure/Auth/AuthorizationServiceExtensions.cs deleted file mode 100644 index df265c8a4b..0000000000 --- a/src/apps/blazor/infrastructure/Auth/AuthorizationServiceExtensions.cs +++ /dev/null @@ -1,13 +0,0 @@ -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Authorization; -using System.Security.Claims; - -namespace FSH.Starter.Blazor.Infrastructure.Auth; - -public static class AuthorizationServiceExtensions -{ - public static async Task HasPermissionAsync(this IAuthorizationService service, ClaimsPrincipal user, string action, string resource) - { - return (await service.AuthorizeAsync(user, null, FshPermission.NameFor(action, resource))).Succeeded; - } -} diff --git a/src/apps/blazor/infrastructure/Auth/Extensions.cs b/src/apps/blazor/infrastructure/Auth/Extensions.cs deleted file mode 100644 index 730ef26e7a..0000000000 --- a/src/apps/blazor/infrastructure/Auth/Extensions.cs +++ /dev/null @@ -1,34 +0,0 @@ -using FSH.Starter.Blazor.Infrastructure.Auth.Jwt; -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Components.Authorization; -using Microsoft.AspNetCore.Components.WebAssembly.Authentication; -using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; - -namespace FSH.Starter.Blazor.Infrastructure.Auth; -public static class Extensions -{ - public static IServiceCollection AddAuthentication(this IServiceCollection services, IConfiguration config) - { - services.AddScoped() - .AddScoped(sp => (IAuthenticationService)sp.GetRequiredService()) - .AddScoped(sp => (IAccessTokenProvider)sp.GetRequiredService()) - .AddScoped() - .AddScoped(); - - services.AddAuthorizationCore(RegisterPermissionClaims); - services.AddCascadingAuthenticationState(); - return services; - } - - - private static void RegisterPermissionClaims(AuthorizationOptions options) - { - foreach (var permission in FshPermissions.All.Select(p => p.Name)) - { - options.AddPolicy(permission, policy => policy.RequireClaim(FshClaims.Permission, permission)); - } - } -} diff --git a/src/apps/blazor/infrastructure/Auth/IAuthenticationService.cs b/src/apps/blazor/infrastructure/Auth/IAuthenticationService.cs deleted file mode 100644 index 4de354af27..0000000000 --- a/src/apps/blazor/infrastructure/Auth/IAuthenticationService.cs +++ /dev/null @@ -1,15 +0,0 @@ -using FSH.Starter.Blazor.Infrastructure.Api; - -namespace FSH.Starter.Blazor.Infrastructure.Auth; - -public interface IAuthenticationService -{ - - void NavigateToExternalLogin(string returnUrl); - - Task LoginAsync(string tenantId, TokenGenerationCommand request); - - Task LogoutAsync(); - - Task ReLoginAsync(string returnUrl); -} \ No newline at end of file diff --git a/src/apps/blazor/infrastructure/Auth/Jwt/AccessTokenProviderAccessor.cs b/src/apps/blazor/infrastructure/Auth/Jwt/AccessTokenProviderAccessor.cs deleted file mode 100644 index 52df7087ae..0000000000 --- a/src/apps/blazor/infrastructure/Auth/Jwt/AccessTokenProviderAccessor.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Microsoft.AspNetCore.Components.WebAssembly.Authentication; -using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal; -using Microsoft.Extensions.DependencyInjection; - -namespace FSH.Starter.Blazor.Infrastructure.Auth.Jwt; - -public class AccessTokenProviderAccessor : IAccessTokenProviderAccessor -{ - private readonly IServiceProvider _provider; - private IAccessTokenProvider? _tokenProvider; - - public AccessTokenProviderAccessor(IServiceProvider provider) => - _provider = provider; - - public IAccessTokenProvider TokenProvider => - _tokenProvider ??= _provider.GetRequiredService(); -} \ No newline at end of file diff --git a/src/apps/blazor/infrastructure/Auth/Jwt/AccessTokenProviderExtensions.cs b/src/apps/blazor/infrastructure/Auth/Jwt/AccessTokenProviderExtensions.cs deleted file mode 100644 index 7004cfe5e4..0000000000 --- a/src/apps/blazor/infrastructure/Auth/Jwt/AccessTokenProviderExtensions.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Microsoft.AspNetCore.Components.WebAssembly.Authentication; - -namespace FSH.Starter.Blazor.Infrastructure.Auth.Jwt; - -public static class AccessTokenProviderExtensions -{ - public static async Task GetAccessTokenAsync(this IAccessTokenProvider tokenProvider) => - (await tokenProvider.RequestAccessToken()) - .TryGetToken(out var token) - ? token.Value - : null; -} \ No newline at end of file diff --git a/src/apps/blazor/infrastructure/Auth/Jwt/JwtAuthenticationHeaderHandler.cs b/src/apps/blazor/infrastructure/Auth/Jwt/JwtAuthenticationHeaderHandler.cs deleted file mode 100644 index 6d9ad7656d..0000000000 --- a/src/apps/blazor/infrastructure/Auth/Jwt/JwtAuthenticationHeaderHandler.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal; -using System.Net.Http.Headers; - -namespace FSH.Starter.Blazor.Infrastructure.Auth.Jwt; -public class JwtAuthenticationHeaderHandler : DelegatingHandler -{ - private readonly IAccessTokenProviderAccessor _tokenProviderAccessor; - private readonly NavigationManager _navigation; - - public JwtAuthenticationHeaderHandler(IAccessTokenProviderAccessor tokenProviderAccessor, NavigationManager navigation) - { - _tokenProviderAccessor = tokenProviderAccessor; - _navigation = navigation; - } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - // skip token endpoints - if (request.RequestUri?.AbsolutePath.Contains("/token") is not true) - { - if (await _tokenProviderAccessor.TokenProvider.GetAccessTokenAsync() is string token) - { - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - } - else - { - _navigation.NavigateTo("/login"); - } - } - - return await base.SendAsync(request, cancellationToken); - } -} \ No newline at end of file diff --git a/src/apps/blazor/infrastructure/Auth/Jwt/JwtAuthenticationService.cs b/src/apps/blazor/infrastructure/Auth/Jwt/JwtAuthenticationService.cs deleted file mode 100644 index f129e35020..0000000000 --- a/src/apps/blazor/infrastructure/Auth/Jwt/JwtAuthenticationService.cs +++ /dev/null @@ -1,242 +0,0 @@ -using System.Security.Claims; -using System.Text.Json; -using Blazored.LocalStorage; -using FSH.Starter.Blazor.Infrastructure.Api; -using FSH.Starter.Blazor.Infrastructure.Storage; -using FSH.Starter.Shared.Authorization; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Authorization; -using Microsoft.AspNetCore.Components.WebAssembly.Authentication; - -namespace FSH.Starter.Blazor.Infrastructure.Auth.Jwt; - -// This is a client-side AuthenticationStateProvider that determines the user's authentication state by -// looking for data persisted in the page when it was rendered on the server. This authentication state will -// be fixed for the lifetime of the WebAssembly application. So, if the user needs to log in or out, a full -// page reload is required. -// -// This only provides a user name and email for display purposes. It does not actually include any tokens -// that authenticate to the server when making subsequent requests. That works separately using a -// cookie that will be included on HttpClient requests to the server. -public sealed class JwtAuthenticationService : AuthenticationStateProvider, IAuthenticationService, IAccessTokenProvider -{ - private readonly SemaphoreSlim _semaphore = new(1, 1); - private readonly IApiClient _client; - private readonly ILocalStorageService _localStorage; - private readonly NavigationManager _navigation; - - public JwtAuthenticationService(PersistentComponentState state, ILocalStorageService localStorage, IApiClient client, NavigationManager navigation) - { - _localStorage = localStorage; - _client = client; - _navigation = navigation; - } - - public override async Task GetAuthenticationStateAsync() - { - string? cachedToken = await GetCachedAuthTokenAsync(); - if (string.IsNullOrWhiteSpace(cachedToken)) - { - return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); - } - - // Generate claimsIdentity from cached token - var claimsIdentity = new ClaimsIdentity(GetClaimsFromJwt(cachedToken), "jwt"); - - // Add cached permissions as claims - if (await GetCachedPermissionsAsync() is List cachedPermissions) - { - claimsIdentity.AddClaims(cachedPermissions.Select(p => new Claim(FshClaims.Permission, p))); - } - - return new AuthenticationState(new ClaimsPrincipal(claimsIdentity)); - } - - public async Task LoginAsync(string tenantId, TokenGenerationCommand request) - { - var tokenResponse = await _client.TokenGenerationEndpointAsync(tenantId, request); - - string? token = tokenResponse.Token; - string? refreshToken = tokenResponse.RefreshToken; - - if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(refreshToken)) - { - return false; - } - - await CacheAuthTokens(token, refreshToken); - - // Get permissions for the current user and add them to the cache - var permissions = await _client.GetUserPermissionsAsync(); - await CachePermissions(permissions); - - NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); - - return true; - } - - public async Task LogoutAsync() - { - await ClearCacheAsync(); - - NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); - - _navigation.NavigateTo("/login"); - } - - public void NavigateToExternalLogin(string returnUrl) - { - throw new NotImplementedException(); - } - - public async Task ReLoginAsync(string returnUrl) - { - await LogoutAsync(); - _navigation.NavigateTo(returnUrl); - } - - public async ValueTask RequestAccessToken(AccessTokenRequestOptions options) - { - return await RequestAccessToken(); - - } - - public async ValueTask RequestAccessToken() - { - // We make sure the access token is only refreshed by one thread at a time. The other ones have to wait. - await _semaphore.WaitAsync(); - try - { - var authState = await GetAuthenticationStateAsync(); - if (authState.User.Identity?.IsAuthenticated is not true) - { - return new AccessTokenResult(AccessTokenResultStatus.RequiresRedirect, new(), "/login", default); - } - - string? token = await GetCachedAuthTokenAsync(); - - //// Check if token needs to be refreshed (when its expiration time is less than 1 minute away) - var expTime = authState.User.GetExpiration(); - var diff = expTime - DateTime.UtcNow; - if (diff.TotalMinutes <= 1) - { - //return new AccessTokenResult(AccessTokenResultStatus.RequiresRedirect, new(), "/login", default); - string? refreshToken = await GetCachedRefreshTokenAsync(); - (bool succeeded, var response) = await TryRefreshTokenAsync(new RefreshTokenCommand { Token = token, RefreshToken = refreshToken }); - if (!succeeded) - { - return new AccessTokenResult(AccessTokenResultStatus.RequiresRedirect, new(), "/login", default); - } - - token = response?.Token; - } - - return new AccessTokenResult(AccessTokenResultStatus.Success, new AccessToken() { Value = token! }, string.Empty, default); - } - finally - { - _semaphore.Release(); - } - } - - private async Task<(bool Succeeded, TokenResponse? Token)> TryRefreshTokenAsync(RefreshTokenCommand request) - { - var authState = await GetAuthenticationStateAsync(); - string? tenantKey = authState.User.GetTenant(); - if (string.IsNullOrWhiteSpace(tenantKey)) - { - throw new InvalidOperationException("Can't refresh token when user is not logged in!"); - } - - try - { - var tokenResponse = await _client.RefreshTokenEndpointAsync(tenantKey, request); - - await CacheAuthTokens(tokenResponse.Token, tokenResponse.RefreshToken); - - return (true, tokenResponse); - } - catch - { - return (false, null); - } - } - - private async ValueTask CacheAuthTokens(string? token, string? refreshToken) - { - await _localStorage.SetItemAsync(StorageConstants.Local.AuthToken, token); - await _localStorage.SetItemAsync(StorageConstants.Local.RefreshToken, refreshToken); - } - - private ValueTask CachePermissions(ICollection permissions) - { - return _localStorage.SetItemAsync(StorageConstants.Local.Permissions, permissions); - } - - private async Task ClearCacheAsync() - { - await _localStorage.RemoveItemAsync(StorageConstants.Local.AuthToken); - await _localStorage.RemoveItemAsync(StorageConstants.Local.RefreshToken); - await _localStorage.RemoveItemAsync(StorageConstants.Local.Permissions); - } - private ValueTask GetCachedAuthTokenAsync() - { - return _localStorage.GetItemAsync(StorageConstants.Local.AuthToken); - } - - private ValueTask GetCachedRefreshTokenAsync() - { - return _localStorage.GetItemAsync(StorageConstants.Local.RefreshToken); - } - - private ValueTask?> GetCachedPermissionsAsync() - { - return _localStorage.GetItemAsync>(StorageConstants.Local.Permissions); - } - - private IEnumerable GetClaimsFromJwt(string jwt) - { - var claims = new List(); - string payload = jwt.Split('.')[1]; - byte[] jsonBytes = ParseBase64WithoutPadding(payload); - var keyValuePairs = JsonSerializer.Deserialize>(jsonBytes); - - if (keyValuePairs is not null) - { - keyValuePairs.TryGetValue(ClaimTypes.Role, out object? roles); - - if (roles is not null) - { - string? rolesString = roles.ToString(); - if (!string.IsNullOrEmpty(rolesString)) - { - if (rolesString.Trim().StartsWith("[")) - { - string[]? parsedRoles = JsonSerializer.Deserialize(rolesString); - - if (parsedRoles is not null) - { - claims.AddRange(parsedRoles.Select(role => new Claim(ClaimTypes.Role, role))); - } - } - else - { - claims.Add(new Claim(ClaimTypes.Role, rolesString)); - } - } - - keyValuePairs.Remove(ClaimTypes.Role); - } - - claims.AddRange(keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString() ?? string.Empty))); - } - - return claims; - } - private byte[] ParseBase64WithoutPadding(string payload) - { - payload = payload.Trim().Replace('-', '+').Replace('_', '/'); - string base64 = payload.PadRight(payload.Length + (4 - payload.Length % 4) % 4, '='); - return Convert.FromBase64String(base64); - } -} diff --git a/src/apps/blazor/infrastructure/Auth/UserInfo.cs b/src/apps/blazor/infrastructure/Auth/UserInfo.cs deleted file mode 100644 index 28bdcff7e7..0000000000 --- a/src/apps/blazor/infrastructure/Auth/UserInfo.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Security.Claims; - -namespace FSH.Starter.Blazor.Infrastructure.Auth; - -// Add properties to this class and update the server and client AuthenticationStateProviders -// to expose more information about the authenticated user to the client. -public sealed class UserInfo -{ - public required string UserId { get; init; } - public required string Name { get; init; } - - public const string UserIdClaimType = "sub"; - public const string NameClaimType = "name"; - - public static UserInfo FromClaimsPrincipal(ClaimsPrincipal principal) => - new() - { - UserId = GetRequiredClaim(principal, UserIdClaimType), - Name = GetRequiredClaim(principal, NameClaimType), - }; - - public ClaimsPrincipal ToClaimsPrincipal() => - new(new ClaimsIdentity( - [new(UserIdClaimType, UserId), new(NameClaimType, Name)], - authenticationType: nameof(UserInfo), - nameType: NameClaimType, - roleType: null)); - - private static string GetRequiredClaim(ClaimsPrincipal principal, string claimType) => - principal.FindFirst(claimType)?.Value ?? throw new InvalidOperationException($"Could not find required '{claimType}' claim."); -} diff --git a/src/apps/blazor/infrastructure/Directory.Packages.props b/src/apps/blazor/infrastructure/Directory.Packages.props deleted file mode 100644 index 22802bc43d..0000000000 --- a/src/apps/blazor/infrastructure/Directory.Packages.props +++ /dev/null @@ -1,22 +0,0 @@ - - - true - true - true - - - true - true - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/apps/blazor/infrastructure/Extensions.cs b/src/apps/blazor/infrastructure/Extensions.cs deleted file mode 100644 index 10adc054a0..0000000000 --- a/src/apps/blazor/infrastructure/Extensions.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Globalization; -using Blazored.LocalStorage; -using FSH.Starter.Blazor.Infrastructure.Api; -using FSH.Starter.Blazor.Infrastructure.Auth; -using FSH.Starter.Blazor.Infrastructure.Auth.Jwt; -using FSH.Starter.Blazor.Infrastructure.Notifications; -using FSH.Starter.Blazor.Infrastructure.Preferences; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using MudBlazor; -using MudBlazor.Services; - -namespace FSH.Starter.Blazor.Infrastructure; -public static class Extensions -{ - private const string ClientName = "FullStackHero.API"; - public static IServiceCollection AddClientServices(this IServiceCollection services, IConfiguration config) - { - services.AddMudServices(configuration => - { - configuration.SnackbarConfiguration.PositionClass = Defaults.Classes.Position.BottomRight; - configuration.SnackbarConfiguration.HideTransitionDuration = 100; - configuration.SnackbarConfiguration.ShowTransitionDuration = 100; - configuration.SnackbarConfiguration.VisibleStateDuration = 3000; - configuration.SnackbarConfiguration.ShowCloseIcon = false; - }); - services.AddBlazoredLocalStorage(); - services.AddAuthentication(config); - services.AddTransient(); - services.AddHttpClient(ClientName, client => - { - client.DefaultRequestHeaders.AcceptLanguage.Clear(); - client.DefaultRequestHeaders.AcceptLanguage.ParseAdd(CultureInfo.DefaultThreadCurrentCulture?.TwoLetterISOLanguageName); - client.BaseAddress = new Uri(config["ApiBaseUrl"]!); - }) - .AddHttpMessageHandler() - .Services - .AddScoped(sp => sp.GetRequiredService().CreateClient(ClientName)); - services.AddTransient(); - services.AddTransient(); - services.AddNotifications(); - return services; - - } -} diff --git a/src/apps/blazor/infrastructure/Infrastructure.csproj b/src/apps/blazor/infrastructure/Infrastructure.csproj deleted file mode 100644 index d53d6be854..0000000000 --- a/src/apps/blazor/infrastructure/Infrastructure.csproj +++ /dev/null @@ -1,30 +0,0 @@ - - - - net9.0 - enable - enable - FSH.Starter.Blazor.Infrastructure - FSH.Starter.Blazor.Infrastructure - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/apps/blazor/infrastructure/Notifications/ConnectionState.cs b/src/apps/blazor/infrastructure/Notifications/ConnectionState.cs deleted file mode 100644 index 69cf9bb2d3..0000000000 --- a/src/apps/blazor/infrastructure/Notifications/ConnectionState.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace FSH.Starter.Blazor.Infrastructure.Notifications; - -public enum ConnectionState -{ - Connected, - Connecting, - Disconnected -} \ No newline at end of file diff --git a/src/apps/blazor/infrastructure/Notifications/ConnectionStateChanged.cs b/src/apps/blazor/infrastructure/Notifications/ConnectionStateChanged.cs deleted file mode 100644 index ae23346385..0000000000 --- a/src/apps/blazor/infrastructure/Notifications/ConnectionStateChanged.cs +++ /dev/null @@ -1,5 +0,0 @@ -using FSH.Starter.Blazor.Shared.Notifications; - -namespace FSH.Starter.Blazor.Infrastructure.Notifications; - -public record ConnectionStateChanged(ConnectionState State, string? Message) : INotificationMessage; \ No newline at end of file diff --git a/src/apps/blazor/infrastructure/Notifications/Extensions.cs b/src/apps/blazor/infrastructure/Notifications/Extensions.cs deleted file mode 100644 index 872b6b8a7a..0000000000 --- a/src/apps/blazor/infrastructure/Notifications/Extensions.cs +++ /dev/null @@ -1,35 +0,0 @@ -using FSH.Starter.Blazor.Shared.Notifications; -using MediatR; -using MediatR.Courier; -using Microsoft.Extensions.DependencyInjection; - -namespace FSH.Starter.Blazor.Infrastructure.Notifications; -internal static class Extensions -{ - public static IServiceCollection AddNotifications(this IServiceCollection services) - { - // Add mediator processing of notifications - var assemblies = AppDomain.CurrentDomain.GetAssemblies(); - - services - .AddMediatR(cfg => - { - cfg.RegisterServicesFromAssemblies(assemblies); - }) - .AddCourier(assemblies) - .AddTransient(); - - // Register handlers for all INotificationMessages - foreach (var eventType in assemblies - .SelectMany(a => a.GetTypes()) - .Where(t => t.GetInterfaces().Any(i => i == typeof(INotificationMessage)))) - { - services.AddSingleton( - typeof(INotificationHandler<>).MakeGenericType( - typeof(NotificationWrapper<>).MakeGenericType(eventType)), - serviceProvider => serviceProvider.GetRequiredService(typeof(MediatRCourier))); - } - - return services; - } -} diff --git a/src/apps/blazor/infrastructure/Notifications/INotificationPublisher.cs b/src/apps/blazor/infrastructure/Notifications/INotificationPublisher.cs deleted file mode 100644 index 8ebca593be..0000000000 --- a/src/apps/blazor/infrastructure/Notifications/INotificationPublisher.cs +++ /dev/null @@ -1,8 +0,0 @@ -using FSH.Starter.Blazor.Shared.Notifications; - -namespace FSH.Starter.Blazor.Infrastructure.Notifications; - -public interface INotificationPublisher -{ - Task PublishAsync(INotificationMessage notification); -} \ No newline at end of file diff --git a/src/apps/blazor/infrastructure/Notifications/NotificationPublisher.cs b/src/apps/blazor/infrastructure/Notifications/NotificationPublisher.cs deleted file mode 100644 index 7bab4ecfc4..0000000000 --- a/src/apps/blazor/infrastructure/Notifications/NotificationPublisher.cs +++ /dev/null @@ -1,24 +0,0 @@ -using FSH.Starter.Blazor.Shared.Notifications; -using MediatR; -using Microsoft.Extensions.Logging; - -namespace FSH.Starter.Blazor.Infrastructure.Notifications; - -public class NotificationPublisher : INotificationPublisher -{ - private readonly ILogger _logger; - private readonly IPublisher _mediator; - - public NotificationPublisher(ILogger logger, IPublisher mediator) => - (_logger, _mediator) = (logger, mediator); - - public Task PublishAsync(INotificationMessage notification) - { - _logger.LogInformation("Publishing Notification : {notification}", notification.GetType().Name); - return _mediator.Publish(CreateNotificationWrapper(notification)); - } - - private INotification CreateNotificationWrapper(INotificationMessage notification) => - (INotification)Activator.CreateInstance( - typeof(NotificationWrapper<>).MakeGenericType(notification.GetType()), notification)!; -} \ No newline at end of file diff --git a/src/apps/blazor/infrastructure/Notifications/NotificationWrapper.cs b/src/apps/blazor/infrastructure/Notifications/NotificationWrapper.cs deleted file mode 100644 index adf1aba2cc..0000000000 --- a/src/apps/blazor/infrastructure/Notifications/NotificationWrapper.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FSH.Starter.Blazor.Shared.Notifications; -using MediatR; - -namespace FSH.Starter.Blazor.Infrastructure.Notifications; - -public class NotificationWrapper : INotification - where TNotificationMessage : INotificationMessage -{ - public NotificationWrapper(TNotificationMessage notification) => Notification = notification; - - public TNotificationMessage Notification { get; } -} \ No newline at end of file diff --git a/src/apps/blazor/infrastructure/Preferences/ClientPreference.cs b/src/apps/blazor/infrastructure/Preferences/ClientPreference.cs deleted file mode 100644 index 0003635571..0000000000 --- a/src/apps/blazor/infrastructure/Preferences/ClientPreference.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FSH.Starter.Blazor.Infrastructure.Themes; - -namespace FSH.Starter.Blazor.Infrastructure.Preferences; - -public class ClientPreference : IPreference -{ - public bool IsDarkMode { get; set; } = true; - public bool IsRTL { get; set; } - public bool IsDrawerOpen { get; set; } - public string PrimaryColor { get; set; } = CustomColors.Light.Primary; - public string SecondaryColor { get; set; } = CustomColors.Light.Secondary; - public double BorderRadius { get; set; } = 5; - public FshTablePreference TablePreference { get; set; } = new FshTablePreference(); -} diff --git a/src/apps/blazor/infrastructure/Preferences/ClientPreferenceManager.cs b/src/apps/blazor/infrastructure/Preferences/ClientPreferenceManager.cs deleted file mode 100644 index bd11448a53..0000000000 --- a/src/apps/blazor/infrastructure/Preferences/ClientPreferenceManager.cs +++ /dev/null @@ -1,136 +0,0 @@ -using System.Text.RegularExpressions; -using Blazored.LocalStorage; -using FSH.Starter.Blazor.Infrastructure.Themes; -using MudBlazor; - -namespace FSH.Starter.Blazor.Infrastructure.Preferences; - -public class ClientPreferenceManager : IClientPreferenceManager -{ - private readonly ILocalStorageService _localStorageService; - - public ClientPreferenceManager( - ILocalStorageService localStorageService) - { - _localStorageService = localStorageService; - } - - public async Task ToggleDarkModeAsync() - { - if (await GetPreference() is ClientPreference preference) - { - preference.IsDarkMode = !preference.IsDarkMode; - await SetPreference(preference); - return !preference.IsDarkMode; - } - - return false; - } - - public async Task ToggleDrawerAsync() - { - if (await GetPreference() is ClientPreference preference) - { - preference.IsDrawerOpen = !preference.IsDrawerOpen; - await SetPreference(preference); - return preference.IsDrawerOpen; - } - - return false; - } - - public async Task ToggleLayoutDirectionAsync() - { - if (await GetPreference() is ClientPreference preference) - { - preference.IsRTL = !preference.IsRTL; - await SetPreference(preference); - return preference.IsRTL; - } - - return false; - } - - public async Task ChangeLanguageAsync(string languageCode) - { - //if (await GetPreference() is ClientPreference preference) - //{ - // var language = Array.Find(LocalizationConstants.SupportedLanguages, a => a.Code == languageCode); - // if (language?.Code is not null) - // { - // preference.LanguageCode = language.Code; - // preference.IsRTL = language.IsRTL; - // } - // else - // { - // preference.LanguageCode = "en-EN"; - // preference.IsRTL = false; - // } - - // await SetPreference(preference); - // return true; - //} - - return false; - } - - public async Task GetCurrentThemeAsync() - { - if (await GetPreference() is ClientPreference preference && preference.IsDarkMode) - return new FshTheme(); - - return new FshTheme(); - } - - public async Task GetPrimaryColorAsync() - { - if (await GetPreference() is ClientPreference preference) - { - string colorCode = preference.PrimaryColor; - if (Regex.Match(colorCode, "^#(?:[0-9a-fA-F]{3,4}){1,2}$").Success) - { - return colorCode; - } - else - { - preference.PrimaryColor = CustomColors.Light.Primary; - await SetPreference(preference); - return preference.PrimaryColor; - } - } - - return CustomColors.Light.Primary; - } - - public async Task IsRTL() - { - if (await GetPreference() is ClientPreference preference) - { - return preference.IsRTL; - } - - return false; - } - - public async Task IsDrawerOpen() - { - if (await GetPreference() is ClientPreference preference) - { - return preference.IsDrawerOpen; - } - - return false; - } - - public static string Preference = "clientPreference"; - - public async Task GetPreference() - { - return await _localStorageService.GetItemAsync(Preference) ?? new ClientPreference(); - } - - public async Task SetPreference(IPreference preference) - { - await _localStorageService.SetItemAsync(Preference, preference as ClientPreference); - } -} diff --git a/src/apps/blazor/infrastructure/Preferences/FshTablePreference.cs b/src/apps/blazor/infrastructure/Preferences/FshTablePreference.cs deleted file mode 100644 index f5595061bd..0000000000 --- a/src/apps/blazor/infrastructure/Preferences/FshTablePreference.cs +++ /dev/null @@ -1,11 +0,0 @@ -using FSH.Starter.Blazor.Shared.Notifications; - -namespace FSH.Starter.Blazor.Infrastructure.Preferences; - -public class FshTablePreference : INotificationMessage -{ - public bool IsDense { get; set; } - public bool IsStriped { get; set; } - public bool HasBorder { get; set; } - public bool IsHoverable { get; set; } -} \ No newline at end of file diff --git a/src/apps/blazor/infrastructure/Preferences/IClientPreferenceManager.cs b/src/apps/blazor/infrastructure/Preferences/IClientPreferenceManager.cs deleted file mode 100644 index 444fd15c3f..0000000000 --- a/src/apps/blazor/infrastructure/Preferences/IClientPreferenceManager.cs +++ /dev/null @@ -1,14 +0,0 @@ -using MudBlazor; - -namespace FSH.Starter.Blazor.Infrastructure.Preferences; - -public interface IClientPreferenceManager : IPreferenceManager -{ - Task GetCurrentThemeAsync(); - - Task ToggleDarkModeAsync(); - - Task ToggleDrawerAsync(); - - Task ToggleLayoutDirectionAsync(); -} \ No newline at end of file diff --git a/src/apps/blazor/infrastructure/Preferences/IPreference.cs b/src/apps/blazor/infrastructure/Preferences/IPreference.cs deleted file mode 100644 index 8909a3ad48..0000000000 --- a/src/apps/blazor/infrastructure/Preferences/IPreference.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace FSH.Starter.Blazor.Infrastructure.Preferences; - -public interface IPreference -{ - -} \ No newline at end of file diff --git a/src/apps/blazor/infrastructure/Preferences/IPreferenceManager.cs b/src/apps/blazor/infrastructure/Preferences/IPreferenceManager.cs deleted file mode 100644 index 041f44603b..0000000000 --- a/src/apps/blazor/infrastructure/Preferences/IPreferenceManager.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace FSH.Starter.Blazor.Infrastructure.Preferences; - -public interface IPreferenceManager -{ - Task SetPreference(IPreference preference); - - Task GetPreference(); - - Task ChangeLanguageAsync(string languageCode); -} \ No newline at end of file diff --git a/src/apps/blazor/infrastructure/Storage/StorageConstants.cs b/src/apps/blazor/infrastructure/Storage/StorageConstants.cs deleted file mode 100644 index 120cf4c5e5..0000000000 --- a/src/apps/blazor/infrastructure/Storage/StorageConstants.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace FSH.Starter.Blazor.Infrastructure.Storage; -public static class StorageConstants -{ - public static class Local - { - public static string Preference = "clientPreference"; - - public static string AuthToken = "authToken"; - public static string RefreshToken = "refreshToken"; - public static string ImageUri = "userImageURL"; - public static string Permissions = "permissions"; - } -} diff --git a/src/apps/blazor/infrastructure/Themes/CustomColors.cs b/src/apps/blazor/infrastructure/Themes/CustomColors.cs deleted file mode 100644 index 5d72078418..0000000000 --- a/src/apps/blazor/infrastructure/Themes/CustomColors.cs +++ /dev/null @@ -1,42 +0,0 @@ -using MudBlazor; - -namespace FSH.Starter.Blazor.Infrastructure.Themes; - -public static class CustomColors -{ - public static readonly List ThemeColors = new() - { - Light.Primary, - Colors.Blue.Default, - Colors.Purple.Default, - Colors.Orange.Default, - Colors.Red.Default, - Colors.Amber.Default, - Colors.DeepPurple.Default, - Colors.Pink.Default, - Colors.Indigo.Default, - Colors.LightBlue.Default, - Colors.Cyan.Default, - Colors.Green.Default, - }; - - public static class Light - { - public const string Primary = "rgba(76,175,80,1)"; - public const string Secondary = "rgba(33,150,243,1)"; - public const string Background = "#FFF"; - public const string AppbarBackground = "#FFF"; - public const string AppbarText = "#6e6e6e"; - } - - public static class Dark - { - public const string Primary = "rgba(76,175,80,1)"; - public const string Secondary = "rgba(33,150,243,1)"; - public const string Background = "#1b1f22"; - public const string AppbarBackground = "#1b1f22"; - public const string DrawerBackground = "#121212"; - public const string Surface = "#202528"; - public const string Disabled = "#545454"; - } -} diff --git a/src/apps/blazor/infrastructure/Themes/CustomTypography.cs b/src/apps/blazor/infrastructure/Themes/CustomTypography.cs deleted file mode 100644 index 69faf18ed2..0000000000 --- a/src/apps/blazor/infrastructure/Themes/CustomTypography.cs +++ /dev/null @@ -1,114 +0,0 @@ -using MudBlazor; - -namespace FSH.Starter.Blazor.Infrastructure.Themes; - -public static class CustomTypography -{ - public static Typography FshTypography => new Typography() - { - Default = new DefaultTypography() - { - FontFamily = ["Montserrat", "Helvetica", "Arial", "sans-serif"], - FontSize = ".875rem", - FontWeight = "400", - LineHeight = "1.43", - LetterSpacing = ".01071em" - }, - H1 = new H1Typography() - { - FontFamily = ["Montserrat", "Helvetica", "Arial", "sans-serif"], - FontSize = "3rem", - FontWeight = "300", - LineHeight = "1.167", - LetterSpacing = "-.01562em" - }, - H2 = new H2Typography() - { - FontFamily = ["Montserrat", "Helvetica", "Arial", "sans-serif"], - FontSize = "2.75rem", - FontWeight = "300", - LineHeight = "1.2", - LetterSpacing = "-.00833em" - }, - H3 = new H3Typography() - { - FontFamily = ["Montserrat", "Helvetica", "Arial", "sans-serif"], - FontSize = "2rem", - FontWeight = "400", - LineHeight = "1.167", - LetterSpacing = "0" - }, - H4 = new H4Typography() - { - FontFamily = ["Montserrat", "Helvetica", "Arial", "sans-serif"], - FontSize = "1.75rem", - FontWeight = "400", - LineHeight = "1.235", - LetterSpacing = ".00735em" - }, - H5 = new H5Typography() - { - FontFamily = ["Montserrat", "Helvetica", "Arial", "sans-serif"], - FontSize = "1.5rem", - FontWeight = "400", - LineHeight = "1.334", - LetterSpacing = "0" - }, - H6 = new H6Typography() - { - FontFamily = ["Montserrat", "Helvetica", "Arial", "sans-serif"], - FontSize = "1.25rem", - FontWeight = "400", - LineHeight = "1.6", - LetterSpacing = ".0075em" - }, - Button = new ButtonTypography() - { - FontFamily = ["Montserrat", "Helvetica", "Arial", "sans-serif"], - FontSize = ".875rem", - FontWeight = "400", - LineHeight = "1.75", - LetterSpacing = ".02857em" - }, - Body1 = new Body1Typography() - { - FontFamily = ["Montserrat", "Helvetica", "Arial", "sans-serif"], - FontSize = "1rem", - FontWeight = "400", - LineHeight = "1.5", - LetterSpacing = ".00938em" - }, - Body2 = new Body2Typography() - { - FontFamily = ["Montserrat", "Helvetica", "Arial", "sans-serif"], - FontSize = ".875rem", - FontWeight = "400", - LineHeight = "1.43", - LetterSpacing = ".01071em" - }, - Caption = new CaptionTypography() - { - FontFamily = ["Montserrat", "Helvetica", "Arial", "sans-serif"], - FontSize = ".75rem", - FontWeight = "200", - LineHeight = "1.66", - LetterSpacing = ".03333em" - }, - Subtitle1 = new Subtitle1Typography() - { - FontFamily = ["Montserrat", "Helvetica", "Arial", "sans-serif"], - FontSize = "1rem", - FontWeight = "400", - LineHeight = "1.57", - LetterSpacing = ".00714em" - }, - Subtitle2 = new Subtitle2Typography() - { - FontFamily = ["Montserrat", "Helvetica", "Arial", "sans-serif"], - FontSize = ".875rem", - FontWeight = "400", - LineHeight = "1.57", - LetterSpacing = ".00714em" - } - }; -} diff --git a/src/apps/blazor/infrastructure/Themes/FshTheme.cs b/src/apps/blazor/infrastructure/Themes/FshTheme.cs deleted file mode 100644 index 233ad52ebb..0000000000 --- a/src/apps/blazor/infrastructure/Themes/FshTheme.cs +++ /dev/null @@ -1,57 +0,0 @@ -using MudBlazor; - -namespace FSH.Starter.Blazor.Infrastructure.Themes; - -public class FshTheme : MudTheme -{ - public FshTheme() - { - PaletteLight = new PaletteLight() - { - Primary = CustomColors.Light.Primary, - Secondary = CustomColors.Light.Secondary, - Background = CustomColors.Light.Background, - AppbarBackground = CustomColors.Light.AppbarBackground, - AppbarText = CustomColors.Light.AppbarText, - DrawerBackground = CustomColors.Light.Background, - DrawerText = "rgba(0,0,0, 0.7)", - Success = CustomColors.Light.Primary, - TableLines = "#e0e0e029", - OverlayDark = "hsl(0deg 0% 0% / 75%)" - }; - - PaletteDark = new PaletteDark() - { - Primary = CustomColors.Dark.Primary, - Secondary = CustomColors.Dark.Secondary, - Success = CustomColors.Dark.Primary, - Black = "#27272f", - Background = CustomColors.Dark.Background, - Surface = CustomColors.Dark.Surface, - DrawerBackground = CustomColors.Dark.DrawerBackground, - DrawerText = "rgba(255,255,255, 0.50)", - AppbarBackground = CustomColors.Dark.AppbarBackground, - AppbarText = "rgba(255,255,255, 0.70)", - TextPrimary = "rgba(255,255,255, 0.70)", - TextSecondary = "rgba(255,255,255, 0.50)", - ActionDefault = "#adadb1", - ActionDisabled = "rgba(255,255,255, 0.26)", - ActionDisabledBackground = "rgba(255,255,255, 0.12)", - DrawerIcon = "rgba(255,255,255, 0.50)", - TableLines = "#e0e0e036", - Dark = CustomColors.Dark.DrawerBackground, - Divider = "#e0e0e036", - OverlayDark = "hsl(0deg 0% 0% / 75%)", - TextDisabled = CustomColors.Dark.Disabled - }; - - LayoutProperties = new LayoutProperties() - { - DefaultBorderRadius = "5px" - }; - - Typography = CustomTypography.FshTypography; - Shadows = new Shadow(); - ZIndex = new ZIndex() { Drawer = 1300 }; - } -} \ No newline at end of file diff --git a/src/apps/blazor/nginx.conf b/src/apps/blazor/nginx.conf deleted file mode 100644 index 9c2af758d5..0000000000 --- a/src/apps/blazor/nginx.conf +++ /dev/null @@ -1,19 +0,0 @@ -events { } - -http { - include mime.types; - - types { - application/wasm wasm; - } - - server { - listen 80; - index index.html; - - location / { - root /usr/share/nginx/html; - try_files $uri $uri/ /index.html =404; - } - } -} diff --git a/src/apps/blazor/scripts/nswag-regen.ps1 b/src/apps/blazor/scripts/nswag-regen.ps1 deleted file mode 100644 index 0d88d60bc5..0000000000 --- a/src/apps/blazor/scripts/nswag-regen.ps1 +++ /dev/null @@ -1,20 +0,0 @@ -# This script is cross-platform, supporting all OSes that PowerShell Core/7 runs on. - -$currentDirectory = Get-Location -$rootDirectory = git rev-parse --show-toplevel -$hostDirectory = Join-Path -Path $rootDirectory -ChildPath 'src/apps/blazor/client' -$infrastructurePrj = Join-Path -Path $rootDirectory -ChildPath 'src/apps/blazor/infrastructure/Infrastructure.csproj' - -Write-Host "Make sure you have run the WebAPI project. `n" -Write-Host "Press any key to continue... `n" -$null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown'); - -Set-Location -Path $hostDirectory -Write-Host "Host Directory is $hostDirectory `n" - -<# Run command #> -dotnet build -t:NSwag $infrastructurePrj - -Set-Location -Path $currentDirectory -Write-Host -NoNewLine 'NSwag Regenerated. Press any key to continue...'; -$null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown'); diff --git a/src/apps/blazor/shared/Notifications/BasicNotification.cs b/src/apps/blazor/shared/Notifications/BasicNotification.cs deleted file mode 100644 index 6401c24f18..0000000000 --- a/src/apps/blazor/shared/Notifications/BasicNotification.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace FSH.Starter.Blazor.Shared.Notifications; - -public class BasicNotification : INotificationMessage -{ - public enum LabelType - { - Information, - Success, - Warning, - Error - } - - public string? Message { get; set; } - public LabelType Label { get; set; } -} \ No newline at end of file diff --git a/src/apps/blazor/shared/Notifications/INotificationMessage.cs b/src/apps/blazor/shared/Notifications/INotificationMessage.cs deleted file mode 100644 index 48dd686d8e..0000000000 --- a/src/apps/blazor/shared/Notifications/INotificationMessage.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace FSH.Starter.Blazor.Shared.Notifications; - -public interface INotificationMessage -{ -} \ No newline at end of file diff --git a/src/apps/blazor/shared/Notifications/JobNotification.cs b/src/apps/blazor/shared/Notifications/JobNotification.cs deleted file mode 100644 index 9b645a6d66..0000000000 --- a/src/apps/blazor/shared/Notifications/JobNotification.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace FSH.Starter.Blazor.Shared.Notifications; - -public class JobNotification : INotificationMessage -{ - public string? Message { get; set; } - public string? JobId { get; set; } - public decimal Progress { get; set; } -} \ No newline at end of file diff --git a/src/apps/blazor/shared/Notifications/NotificationConstants.cs b/src/apps/blazor/shared/Notifications/NotificationConstants.cs deleted file mode 100644 index 69e0b764b3..0000000000 --- a/src/apps/blazor/shared/Notifications/NotificationConstants.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace FSH.Starter.Blazor.Shared.Notifications; - -public static class NotificationConstants -{ - public const string NotificationFromServer = nameof(NotificationFromServer); -} \ No newline at end of file diff --git a/src/apps/blazor/shared/Notifications/StatsChangedNotification.cs b/src/apps/blazor/shared/Notifications/StatsChangedNotification.cs deleted file mode 100644 index 7b8f4f006f..0000000000 --- a/src/apps/blazor/shared/Notifications/StatsChangedNotification.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace FSH.Starter.Blazor.Shared.Notifications; - -public class StatsChangedNotification : INotificationMessage -{ -} \ No newline at end of file diff --git a/src/apps/blazor/shared/Shared.csproj b/src/apps/blazor/shared/Shared.csproj deleted file mode 100644 index 16aa766eec..0000000000 --- a/src/apps/blazor/shared/Shared.csproj +++ /dev/null @@ -1,11 +0,0 @@ - - - - net9.0 - enable - enable - FSH.Starter.Blazor.Shared - FSH.Starter.Blazor.Shared - - - diff --git a/src/aspire/Host/Host.csproj b/src/aspire/Host/Host.csproj deleted file mode 100644 index 35f6caac53..0000000000 --- a/src/aspire/Host/Host.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - FSH.Starter.Aspire - FSH.Starter.Aspire - Exe - net9.0 - enable - enable - true - a007d645-3346-446a-89ab-2bb3fdeebb54 - - - - - - - - - - - - - - - diff --git a/src/aspire/Host/Program.cs b/src/aspire/Host/Program.cs deleted file mode 100644 index 1875e66fa7..0000000000 --- a/src/aspire/Host/Program.cs +++ /dev/null @@ -1,26 +0,0 @@ -var builder = DistributedApplication.CreateBuilder(args); - -builder.AddContainer("grafana", "grafana/grafana") - .WithBindMount("../../../compose/grafana/config", "/etc/grafana", isReadOnly: true) - .WithBindMount("../../../compose/grafana/dashboards", "/var/lib/grafana/dashboards", isReadOnly: true) - .WithHttpEndpoint(port: 3000, targetPort: 3000, name: "http"); - -builder.AddContainer("prometheus", "prom/prometheus") - .WithBindMount("../../../compose/prometheus", "/etc/prometheus", isReadOnly: true) - .WithHttpEndpoint(port: 9090, targetPort: 9090); - -var username = builder.AddParameter("pg-username", "admin"); -var password = builder.AddParameter("pg-password", "admin"); - -var database = builder.AddPostgres("db", username, password, port: 5432) - .WithDataVolume() - .AddDatabase("fullstackhero"); - -var api = builder.AddProject("webapi") - .WaitFor(database); - -var blazor = builder.AddProject("blazor"); - -using var app = builder.Build(); - -await app.RunAsync(); diff --git a/src/aspire/Host/Properties/launchSettings.json b/src/aspire/Host/Properties/launchSettings.json deleted file mode 100644 index 946a56bbb7..0000000000 --- a/src/aspire/Host/Properties/launchSettings.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:7200;http://localhost:5200", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "DOTNET_ENVIRONMENT": "Development", - "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21192", - "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22197" - } - } - } -} diff --git a/src/aspire/service-defaults/Extensions.cs b/src/aspire/service-defaults/Extensions.cs deleted file mode 100644 index 7893d5361b..0000000000 --- a/src/aspire/service-defaults/Extensions.cs +++ /dev/null @@ -1,152 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Diagnostics.HealthChecks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using OpenTelemetry; -using OpenTelemetry.Metrics; -using OpenTelemetry.Resources; -using OpenTelemetry.Trace; - -namespace FSH.Starter.Aspire.ServiceDefaults; - -// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. -// This project should be referenced by each service project in your solution. -// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults -public static class Extensions -{ - public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) - { - builder.ConfigureOpenTelemetry(); - - builder.AddDefaultHealthChecks(); - - builder.Services.AddServiceDiscovery(); - - builder.Services.ConfigureHttpClientDefaults(http => - { - // Turn on resilience by default - http.AddStandardResilienceHandler(); - - // Turn on service discovery by default - http.AddServiceDiscovery(); - }); - - return builder; - } - - public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) - { - #region OpenTelemetry - - // Configure OpenTelemetry service resource details - // See https://github.com/open-telemetry/opentelemetry-specification/tree/main/specification/resource/semantic_conventions - var entryAssembly = System.Reflection.Assembly.GetEntryAssembly(); - var entryAssemblyName = entryAssembly?.GetName(); - var versionAttribute = entryAssembly?.GetCustomAttributes(false) - .OfType() - .FirstOrDefault(); - var resourceServiceName = entryAssemblyName?.Name; - var resourceServiceVersion = versionAttribute?.InformationalVersion ?? entryAssemblyName?.Version?.ToString(); - var attributes = new Dictionary - { - ["host.name"] = Environment.MachineName, - ["service.names"] = - "FSH.Starter.WebApi.Host", //builder.Configuration["OpenTelemetrySettings:ServiceName"]!, //It's a WA Fix because the service.name tag is not completed automatically by Resource.Builder()...AddService(serviceName) https://github.com/open-telemetry/opentelemetry-dotnet/issues/2027 - ["os.description"] = System.Runtime.InteropServices.RuntimeInformation.OSDescription, - ["deployment.environment"] = builder.Environment.EnvironmentName.ToLowerInvariant() - }; - var resourceBuilder = ResourceBuilder.CreateDefault() - .AddService(serviceName: resourceServiceName, serviceVersion: resourceServiceVersion) - .AddTelemetrySdk() - //.AddEnvironmentVariableDetector() - .AddAttributes(attributes); - - #endregion region - - - builder.Logging.AddOpenTelemetry(logging => - { - logging.IncludeFormattedMessage = true; - logging.IncludeScopes = true; - logging.SetResourceBuilder(resourceBuilder); - }); - - builder.Services.AddOpenTelemetry() - .WithMetrics(metrics => - { - metrics.SetResourceBuilder(resourceBuilder) - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation() - .AddProcessInstrumentation() - .AddMeter(MetricsConstants.Todos) - .AddMeter(MetricsConstants.Catalog); - }) - .WithTracing(tracing => - { - if (builder.Environment.IsDevelopment()) - { - tracing.SetSampler(new AlwaysOnSampler()); - } - - tracing.SetResourceBuilder(resourceBuilder) - .AddAspNetCoreInstrumentation(nci => nci.RecordException = true) - .AddHttpClientInstrumentation() - .AddEntityFrameworkCoreInstrumentation(); - }); - - builder.AddOpenTelemetryExporters(); - - return builder; - } - - private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) - { - var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); - - if (useOtlpExporter) - { - builder.Services.AddOpenTelemetry().UseOtlpExporter(); - } - - // The following lines enable the Prometheus exporter (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package) - builder.Services.AddOpenTelemetry() - // BUG: Part of the workaround for https://github.com/open-telemetry/opentelemetry-dotnet-contrib/issues/1617 - .WithMetrics(metrics => metrics.AddPrometheusExporter(options => - { - options.DisableTotalNameSuffixForCounters = true; - })); - - return builder; - } - - public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) - { - builder.Services.AddHealthChecks() - // Add a default liveness check to ensure app is responsive - .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); - - return builder; - } - - public static WebApplication MapDefaultEndpoints(this WebApplication app) - { - // The following line enables the Prometheus endpoint (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package) - app.UseOpenTelemetryPrometheusScrapingEndpoint( - context => - { - if (context.Request.Path != "/metrics") return false; - return true; - }); - - // All health checks must pass for app to be considered ready to accept traffic after starting - app.MapHealthChecks("/health").AllowAnonymous(); - // Only health checks tagged with the "live" tag must pass for app to be considered alive - app.MapHealthChecks("/alive", new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") }) - .AllowAnonymous(); - - return app; - } -} diff --git a/src/aspire/service-defaults/MetricsConstants.cs b/src/aspire/service-defaults/MetricsConstants.cs deleted file mode 100644 index 57874dfe95..0000000000 --- a/src/aspire/service-defaults/MetricsConstants.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace FSH.Starter.Aspire.ServiceDefaults; -public static class MetricsConstants -{ - public const string AppName = "fullstackhero"; - public const string Todos = "Todos"; - public const string Catalog = "Catalog"; -} diff --git a/src/aspire/service-defaults/ServiceDefaults.csproj b/src/aspire/service-defaults/ServiceDefaults.csproj deleted file mode 100644 index 26553c1c7b..0000000000 --- a/src/aspire/service-defaults/ServiceDefaults.csproj +++ /dev/null @@ -1,32 +0,0 @@ - - - - FSH.Starter.Aspire.ServiceDefaults - FSH.Starter.Aspire.ServiceDefaults - net9.0 - enable - enable - true - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/global.json b/src/global.json deleted file mode 100644 index d5bf446d0c..0000000000 --- a/src/global.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "sdk": { - "version": "9.0.100", - "rollForward": "latestFeature" - } -} \ No newline at end of file diff --git a/terraform/.gitignore b/terraform/.gitignore deleted file mode 100644 index 4776104d1e..0000000000 --- a/terraform/.gitignore +++ /dev/null @@ -1,38 +0,0 @@ -# Local .terraform directories -**/.terraform/* - -# .tfstate files -*.tfstate -*.tfstate.* - -# Crash log files -crash.log -crash.*.log - -# Exclude all .tfvars files, which are likely to contain sensitive data, such as -# password, private keys, and other secrets. These should not be part of version -# control as they are data points which are potentially sensitive and subject -# to change depending on the environment. -# *.tfvars -# *.tfvars.json - -# Ignore override files as they are usually used to override resources locally and so -# are not checked in -override.tf -override.tf.json -*_override.tf -*_override.tf.json - -# Ignore transient lock info files created by terraform apply -.terraform.tfstate.lock.info - -# Include override files you do wish to add to version control using negated pattern -# !example_override.tf - -# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan -# example: *tfplan* - -# Ignore CLI configuration files -.terraformrc -terraform.rc -.terraform.lock.hcl \ No newline at end of file diff --git a/terraform/README.md b/terraform/README.md deleted file mode 100644 index 4ad56c1cb2..0000000000 --- a/terraform/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# Deploying FullStackHero .NET Starter Kit to AWS with Terraform - -## Pre-Requisites - -1. Ensure you have the latest Terraform installed on your machine. I used `Terraform v1.8.4` at the time of development. -2. You should have installed AWS CLI Tools and authenticated your machine to access AWS. - -## Bootstrapping - -If you are deploying this for the first time, navigate to `./boostrap` and run the following commands - -```terraform -terraform init -terraform plan -terraform apply --auto-approve -``` - -This will provision the required backend resources on AWS that terraform would need in the next steps. Basics this will create an Amazon S3 Bucket, and DynamoDB Table. - -## Deploy - -Navigate to `./environments/dev/` and run the following. - -``` -terraform init -terraform plan -terraform apply --auto-approve -``` - -This will deploy the following, - -- ECS Cluster and Task (.NET Web API) -- RDS PostgreSQL Database Instance -- Required Networking Components like VPC, ALB etc. - -## Destroy - -Once you are done with your testing, ensure that you delete the resources to keep your bill under control. - -```` -terraform destroy --auto-approve -``` -```` diff --git a/terraform/apps/playground/README.md b/terraform/apps/playground/README.md new file mode 100644 index 0000000000..c8e6ebf31d --- /dev/null +++ b/terraform/apps/playground/README.md @@ -0,0 +1,6 @@ +# Playground App Stack +Terraform stack for the Playground API/Blazor app. Uses shared modules from `../../modules`. + +- Env/region stacks live under `envs///` (backend.tf + *.tfvars + main.tf). +- App composition lives under `app_stack/` (wiring ECS services, ALB, RDS, Redis, S3). +- Images are built from GitHub Actions, pushed to ECR, and referenced in tfvars. diff --git a/terraform/apps/playground/app_stack/main.tf b/terraform/apps/playground/app_stack/main.tf new file mode 100644 index 0000000000..185aa81b09 --- /dev/null +++ b/terraform/apps/playground/app_stack/main.tf @@ -0,0 +1,435 @@ +################################################################################ +# Local Variables +################################################################################ + +locals { + common_tags = { + Environment = var.environment + Project = "dotnet-starter-kit" + ManagedBy = "terraform" + } + aspnetcore_environment = var.environment == "dev" ? "Development" : "Production" + name_prefix = "${var.environment}-${var.region}" + + # Container images constructed from registry, name, and tag + api_container_image = "${var.container_registry}/${var.api_image_name}:${var.container_image_tag}" + blazor_container_image = "${var.container_registry}/${var.blazor_image_name}:${var.container_image_tag}" +} + +################################################################################ +# Network +################################################################################ + +module "network" { + source = "../../../modules/network" + + name = local.name_prefix + cidr_block = var.vpc_cidr_block + + public_subnets = var.public_subnets + private_subnets = var.private_subnets + + enable_nat_gateway = var.enable_nat_gateway + single_nat_gateway = var.single_nat_gateway + + enable_s3_endpoint = var.enable_s3_endpoint + enable_ecr_endpoints = var.enable_ecr_endpoints + enable_logs_endpoint = var.enable_logs_endpoint + enable_secretsmanager_endpoint = var.enable_secretsmanager_endpoint + + enable_flow_logs = var.enable_flow_logs + flow_logs_retention_days = var.flow_logs_retention_days + + tags = local.common_tags +} + +################################################################################ +# ECS Cluster +################################################################################ + +module "ecs_cluster" { + source = "../../../modules/ecs_cluster" + + name = "${local.name_prefix}-cluster" + container_insights = var.enable_container_insights + tags = local.common_tags +} + +################################################################################ +# ALB Security Group +################################################################################ + +resource "aws_security_group" "alb" { + name = "${local.name_prefix}-alb" + description = "ALB security group" + vpc_id = module.network.vpc_id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-alb" + }) +} + +resource "aws_vpc_security_group_ingress_rule" "alb_http" { + security_group_id = aws_security_group.alb.id + description = "HTTP from anywhere" + from_port = 80 + to_port = 80 + ip_protocol = "tcp" + cidr_ipv4 = "0.0.0.0/0" +} + +resource "aws_vpc_security_group_ingress_rule" "alb_https" { + count = var.enable_https ? 1 : 0 + security_group_id = aws_security_group.alb.id + description = "HTTPS from anywhere" + from_port = 443 + to_port = 443 + ip_protocol = "tcp" + cidr_ipv4 = "0.0.0.0/0" +} + +resource "aws_vpc_security_group_egress_rule" "alb_all" { + security_group_id = aws_security_group.alb.id + description = "All outbound traffic" + ip_protocol = "-1" + cidr_ipv4 = "0.0.0.0/0" +} + +################################################################################ +# Application Load Balancer +################################################################################ + +module "alb" { + source = "../../../modules/alb" + + name = "${local.name_prefix}-alb" + subnet_ids = module.network.public_subnet_ids + security_group_id = aws_security_group.alb.id + + enable_https = var.enable_https + certificate_arn = var.acm_certificate_arn + ssl_policy = var.ssl_policy + + enable_deletion_protection = var.alb_enable_deletion_protection + idle_timeout = var.alb_idle_timeout + + access_logs_bucket = var.alb_access_logs_bucket + access_logs_prefix = var.alb_access_logs_prefix + + tags = local.common_tags +} + +################################################################################ +# S3 Bucket for Application Data +################################################################################ + +module "app_s3" { + source = "../../../modules/s3_bucket" + + name = var.app_s3_bucket_name + force_destroy = var.environment == "dev" ? true : false + versioning_enabled = var.app_s3_versioning_enabled + + enable_public_read = var.app_s3_enable_public_read + public_read_prefix = var.app_s3_public_read_prefix + + enable_cloudfront = var.app_s3_enable_cloudfront + cloudfront_price_class = var.app_s3_cloudfront_price_class + cloudfront_aliases = var.app_s3_cloudfront_aliases + cloudfront_acm_certificate_arn = var.app_s3_cloudfront_certificate_arn + + enable_intelligent_tiering = var.app_s3_enable_intelligent_tiering + lifecycle_rules = var.app_s3_lifecycle_rules + + cors_rules = var.enable_https && var.domain_name != null ? [ + { + allowed_methods = ["GET", "PUT", "POST"] + allowed_origins = ["https://${var.domain_name}"] + allowed_headers = ["*"] + expose_headers = ["ETag"] + max_age_seconds = 3600 + } + ] : [] + + tags = local.common_tags +} + +################################################################################ +# IAM Role for API Task +################################################################################ + +data "aws_iam_policy_document" "api_task_assume" { + statement { + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["ecs-tasks.amazonaws.com"] + } + } +} + +data "aws_iam_policy_document" "api_task_s3" { + statement { + sid = "AllowBucketReadWrite" + actions = [ + "s3:PutObject", + "s3:DeleteObject", + "s3:GetObject", + "s3:ListBucket" + ] + resources = [ + module.app_s3.bucket_arn, + "${module.app_s3.bucket_arn}/*" + ] + } +} + +# Note: The api_task role doesn't need secrets access because the connection string +# is injected via ECS secrets (handled by task execution role in ecs_service module). +# This policy document is kept for potential future runtime secret access needs. +data "aws_iam_policy_document" "api_task_secrets" { + count = var.db_manage_master_user_password ? 1 : 0 + + statement { + sid = "AllowSecretsAccess" + actions = [ + "secretsmanager:GetSecretValue" + ] + resources = [ + aws_secretsmanager_secret.db_connection_string[0].arn + ] + } +} + +resource "aws_iam_role" "api_task" { + name = "${var.environment}-api-task" + assume_role_policy = data.aws_iam_policy_document.api_task_assume.json + tags = local.common_tags +} + +resource "aws_iam_role_policy" "api_task_s3" { + name = "${var.environment}-api-task-s3" + role = aws_iam_role.api_task.id + policy = data.aws_iam_policy_document.api_task_s3.json +} + +resource "aws_iam_role_policy" "api_task_secrets" { + count = var.db_manage_master_user_password ? 1 : 0 + name = "${var.environment}-api-task-secrets" + role = aws_iam_role.api_task.id + policy = data.aws_iam_policy_document.api_task_secrets[0].json +} + +################################################################################ +# RDS PostgreSQL +################################################################################ + +module "rds" { + source = "../../../modules/rds_postgres" + + name = "${local.name_prefix}-postgres" + vpc_id = module.network.vpc_id + subnet_ids = module.network.private_subnet_ids + + # Use CIDR block to allow access from private subnets (ECS services) + allowed_cidr_blocks = [var.vpc_cidr_block] + + db_name = var.db_name + username = var.db_username + + manage_master_user_password = var.db_manage_master_user_password + password = var.db_manage_master_user_password ? null : var.db_password + + instance_class = var.db_instance_class + allocated_storage = var.db_allocated_storage + max_allocated_storage = var.db_max_allocated_storage + storage_type = var.db_storage_type + engine_version = var.db_engine_version + + multi_az = var.db_multi_az + backup_retention_period = var.db_backup_retention_period + deletion_protection = var.db_deletion_protection + skip_final_snapshot = var.environment == "dev" ? true : false + final_snapshot_identifier = var.environment != "dev" ? "${local.name_prefix}-postgres-final" : null + + performance_insights_enabled = var.db_enable_performance_insights + monitoring_interval = var.db_enable_enhanced_monitoring ? var.db_monitoring_interval : 0 + + tags = local.common_tags +} + +################################################################################ +# Connection String Secret (for managed password) +# When using AWS-managed password, we need to create a separate secret that +# contains the full connection string, constructed using the password from +# the RDS-managed secret. +################################################################################ + +# Read the RDS-managed secret to get the password +data "aws_secretsmanager_secret_version" "rds_password" { + count = var.db_manage_master_user_password ? 1 : 0 + secret_id = module.rds.secret_arn +} + +# Create a secret for the full connection string +resource "aws_secretsmanager_secret" "db_connection_string" { + count = var.db_manage_master_user_password ? 1 : 0 + + name = "${var.environment}-db-connection-string" + description = "Full PostgreSQL connection string for .NET application" + + tags = local.common_tags +} + +resource "aws_secretsmanager_secret_version" "db_connection_string" { + count = var.db_manage_master_user_password ? 1 : 0 + secret_id = aws_secretsmanager_secret.db_connection_string[0].id + + secret_string = "Host=${module.rds.endpoint};Port=${module.rds.port};Database=${var.db_name};Username=${var.db_username};Password=${jsondecode(data.aws_secretsmanager_secret_version.rds_password[0].secret_string)["password"]};Pooling=true;SSL Mode=Require;Trust Server Certificate=true;" +} + +locals { + # Connection string for non-managed password (directly in env var) + # Only constructed when db_password is provided (i.e., not using managed password) + db_connection_string_plain = var.db_manage_master_user_password ? "" : "Host=${module.rds.endpoint};Port=${module.rds.port};Database=${var.db_name};Username=${var.db_username};Password=${var.db_password};Pooling=true;SSL Mode=Require;Trust Server Certificate=true;" +} + +################################################################################ +# ElastiCache Redis +################################################################################ + +module "redis" { + source = "../../../modules/elasticache_redis" + + name = "${local.name_prefix}-redis" + vpc_id = module.network.vpc_id + subnet_ids = module.network.private_subnet_ids + + # Use CIDR block to allow access from private subnets (ECS services) + allowed_cidr_blocks = [var.vpc_cidr_block] + + node_type = var.redis_node_type + num_cache_clusters = var.redis_num_cache_clusters + engine_version = var.redis_engine_version + automatic_failover_enabled = var.redis_automatic_failover_enabled + transit_encryption_enabled = var.redis_transit_encryption_enabled + + tags = local.common_tags +} + +################################################################################ +# API ECS Service +################################################################################ + +module "api_service" { + source = "../../../modules/ecs_service" + + name = "${var.environment}-api" + region = var.region + cluster_arn = module.ecs_cluster.arn + container_image = local.api_container_image + container_port = var.api_container_port + cpu = var.api_cpu + memory = var.api_memory + desired_count = var.api_desired_count + + vpc_id = module.network.vpc_id + vpc_cidr_block = module.network.vpc_cidr_block + subnet_ids = module.network.private_subnet_ids + assign_public_ip = false + + listener_arn = var.enable_https ? module.alb.https_listener_arn : module.alb.http_listener_arn + listener_rule_priority = 10 + path_patterns = ["/api/*", "/scalar*", "/health*", "/swagger*", "/openapi*"] + + health_check_path = "/health/live" + health_check_healthy_threshold = var.api_health_check_healthy_threshold + deregistration_delay = var.api_deregistration_delay + + task_role_arn = aws_iam_role.api_task.arn + + enable_circuit_breaker = var.api_enable_circuit_breaker + use_fargate_spot = var.api_use_fargate_spot + + # When using managed password, connection string comes from secrets + # When not using managed password, connection string is set directly in env vars + environment_variables = merge( + { + ASPNETCORE_ENVIRONMENT = local.aspnetcore_environment + CachingOptions__Redis = module.redis.connection_string + Storage__Provider = "s3" + Storage__S3__Bucket = var.app_s3_bucket_name + Storage__S3__PublicBaseUrl = module.app_s3.cloudfront_domain_name != "" ? "https://${module.app_s3.cloudfront_domain_name}" : "" + }, + # Only set connection string in env vars when NOT using managed password + var.db_manage_master_user_password ? {} : { + DatabaseOptions__ConnectionString = local.db_connection_string_plain + }, + var.enable_https && var.domain_name != null ? { + OriginOptions__OriginUrl = "https://${var.domain_name}" + CorsOptions__AllowedOrigins__0 = "https://${var.domain_name}" + } : { + OriginOptions__OriginUrl = "http://${module.alb.dns_name}" + CorsOptions__AllowedOrigins__0 = "http://${module.alb.dns_name}" + }, + var.api_extra_environment_variables + ) + + # When using managed password, inject the full connection string from Secrets Manager + secrets = var.db_manage_master_user_password ? [ + { + name = "DatabaseOptions__ConnectionString" + valueFrom = aws_secretsmanager_secret.db_connection_string[0].arn + } + ] : [] + + tags = local.common_tags +} + +################################################################################ +# Blazor ECS Service +################################################################################ + +module "blazor_service" { + source = "../../../modules/ecs_service" + + name = "${var.environment}-blazor" + region = var.region + cluster_arn = module.ecs_cluster.arn + container_image = local.blazor_container_image + container_port = var.blazor_container_port + cpu = var.blazor_cpu + memory = var.blazor_memory + desired_count = var.blazor_desired_count + + vpc_id = module.network.vpc_id + vpc_cidr_block = module.network.vpc_cidr_block + subnet_ids = module.network.private_subnet_ids + assign_public_ip = false + + listener_arn = var.enable_https ? module.alb.https_listener_arn : module.alb.http_listener_arn + listener_rule_priority = 20 + path_patterns = ["/*"] + + health_check_path = "/health/live" + health_check_healthy_threshold = var.blazor_health_check_healthy_threshold + deregistration_delay = var.blazor_deregistration_delay + + enable_circuit_breaker = var.blazor_enable_circuit_breaker + use_fargate_spot = var.blazor_use_fargate_spot + + environment_variables = merge( + { + ASPNETCORE_ENVIRONMENT = local.aspnetcore_environment + }, + var.enable_https && var.domain_name != null ? { + Api__BaseUrl = "https://${var.domain_name}" + } : { + Api__BaseUrl = "http://${module.alb.dns_name}" + }, + var.blazor_extra_environment_variables + ) + + tags = local.common_tags +} diff --git a/terraform/apps/playground/app_stack/outputs.tf b/terraform/apps/playground/app_stack/outputs.tf new file mode 100644 index 0000000000..3e25fdf3a0 --- /dev/null +++ b/terraform/apps/playground/app_stack/outputs.tf @@ -0,0 +1,138 @@ +################################################################################ +# Network Outputs +################################################################################ + +output "vpc_id" { + description = "VPC ID." + value = module.network.vpc_id +} + +output "vpc_cidr_block" { + description = "VPC CIDR block." + value = module.network.vpc_cidr_block +} + +output "public_subnet_ids" { + description = "Public subnet IDs." + value = module.network.public_subnet_ids +} + +output "private_subnet_ids" { + description = "Private subnet IDs." + value = module.network.private_subnet_ids +} + +################################################################################ +# ALB Outputs +################################################################################ + +output "alb_arn" { + description = "ALB ARN." + value = module.alb.arn +} + +output "alb_dns_name" { + description = "ALB DNS name." + value = module.alb.dns_name +} + +output "alb_zone_id" { + description = "ALB hosted zone ID." + value = module.alb.zone_id +} + +################################################################################ +# Application URLs +################################################################################ + +output "api_url" { + description = "API URL." + value = var.enable_https && var.domain_name != null ? "https://${var.domain_name}/api" : "http://${module.alb.dns_name}/api" +} + +output "blazor_url" { + description = "Blazor UI URL." + value = var.enable_https && var.domain_name != null ? "https://${var.domain_name}" : "http://${module.alb.dns_name}" +} + +################################################################################ +# ECS Outputs +################################################################################ + +output "ecs_cluster_id" { + description = "ECS cluster ID." + value = module.ecs_cluster.id +} + +output "ecs_cluster_arn" { + description = "ECS cluster ARN." + value = module.ecs_cluster.arn +} + +output "api_service_name" { + description = "API ECS service name." + value = module.api_service.service_name +} + +output "blazor_service_name" { + description = "Blazor ECS service name." + value = module.blazor_service.service_name +} + +################################################################################ +# Database Outputs +################################################################################ + +output "rds_endpoint" { + description = "RDS endpoint." + value = module.rds.endpoint +} + +output "rds_port" { + description = "RDS port." + value = module.rds.port +} + +output "rds_secret_arn" { + description = "RDS Secrets Manager secret ARN (if manage_master_user_password is true)." + value = module.rds.secret_arn +} + +################################################################################ +# Redis Outputs +################################################################################ + +output "redis_endpoint" { + description = "Redis primary endpoint address." + value = module.redis.primary_endpoint_address +} + +output "redis_connection_string" { + description = "Redis connection string for .NET applications." + value = module.redis.connection_string + sensitive = true +} + +################################################################################ +# S3 Outputs +################################################################################ + +output "s3_bucket_name" { + description = "S3 bucket name." + value = module.app_s3.bucket_name +} + +output "s3_bucket_arn" { + description = "S3 bucket ARN." + value = module.app_s3.bucket_arn +} + +output "s3_cloudfront_domain" { + description = "CloudFront distribution domain name." + value = module.app_s3.cloudfront_domain_name +} + +output "s3_cloudfront_distribution_id" { + description = "CloudFront distribution ID." + value = module.app_s3.cloudfront_distribution_id +} diff --git a/terraform/apps/playground/app_stack/variables.tf b/terraform/apps/playground/app_stack/variables.tf new file mode 100644 index 0000000000..d94ba25666 --- /dev/null +++ b/terraform/apps/playground/app_stack/variables.tf @@ -0,0 +1,516 @@ +################################################################################ +# General Variables +################################################################################ + +variable "environment" { + type = string + description = "Environment name (dev, staging, prod)." + + validation { + condition = contains(["dev", "staging", "prod"], var.environment) + error_message = "Environment must be dev, staging, or prod." + } +} + +variable "region" { + type = string + description = "AWS region." + + validation { + condition = can(regex("^[a-z]{2}-[a-z]+-\\d$", var.region)) + error_message = "Region must be a valid AWS region identifier (e.g., us-east-1)." + } +} + +variable "domain_name" { + type = string + description = "Domain name for the application (optional)." + default = null +} + +################################################################################ +# Network Variables +################################################################################ + +variable "vpc_cidr_block" { + type = string + description = "CIDR block for the VPC." + + validation { + condition = can(cidrnetmask(var.vpc_cidr_block)) + error_message = "VPC CIDR block must be a valid CIDR notation." + } +} + +variable "public_subnets" { + description = "Public subnet definitions." + type = map(object({ + cidr_block = string + az = string + })) +} + +variable "private_subnets" { + description = "Private subnet definitions." + type = map(object({ + cidr_block = string + az = string + })) +} + +variable "enable_nat_gateway" { + type = bool + description = "Enable NAT Gateway for private subnets." + default = true +} + +variable "single_nat_gateway" { + type = bool + description = "Use a single NAT Gateway (cost savings for non-prod)." + default = true +} + +variable "enable_s3_endpoint" { + type = bool + description = "Enable S3 VPC Gateway Endpoint." + default = true +} + +variable "enable_ecr_endpoints" { + type = bool + description = "Enable ECR VPC Interface Endpoints." + default = true +} + +variable "enable_logs_endpoint" { + type = bool + description = "Enable CloudWatch Logs VPC Interface Endpoint." + default = true +} + +variable "enable_secretsmanager_endpoint" { + type = bool + description = "Enable Secrets Manager VPC Interface Endpoint." + default = false +} + +variable "enable_flow_logs" { + type = bool + description = "Enable VPC Flow Logs." + default = false +} + +variable "flow_logs_retention_days" { + type = number + description = "Flow logs retention period in days." + default = 14 +} + +################################################################################ +# ECS Cluster Variables +################################################################################ + +variable "enable_container_insights" { + type = bool + description = "Enable Container Insights for ECS cluster." + default = true +} + +################################################################################ +# ALB Variables +################################################################################ + +variable "enable_https" { + type = bool + description = "Enable HTTPS on the ALB." + default = false +} + +variable "acm_certificate_arn" { + type = string + description = "ACM certificate ARN for HTTPS (required if enable_https is true)." + default = null +} + +variable "ssl_policy" { + type = string + description = "SSL policy for the HTTPS listener." + default = "ELBSecurityPolicy-TLS13-1-2-2021-06" +} + +variable "alb_enable_deletion_protection" { + type = bool + description = "Enable deletion protection for the ALB." + default = false +} + +variable "alb_idle_timeout" { + type = number + description = "ALB idle timeout in seconds." + default = 60 +} + +variable "alb_access_logs_bucket" { + type = string + description = "S3 bucket for ALB access logs." + default = null +} + +variable "alb_access_logs_prefix" { + type = string + description = "S3 prefix for ALB access logs." + default = "alb-logs" +} + +################################################################################ +# S3 Variables +################################################################################ + +variable "app_s3_bucket_name" { + type = string + description = "S3 bucket name for application data (must be globally unique)." + + validation { + condition = can(regex("^[a-z0-9][a-z0-9.-]*[a-z0-9]$", var.app_s3_bucket_name)) + error_message = "Bucket name must contain only lowercase letters, numbers, hyphens, and periods." + } +} + +variable "app_s3_versioning_enabled" { + type = bool + description = "Enable versioning on the S3 bucket." + default = true +} + +variable "app_s3_enable_public_read" { + type = bool + description = "Whether to enable public read on uploads prefix." + default = false +} + +variable "app_s3_public_read_prefix" { + type = string + description = "Prefix to allow public read (e.g., uploads/)." + default = "uploads/" +} + +variable "app_s3_enable_cloudfront" { + type = bool + description = "Whether to provision a CloudFront distribution for the app bucket." + default = true +} + +variable "app_s3_cloudfront_price_class" { + type = string + description = "Price class for CloudFront." + default = "PriceClass_100" +} + +variable "app_s3_cloudfront_aliases" { + type = list(string) + description = "Alternative domain names (CNAMEs) for CloudFront." + default = [] +} + +variable "app_s3_cloudfront_certificate_arn" { + type = string + description = "ACM certificate ARN for CloudFront (required if using aliases)." + default = null +} + +variable "app_s3_enable_intelligent_tiering" { + type = bool + description = "Enable automatic transition to Intelligent-Tiering." + default = false +} + +variable "app_s3_lifecycle_rules" { + type = list(object({ + id = string + enabled = optional(bool, true) + prefix = optional(string, "") + expiration_days = optional(number) + noncurrent_version_expiration_days = optional(number) + abort_incomplete_multipart_upload_days = optional(number, 7) + transitions = optional(list(object({ + days = number + storage_class = string + })), []) + noncurrent_version_transitions = optional(list(object({ + days = number + storage_class = string + })), []) + })) + description = "List of lifecycle rules for the S3 bucket." + default = [] +} + +################################################################################ +# Database Variables +################################################################################ + +variable "db_name" { + type = string + description = "Database name." +} + +variable "db_username" { + type = string + description = "Database admin username." +} + +variable "db_password" { + type = string + description = "Database admin password (not used if manage_master_user_password is true)." + sensitive = true + default = null +} + +variable "db_manage_master_user_password" { + type = bool + description = "Let AWS manage the master user password in Secrets Manager." + default = false +} + +variable "db_instance_class" { + type = string + description = "RDS instance class." + default = "db.t3.micro" +} + +variable "db_allocated_storage" { + type = number + description = "Allocated storage in GB." + default = 20 +} + +variable "db_max_allocated_storage" { + type = number + description = "Maximum allocated storage for autoscaling in GB." + default = 100 +} + +variable "db_storage_type" { + type = string + description = "Storage type (gp2, gp3, io1)." + default = "gp3" +} + +variable "db_engine_version" { + type = string + description = "PostgreSQL engine version." + default = "16" +} + +variable "db_multi_az" { + type = bool + description = "Enable Multi-AZ deployment." + default = false +} + +variable "db_backup_retention_period" { + type = number + description = "Backup retention period in days." + default = 7 +} + +variable "db_deletion_protection" { + type = bool + description = "Enable deletion protection." + default = false +} + +variable "db_enable_performance_insights" { + type = bool + description = "Enable Performance Insights." + default = false +} + +variable "db_enable_enhanced_monitoring" { + type = bool + description = "Enable Enhanced Monitoring." + default = false +} + +variable "db_monitoring_interval" { + type = number + description = "Enhanced Monitoring interval in seconds." + default = 60 +} + +################################################################################ +# Redis Variables +################################################################################ + +variable "redis_node_type" { + type = string + description = "ElastiCache node type." + default = "cache.t3.micro" +} + +variable "redis_num_cache_clusters" { + type = number + description = "Number of cache clusters (nodes)." + default = 1 +} + +variable "redis_engine_version" { + type = string + description = "Redis engine version." + default = "7.1" +} + +variable "redis_automatic_failover_enabled" { + type = bool + description = "Enable automatic failover (requires num_cache_clusters >= 2)." + default = false +} + +variable "redis_transit_encryption_enabled" { + type = bool + description = "Enable in-transit encryption." + default = true +} + +################################################################################ +# Container Image Variables +################################################################################ + +variable "container_registry" { + type = string + description = "Container registry URL (e.g., ghcr.io/fullstackhero)." + default = "ghcr.io/fullstackhero" +} + +variable "container_image_tag" { + type = string + description = "Container image tag (shared across all services)." +} + +variable "api_image_name" { + type = string + description = "API container image name (without registry or tag)." + default = "fsh-playground-api" +} + +variable "blazor_image_name" { + type = string + description = "Blazor container image name (without registry or tag)." + default = "fsh-playground-blazor" +} + +################################################################################ +# API Service Variables +################################################################################ + +variable "api_container_port" { + type = number + description = "API container port." + default = 8080 +} + +variable "api_cpu" { + type = string + description = "API CPU units." + default = "256" +} + +variable "api_memory" { + type = string + description = "API memory." + default = "512" +} + +variable "api_desired_count" { + type = number + description = "Desired API task count." + default = 1 +} + +variable "api_health_check_healthy_threshold" { + type = number + description = "Number of consecutive health checks required for healthy status." + default = 2 +} + +variable "api_deregistration_delay" { + type = number + description = "Target group deregistration delay in seconds." + default = 30 +} + +variable "api_enable_circuit_breaker" { + type = bool + description = "Enable deployment circuit breaker." + default = true +} + +variable "api_use_fargate_spot" { + type = bool + description = "Use Fargate Spot capacity." + default = false +} + +variable "api_extra_environment_variables" { + type = map(string) + description = "Additional environment variables for API." + default = {} +} + +################################################################################ +# Blazor Service Variables +################################################################################ + +variable "blazor_container_port" { + type = number + description = "Blazor container port." + default = 8080 +} + +variable "blazor_cpu" { + type = string + description = "Blazor CPU units." + default = "256" +} + +variable "blazor_memory" { + type = string + description = "Blazor memory." + default = "512" +} + +variable "blazor_desired_count" { + type = number + description = "Desired Blazor task count." + default = 1 +} + +variable "blazor_health_check_healthy_threshold" { + type = number + description = "Number of consecutive health checks required for healthy status." + default = 2 +} + +variable "blazor_deregistration_delay" { + type = number + description = "Target group deregistration delay in seconds." + default = 30 +} + +variable "blazor_enable_circuit_breaker" { + type = bool + description = "Enable deployment circuit breaker." + default = true +} + +variable "blazor_use_fargate_spot" { + type = bool + description = "Use Fargate Spot capacity." + default = false +} + +variable "blazor_extra_environment_variables" { + type = map(string) + description = "Additional environment variables for Blazor." + default = {} +} diff --git a/terraform/apps/playground/app_stack/versions.tf b/terraform/apps/playground/app_stack/versions.tf new file mode 100644 index 0000000000..2ca9c8626e --- /dev/null +++ b/terraform/apps/playground/app_stack/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.80.0" + } + } +} diff --git a/terraform/apps/playground/envs/dev/us-east-1/backend.tf b/terraform/apps/playground/envs/dev/us-east-1/backend.tf new file mode 100644 index 0000000000..9b68c7a0e3 --- /dev/null +++ b/terraform/apps/playground/envs/dev/us-east-1/backend.tf @@ -0,0 +1,11 @@ +# Terraform 1.10+ uses S3 native locking via use_lockfile. +# DynamoDB is no longer required for state locking. +terraform { + backend "s3" { + bucket = "fsh-state-bucket" + key = "dev/us-east-1/terraform.tfstate" + region = "us-east-1" + encrypt = true + use_lockfile = true + } +} diff --git a/terraform/apps/playground/envs/dev/us-east-1/main.tf b/terraform/apps/playground/envs/dev/us-east-1/main.tf new file mode 100644 index 0000000000..c06e7d94da --- /dev/null +++ b/terraform/apps/playground/envs/dev/us-east-1/main.tf @@ -0,0 +1,139 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.80.0" + } + } +} + +provider "aws" { + region = var.region + + default_tags { + tags = { + Environment = var.environment + Project = "dotnet-starter-kit" + ManagedBy = "terraform" + } + } +} + +module "app" { + source = "../../../app_stack" + + environment = var.environment + region = var.region + domain_name = var.domain_name + + # Network + vpc_cidr_block = var.vpc_cidr_block + public_subnets = var.public_subnets + private_subnets = var.private_subnets + + enable_nat_gateway = var.enable_nat_gateway + single_nat_gateway = var.single_nat_gateway + + enable_s3_endpoint = var.enable_s3_endpoint + enable_ecr_endpoints = var.enable_ecr_endpoints + enable_logs_endpoint = var.enable_logs_endpoint + enable_secretsmanager_endpoint = var.enable_secretsmanager_endpoint + enable_flow_logs = var.enable_flow_logs + + # ECS + enable_container_insights = var.enable_container_insights + + # ALB + enable_https = var.enable_https + acm_certificate_arn = var.acm_certificate_arn + + # S3 + app_s3_bucket_name = var.app_s3_bucket_name + app_s3_enable_public_read = var.app_s3_enable_public_read + app_s3_enable_cloudfront = var.app_s3_enable_cloudfront + app_s3_cloudfront_price_class = var.app_s3_cloudfront_price_class + + # Database + db_name = var.db_name + db_username = var.db_username + db_password = var.db_password + db_manage_master_user_password = var.db_manage_master_user_password + db_instance_class = var.db_instance_class + db_engine_version = var.db_engine_version + + # Redis + redis_node_type = var.redis_node_type + + # Container Images + container_registry = var.container_registry + container_image_tag = var.container_image_tag + api_image_name = var.api_image_name + blazor_image_name = var.blazor_image_name + + # API Service + api_container_port = var.api_container_port + api_cpu = var.api_cpu + api_memory = var.api_memory + api_desired_count = var.api_desired_count + api_enable_circuit_breaker = var.api_enable_circuit_breaker + api_use_fargate_spot = var.api_use_fargate_spot + + # Blazor Service + blazor_container_port = var.blazor_container_port + blazor_cpu = var.blazor_cpu + blazor_memory = var.blazor_memory + blazor_desired_count = var.blazor_desired_count + blazor_enable_circuit_breaker = var.blazor_enable_circuit_breaker + blazor_use_fargate_spot = var.blazor_use_fargate_spot +} + +################################################################################ +# Outputs +################################################################################ + +output "vpc_id" { + description = "VPC ID." + value = module.app.vpc_id +} + +output "alb_dns_name" { + description = "ALB DNS name." + value = module.app.alb_dns_name +} + +output "api_url" { + description = "API URL." + value = module.app.api_url +} + +output "blazor_url" { + description = "Blazor URL." + value = module.app.blazor_url +} + +output "rds_endpoint" { + description = "RDS endpoint." + value = module.app.rds_endpoint +} + +output "rds_secret_arn" { + description = "RDS secret ARN (if using managed password)." + value = module.app.rds_secret_arn +} + +output "redis_endpoint" { + description = "Redis endpoint." + value = module.app.redis_endpoint +} + +output "s3_bucket_name" { + description = "S3 bucket name." + value = module.app.s3_bucket_name +} + +output "s3_cloudfront_domain" { + description = "CloudFront domain." + value = module.app.s3_cloudfront_domain != "" ? "https://${module.app.s3_cloudfront_domain}" : "" +} diff --git a/terraform/apps/playground/envs/dev/us-east-1/terraform.tfvars b/terraform/apps/playground/envs/dev/us-east-1/terraform.tfvars new file mode 100644 index 0000000000..9f6a04a3c0 --- /dev/null +++ b/terraform/apps/playground/envs/dev/us-east-1/terraform.tfvars @@ -0,0 +1,86 @@ +################################################################################ +# Environment Settings +################################################################################ + +environment = "dev" +region = "us-east-1" + +################################################################################ +# Network Configuration +################################################################################ + +vpc_cidr_block = "10.10.0.0/16" + +public_subnets = { + a = { + cidr_block = "10.10.0.0/24" + az = "us-east-1a" + } + b = { + cidr_block = "10.10.1.0/24" + az = "us-east-1b" + } +} + +private_subnets = { + a = { + cidr_block = "10.10.10.0/24" + az = "us-east-1a" + } + b = { + cidr_block = "10.10.11.0/24" + az = "us-east-1b" + } +} + +# Use single NAT gateway in dev to save costs +enable_nat_gateway = true +single_nat_gateway = true + +# VPC Endpoints (reduce data transfer costs) +enable_s3_endpoint = true +enable_ecr_endpoints = true +enable_logs_endpoint = true + +################################################################################ +# S3 Configuration +################################################################################ + +app_s3_bucket_name = "dev-fsh-app-bucket" +app_s3_enable_public_read = false +app_s3_enable_cloudfront = true + +################################################################################ +# Database Configuration +################################################################################ + +db_name = "fshdb" +db_username = "fshadmin" + +# Option 1: Use AWS Secrets Manager for password (recommended) +db_manage_master_user_password = true + +# Option 2: Provide password directly (not recommended, use TF_VAR_db_password env var) +# db_password = "your-secure-password" + +################################################################################ +# Container Images +################################################################################ + +# Single tag for all container images (typically a git commit SHA or version) +container_image_tag = "1d2c9f9d3b85bb86229f1bc1b9cd8196054f2166" + +# Optional: Override defaults if needed +# container_registry = "ghcr.io/fullstackhero" +# api_image_name = "fsh-playground-api" +# blazor_image_name = "fsh-playground-blazor" + +################################################################################ +# Service Configuration (dev defaults use Fargate Spot for cost savings) +################################################################################ + +api_desired_count = 1 +api_use_fargate_spot = true + +blazor_desired_count = 1 +blazor_use_fargate_spot = true diff --git a/terraform/apps/playground/envs/dev/us-east-1/variables.tf b/terraform/apps/playground/envs/dev/us-east-1/variables.tf new file mode 100644 index 0000000000..6886ee1868 --- /dev/null +++ b/terraform/apps/playground/envs/dev/us-east-1/variables.tf @@ -0,0 +1,297 @@ +################################################################################ +# General Variables +################################################################################ + +variable "environment" { + type = string + description = "Environment name." + default = "dev" +} + +variable "region" { + type = string + description = "AWS region." + default = "us-east-1" +} + +variable "domain_name" { + type = string + description = "Domain name for the application (optional)." + default = null +} + +################################################################################ +# Network Variables +################################################################################ + +variable "vpc_cidr_block" { + type = string + description = "CIDR block for the VPC." +} + +variable "public_subnets" { + description = "Public subnet definitions." + type = map(object({ + cidr_block = string + az = string + })) +} + +variable "private_subnets" { + description = "Private subnet definitions." + type = map(object({ + cidr_block = string + az = string + })) +} + +variable "enable_nat_gateway" { + type = bool + description = "Enable NAT Gateway." + default = true +} + +variable "single_nat_gateway" { + type = bool + description = "Use single NAT Gateway (cost savings)." + default = true +} + +variable "enable_s3_endpoint" { + type = bool + description = "Enable S3 VPC Gateway Endpoint." + default = true +} + +variable "enable_ecr_endpoints" { + type = bool + description = "Enable ECR VPC Interface Endpoints." + default = true +} + +variable "enable_logs_endpoint" { + type = bool + description = "Enable CloudWatch Logs VPC Interface Endpoint." + default = true +} + +variable "enable_secretsmanager_endpoint" { + type = bool + description = "Enable Secrets Manager VPC Interface Endpoint." + default = false +} + +variable "enable_flow_logs" { + type = bool + description = "Enable VPC Flow Logs." + default = false +} + +################################################################################ +# ECS Variables +################################################################################ + +variable "enable_container_insights" { + type = bool + description = "Enable Container Insights." + default = false +} + +################################################################################ +# ALB Variables +################################################################################ + +variable "enable_https" { + type = bool + description = "Enable HTTPS on ALB." + default = false +} + +variable "acm_certificate_arn" { + type = string + description = "ACM certificate ARN for HTTPS." + default = null +} + +################################################################################ +# S3 Variables +################################################################################ + +variable "app_s3_bucket_name" { + type = string + description = "S3 bucket for application data." +} + +variable "app_s3_enable_public_read" { + type = bool + description = "Whether to enable public read on uploads prefix." + default = false +} + +variable "app_s3_enable_cloudfront" { + type = bool + description = "Whether to enable CloudFront in front of the app bucket." + default = true +} + +variable "app_s3_cloudfront_price_class" { + type = string + description = "Price class for CloudFront distribution." + default = "PriceClass_100" +} + +################################################################################ +# Database Variables +################################################################################ + +variable "db_name" { + type = string + description = "Database name." +} + +variable "db_username" { + type = string + description = "Database admin username." +} + +variable "db_password" { + type = string + description = "Database admin password." + sensitive = true + default = null +} + +variable "db_manage_master_user_password" { + type = bool + description = "Let AWS manage the master user password in Secrets Manager." + default = false +} + +variable "db_instance_class" { + type = string + description = "RDS instance class." + default = "db.t3.micro" +} + +variable "db_engine_version" { + type = string + description = "PostgreSQL engine version." + default = "16" +} + +################################################################################ +# Redis Variables +################################################################################ + +variable "redis_node_type" { + type = string + description = "ElastiCache node type." + default = "cache.t3.micro" +} + +################################################################################ +# Container Image Variables +################################################################################ + +variable "container_registry" { + type = string + description = "Container registry URL." + default = "ghcr.io/fullstackhero" +} + +variable "container_image_tag" { + type = string + description = "Container image tag (shared across all services)." +} + +variable "api_image_name" { + type = string + description = "API container image name." + default = "fsh-playground-api" +} + +variable "blazor_image_name" { + type = string + description = "Blazor container image name." + default = "fsh-playground-blazor" +} + +################################################################################ +# API Service Variables +################################################################################ + +variable "api_container_port" { + type = number + description = "API container port." + default = 8080 +} + +variable "api_cpu" { + type = string + description = "API CPU units." + default = "256" +} + +variable "api_memory" { + type = string + description = "API memory." + default = "512" +} + +variable "api_desired_count" { + type = number + description = "Desired API task count." + default = 1 +} + +variable "api_enable_circuit_breaker" { + type = bool + description = "Enable deployment circuit breaker." + default = true +} + +variable "api_use_fargate_spot" { + type = bool + description = "Use Fargate Spot capacity." + default = true +} + +################################################################################ +# Blazor Service Variables +################################################################################ + +variable "blazor_container_port" { + type = number + description = "Blazor container port." + default = 8080 +} + +variable "blazor_cpu" { + type = string + description = "Blazor CPU units." + default = "256" +} + +variable "blazor_memory" { + type = string + description = "Blazor memory." + default = "512" +} + +variable "blazor_desired_count" { + type = number + description = "Desired Blazor task count." + default = 1 +} + +variable "blazor_enable_circuit_breaker" { + type = bool + description = "Enable deployment circuit breaker." + default = true +} + +variable "blazor_use_fargate_spot" { + type = bool + description = "Use Fargate Spot capacity." + default = true +} diff --git a/terraform/apps/playground/envs/prod/us-east-1/backend.tf b/terraform/apps/playground/envs/prod/us-east-1/backend.tf new file mode 100644 index 0000000000..939db5fc8e --- /dev/null +++ b/terraform/apps/playground/envs/prod/us-east-1/backend.tf @@ -0,0 +1,11 @@ +# Terraform 1.10+ uses S3 native locking via use_lockfile. +# DynamoDB is no longer required for state locking. +terraform { + backend "s3" { + bucket = "fsh-state-bucket" + key = "prod/us-east-1/terraform.tfstate" + region = "us-east-1" + encrypt = true + use_lockfile = true + } +} diff --git a/terraform/apps/playground/envs/prod/us-east-1/main.tf b/terraform/apps/playground/envs/prod/us-east-1/main.tf new file mode 100644 index 0000000000..1a673644d5 --- /dev/null +++ b/terraform/apps/playground/envs/prod/us-east-1/main.tf @@ -0,0 +1,162 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.80.0" + } + } +} + +provider "aws" { + region = var.region + + default_tags { + tags = { + Environment = var.environment + Project = "dotnet-starter-kit" + ManagedBy = "terraform" + } + } +} + +module "app" { + source = "../../../app_stack" + + environment = var.environment + region = var.region + domain_name = var.domain_name + + # Network + vpc_cidr_block = var.vpc_cidr_block + public_subnets = var.public_subnets + private_subnets = var.private_subnets + + enable_nat_gateway = var.enable_nat_gateway + single_nat_gateway = var.single_nat_gateway + + enable_s3_endpoint = var.enable_s3_endpoint + enable_ecr_endpoints = var.enable_ecr_endpoints + enable_logs_endpoint = var.enable_logs_endpoint + enable_secretsmanager_endpoint = var.enable_secretsmanager_endpoint + enable_flow_logs = var.enable_flow_logs + flow_logs_retention_days = var.flow_logs_retention_days + + # ECS + enable_container_insights = var.enable_container_insights + + # ALB + enable_https = var.enable_https + acm_certificate_arn = var.acm_certificate_arn + alb_enable_deletion_protection = var.alb_enable_deletion_protection + + # S3 + app_s3_bucket_name = var.app_s3_bucket_name + app_s3_versioning_enabled = var.app_s3_versioning_enabled + app_s3_enable_public_read = var.app_s3_enable_public_read + app_s3_enable_cloudfront = var.app_s3_enable_cloudfront + app_s3_cloudfront_price_class = var.app_s3_cloudfront_price_class + app_s3_enable_intelligent_tiering = var.app_s3_enable_intelligent_tiering + + # Database + db_name = var.db_name + db_username = var.db_username + db_password = var.db_password + db_manage_master_user_password = var.db_manage_master_user_password + db_instance_class = var.db_instance_class + db_allocated_storage = var.db_allocated_storage + db_max_allocated_storage = var.db_max_allocated_storage + db_engine_version = var.db_engine_version + db_multi_az = var.db_multi_az + db_backup_retention_period = var.db_backup_retention_period + db_deletion_protection = var.db_deletion_protection + db_enable_performance_insights = var.db_enable_performance_insights + db_enable_enhanced_monitoring = var.db_enable_enhanced_monitoring + + # Redis + redis_node_type = var.redis_node_type + redis_num_cache_clusters = var.redis_num_cache_clusters + redis_automatic_failover_enabled = var.redis_automatic_failover_enabled + + # Container Images + container_registry = var.container_registry + container_image_tag = var.container_image_tag + api_image_name = var.api_image_name + blazor_image_name = var.blazor_image_name + + # API Service + api_container_port = var.api_container_port + api_cpu = var.api_cpu + api_memory = var.api_memory + api_desired_count = var.api_desired_count + api_enable_circuit_breaker = var.api_enable_circuit_breaker + api_use_fargate_spot = var.api_use_fargate_spot + + # Blazor Service + blazor_container_port = var.blazor_container_port + blazor_cpu = var.blazor_cpu + blazor_memory = var.blazor_memory + blazor_desired_count = var.blazor_desired_count + blazor_enable_circuit_breaker = var.blazor_enable_circuit_breaker + blazor_use_fargate_spot = var.blazor_use_fargate_spot +} + +################################################################################ +# Outputs +################################################################################ + +output "vpc_id" { + description = "VPC ID." + value = module.app.vpc_id +} + +output "alb_dns_name" { + description = "ALB DNS name." + value = module.app.alb_dns_name +} + +output "alb_zone_id" { + description = "ALB hosted zone ID (for Route53 alias records)." + value = module.app.alb_zone_id +} + +output "api_url" { + description = "API URL." + value = module.app.api_url +} + +output "blazor_url" { + description = "Blazor URL." + value = module.app.blazor_url +} + +output "rds_endpoint" { + description = "RDS endpoint." + value = module.app.rds_endpoint +} + +output "rds_secret_arn" { + description = "RDS secret ARN (if using managed password)." + value = module.app.rds_secret_arn +} + +output "redis_endpoint" { + description = "Redis endpoint." + value = module.app.redis_endpoint +} + +output "s3_bucket_name" { + description = "S3 bucket name." + value = module.app.s3_bucket_name +} + +output "s3_cloudfront_domain" { + description = "CloudFront domain." + value = module.app.s3_cloudfront_domain != "" ? "https://${module.app.s3_cloudfront_domain}" : "" +} + +output "s3_cloudfront_distribution_id" { + description = "CloudFront distribution ID (for invalidations)." + value = module.app.s3_cloudfront_distribution_id +} diff --git a/terraform/apps/playground/envs/prod/us-east-1/prod.us-east-1.tfvars b/terraform/apps/playground/envs/prod/us-east-1/prod.us-east-1.tfvars new file mode 100644 index 0000000000..cf60e5b05c --- /dev/null +++ b/terraform/apps/playground/envs/prod/us-east-1/prod.us-east-1.tfvars @@ -0,0 +1,44 @@ +environment = "prod" +region = "us-east-1" + +vpc_cidr_block = "10.30.0.0/16" + +public_subnets = { + a = { + cidr_block = "10.30.0.0/24" + az = "us-east-1a" + } + b = { + cidr_block = "10.30.1.0/24" + az = "us-east-1b" + } +} + +private_subnets = { + a = { + cidr_block = "10.30.10.0/24" + az = "us-east-1a" + } + b = { + cidr_block = "10.30.11.0/24" + az = "us-east-1b" + } +} + +app_s3_bucket_name = "CHANGE_ME-app-prod-us-east-1" + +db_name = "fshdb" +db_username = "fshadmin" +db_password = "CHANGE_ME_STRONG_PASSWORD" + +api_container_image = "CHANGE_ME_API_IMAGE" +api_container_port = 8080 +api_cpu = "512" +api_memory = "1024" +api_desired_count = 3 + +blazor_container_image = "CHANGE_ME_BLAZOR_IMAGE" +blazor_container_port = 8080 +blazor_cpu = "512" +blazor_memory = "1024" +blazor_desired_count = 3 diff --git a/terraform/apps/playground/envs/prod/us-east-1/terraform.tfvars b/terraform/apps/playground/envs/prod/us-east-1/terraform.tfvars new file mode 100644 index 0000000000..c8ecf203c3 --- /dev/null +++ b/terraform/apps/playground/envs/prod/us-east-1/terraform.tfvars @@ -0,0 +1,127 @@ +################################################################################ +# Environment Settings +################################################################################ + +environment = "prod" +region = "us-east-1" + +# Configure with your production domain +# domain_name = "app.example.com" +# enable_https = true +# acm_certificate_arn = "arn:aws:acm:us-east-1:ACCOUNT_ID:certificate/CERT_ID" + +################################################################################ +# Network Configuration +################################################################################ + +vpc_cidr_block = "10.30.0.0/16" + +public_subnets = { + a = { + cidr_block = "10.30.0.0/24" + az = "us-east-1a" + } + b = { + cidr_block = "10.30.1.0/24" + az = "us-east-1b" + } + c = { + cidr_block = "10.30.2.0/24" + az = "us-east-1c" + } +} + +private_subnets = { + a = { + cidr_block = "10.30.10.0/24" + az = "us-east-1a" + } + b = { + cidr_block = "10.30.11.0/24" + az = "us-east-1b" + } + c = { + cidr_block = "10.30.12.0/24" + az = "us-east-1c" + } +} + +# Production uses NAT gateway per AZ for high availability +single_nat_gateway = false + +# VPC Endpoints for security and performance +enable_s3_endpoint = true +enable_ecr_endpoints = true +enable_logs_endpoint = true +enable_secretsmanager_endpoint = true + +# Enable flow logs for compliance and security auditing +enable_flow_logs = true +flow_logs_retention_days = 90 + +################################################################################ +# S3 Configuration +################################################################################ + +app_s3_bucket_name = "prod-fsh-app-bucket" +app_s3_versioning_enabled = true +app_s3_enable_public_read = false +app_s3_enable_cloudfront = true +app_s3_cloudfront_price_class = "PriceClass_200" +app_s3_enable_intelligent_tiering = true + +################################################################################ +# Database Configuration +################################################################################ + +db_name = "fshdb" +db_username = "fshadmin" + +# Use AWS Secrets Manager for password (mandatory for production) +db_manage_master_user_password = true + +# Production database settings +db_instance_class = "db.t3.medium" +db_allocated_storage = 50 +db_max_allocated_storage = 200 +db_multi_az = true +db_backup_retention_period = 30 +db_deletion_protection = true +db_enable_performance_insights = true +db_enable_enhanced_monitoring = true + +################################################################################ +# Redis Configuration +################################################################################ + +redis_node_type = "cache.t3.medium" +redis_num_cache_clusters = 2 +redis_automatic_failover_enabled = true + +################################################################################ +# Container Images +################################################################################ + +# Single tag for all container images +container_image_tag = "latest" + +# Optional: Override defaults if needed +# container_registry = "ghcr.io/fullstackhero" +# api_image_name = "fsh-playground-api" +# blazor_image_name = "fsh-playground-blazor" + +################################################################################ +# Service Configuration (Production - no Spot for stability) +################################################################################ + +api_desired_count = 3 +api_use_fargate_spot = false + +blazor_desired_count = 3 +blazor_use_fargate_spot = false + +# Enable Container Insights for full observability +enable_container_insights = true + +# ALB protection +alb_enable_deletion_protection = true diff --git a/terraform/apps/playground/envs/prod/us-east-1/variables.tf b/terraform/apps/playground/envs/prod/us-east-1/variables.tf new file mode 100644 index 0000000000..32bc44eee0 --- /dev/null +++ b/terraform/apps/playground/envs/prod/us-east-1/variables.tf @@ -0,0 +1,375 @@ +################################################################################ +# General Variables +################################################################################ + +variable "environment" { + type = string + description = "Environment name." + default = "prod" +} + +variable "region" { + type = string + description = "AWS region." + default = "us-east-1" +} + +variable "domain_name" { + type = string + description = "Domain name for the application." + default = null +} + +################################################################################ +# Network Variables +################################################################################ + +variable "vpc_cidr_block" { + type = string + description = "CIDR block for the VPC." +} + +variable "public_subnets" { + description = "Public subnet definitions." + type = map(object({ + cidr_block = string + az = string + })) +} + +variable "private_subnets" { + description = "Private subnet definitions." + type = map(object({ + cidr_block = string + az = string + })) +} + +variable "enable_nat_gateway" { + type = bool + description = "Enable NAT Gateway." + default = true +} + +variable "single_nat_gateway" { + type = bool + description = "Use single NAT Gateway." + default = false +} + +variable "enable_s3_endpoint" { + type = bool + description = "Enable S3 VPC Gateway Endpoint." + default = true +} + +variable "enable_ecr_endpoints" { + type = bool + description = "Enable ECR VPC Interface Endpoints." + default = true +} + +variable "enable_logs_endpoint" { + type = bool + description = "Enable CloudWatch Logs VPC Interface Endpoint." + default = true +} + +variable "enable_secretsmanager_endpoint" { + type = bool + description = "Enable Secrets Manager VPC Interface Endpoint." + default = true +} + +variable "enable_flow_logs" { + type = bool + description = "Enable VPC Flow Logs." + default = true +} + +variable "flow_logs_retention_days" { + type = number + description = "Flow logs retention period in days." + default = 90 +} + +################################################################################ +# ECS Variables +################################################################################ + +variable "enable_container_insights" { + type = bool + description = "Enable Container Insights." + default = true +} + +################################################################################ +# ALB Variables +################################################################################ + +variable "enable_https" { + type = bool + description = "Enable HTTPS on ALB." + default = true +} + +variable "acm_certificate_arn" { + type = string + description = "ACM certificate ARN for HTTPS." + default = null +} + +variable "alb_enable_deletion_protection" { + type = bool + description = "Enable deletion protection for ALB." + default = true +} + +################################################################################ +# S3 Variables +################################################################################ + +variable "app_s3_bucket_name" { + type = string + description = "S3 bucket for application data." +} + +variable "app_s3_versioning_enabled" { + type = bool + description = "Enable versioning on S3 bucket." + default = true +} + +variable "app_s3_enable_public_read" { + type = bool + description = "Whether to enable public read on uploads prefix." + default = false +} + +variable "app_s3_enable_cloudfront" { + type = bool + description = "Whether to enable CloudFront in front of the app bucket." + default = true +} + +variable "app_s3_cloudfront_price_class" { + type = string + description = "Price class for CloudFront distribution." + default = "PriceClass_200" +} + +variable "app_s3_enable_intelligent_tiering" { + type = bool + description = "Enable automatic transition to Intelligent-Tiering." + default = true +} + +################################################################################ +# Database Variables +################################################################################ + +variable "db_name" { + type = string + description = "Database name." +} + +variable "db_username" { + type = string + description = "Database admin username." +} + +variable "db_password" { + type = string + description = "Database admin password." + sensitive = true + default = null +} + +variable "db_manage_master_user_password" { + type = bool + description = "Let AWS manage the master user password in Secrets Manager." + default = true +} + +variable "db_instance_class" { + type = string + description = "RDS instance class." + default = "db.t3.medium" +} + +variable "db_allocated_storage" { + type = number + description = "Allocated storage in GB." + default = 50 +} + +variable "db_max_allocated_storage" { + type = number + description = "Maximum allocated storage for autoscaling in GB." + default = 200 +} + +variable "db_engine_version" { + type = string + description = "PostgreSQL engine version." + default = "16" +} + +variable "db_multi_az" { + type = bool + description = "Enable Multi-AZ deployment." + default = true +} + +variable "db_backup_retention_period" { + type = number + description = "Backup retention period in days." + default = 30 +} + +variable "db_deletion_protection" { + type = bool + description = "Enable deletion protection." + default = true +} + +variable "db_enable_performance_insights" { + type = bool + description = "Enable Performance Insights." + default = true +} + +variable "db_enable_enhanced_monitoring" { + type = bool + description = "Enable Enhanced Monitoring." + default = true +} + +################################################################################ +# Redis Variables +################################################################################ + +variable "redis_node_type" { + type = string + description = "ElastiCache node type." + default = "cache.t3.medium" +} + +variable "redis_num_cache_clusters" { + type = number + description = "Number of cache clusters (nodes)." + default = 2 +} + +variable "redis_automatic_failover_enabled" { + type = bool + description = "Enable automatic failover." + default = true +} + +################################################################################ +# Container Image Variables +################################################################################ + +variable "container_registry" { + type = string + description = "Container registry URL." + default = "ghcr.io/fullstackhero" +} + +variable "container_image_tag" { + type = string + description = "Container image tag (shared across all services)." +} + +variable "api_image_name" { + type = string + description = "API container image name." + default = "fsh-playground-api" +} + +variable "blazor_image_name" { + type = string + description = "Blazor container image name." + default = "fsh-playground-blazor" +} + +################################################################################ +# API Service Variables +################################################################################ + +variable "api_container_port" { + type = number + description = "API container port." + default = 8080 +} + +variable "api_cpu" { + type = string + description = "API CPU units." + default = "1024" +} + +variable "api_memory" { + type = string + description = "API memory." + default = "2048" +} + +variable "api_desired_count" { + type = number + description = "Desired API task count." + default = 3 +} + +variable "api_enable_circuit_breaker" { + type = bool + description = "Enable deployment circuit breaker." + default = true +} + +variable "api_use_fargate_spot" { + type = bool + description = "Use Fargate Spot capacity." + default = false +} + +################################################################################ +# Blazor Service Variables +################################################################################ + +variable "blazor_container_port" { + type = number + description = "Blazor container port." + default = 8080 +} + +variable "blazor_cpu" { + type = string + description = "Blazor CPU units." + default = "1024" +} + +variable "blazor_memory" { + type = string + description = "Blazor memory." + default = "2048" +} + +variable "blazor_desired_count" { + type = number + description = "Desired Blazor task count." + default = 3 +} + +variable "blazor_enable_circuit_breaker" { + type = bool + description = "Enable deployment circuit breaker." + default = true +} + +variable "blazor_use_fargate_spot" { + type = bool + description = "Use Fargate Spot capacity." + default = false +} diff --git a/terraform/apps/playground/envs/staging/us-east-1/backend.tf b/terraform/apps/playground/envs/staging/us-east-1/backend.tf new file mode 100644 index 0000000000..e9f0053441 --- /dev/null +++ b/terraform/apps/playground/envs/staging/us-east-1/backend.tf @@ -0,0 +1,11 @@ +# Terraform 1.10+ uses S3 native locking via use_lockfile. +# DynamoDB is no longer required for state locking. +terraform { + backend "s3" { + bucket = "fsh-state-bucket" + key = "staging/us-east-1/terraform.tfstate" + region = "us-east-1" + encrypt = true + use_lockfile = true + } +} diff --git a/terraform/apps/playground/envs/staging/us-east-1/main.tf b/terraform/apps/playground/envs/staging/us-east-1/main.tf new file mode 100644 index 0000000000..609aa1c722 --- /dev/null +++ b/terraform/apps/playground/envs/staging/us-east-1/main.tf @@ -0,0 +1,144 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.80.0" + } + } +} + +provider "aws" { + region = var.region + + default_tags { + tags = { + Environment = var.environment + Project = "dotnet-starter-kit" + ManagedBy = "terraform" + } + } +} + +module "app" { + source = "../../../app_stack" + + environment = var.environment + region = var.region + domain_name = var.domain_name + + # Network + vpc_cidr_block = var.vpc_cidr_block + public_subnets = var.public_subnets + private_subnets = var.private_subnets + + enable_nat_gateway = var.enable_nat_gateway + single_nat_gateway = var.single_nat_gateway + + enable_s3_endpoint = var.enable_s3_endpoint + enable_ecr_endpoints = var.enable_ecr_endpoints + enable_logs_endpoint = var.enable_logs_endpoint + enable_secretsmanager_endpoint = var.enable_secretsmanager_endpoint + enable_flow_logs = var.enable_flow_logs + + # ECS + enable_container_insights = var.enable_container_insights + + # ALB + enable_https = var.enable_https + acm_certificate_arn = var.acm_certificate_arn + + # S3 + app_s3_bucket_name = var.app_s3_bucket_name + app_s3_enable_public_read = var.app_s3_enable_public_read + app_s3_enable_cloudfront = var.app_s3_enable_cloudfront + app_s3_cloudfront_price_class = var.app_s3_cloudfront_price_class + + # Database + db_name = var.db_name + db_username = var.db_username + db_password = var.db_password + db_manage_master_user_password = var.db_manage_master_user_password + db_instance_class = var.db_instance_class + db_engine_version = var.db_engine_version + db_multi_az = var.db_multi_az + db_deletion_protection = var.db_deletion_protection + db_enable_performance_insights = var.db_enable_performance_insights + + # Redis + redis_node_type = var.redis_node_type + redis_num_cache_clusters = var.redis_num_cache_clusters + redis_automatic_failover_enabled = var.redis_automatic_failover_enabled + + # Container Images + container_registry = var.container_registry + container_image_tag = var.container_image_tag + api_image_name = var.api_image_name + blazor_image_name = var.blazor_image_name + + # API Service + api_container_port = var.api_container_port + api_cpu = var.api_cpu + api_memory = var.api_memory + api_desired_count = var.api_desired_count + api_enable_circuit_breaker = var.api_enable_circuit_breaker + api_use_fargate_spot = var.api_use_fargate_spot + + # Blazor Service + blazor_container_port = var.blazor_container_port + blazor_cpu = var.blazor_cpu + blazor_memory = var.blazor_memory + blazor_desired_count = var.blazor_desired_count + blazor_enable_circuit_breaker = var.blazor_enable_circuit_breaker + blazor_use_fargate_spot = var.blazor_use_fargate_spot +} + +################################################################################ +# Outputs +################################################################################ + +output "vpc_id" { + description = "VPC ID." + value = module.app.vpc_id +} + +output "alb_dns_name" { + description = "ALB DNS name." + value = module.app.alb_dns_name +} + +output "api_url" { + description = "API URL." + value = module.app.api_url +} + +output "blazor_url" { + description = "Blazor URL." + value = module.app.blazor_url +} + +output "rds_endpoint" { + description = "RDS endpoint." + value = module.app.rds_endpoint +} + +output "rds_secret_arn" { + description = "RDS secret ARN (if using managed password)." + value = module.app.rds_secret_arn +} + +output "redis_endpoint" { + description = "Redis endpoint." + value = module.app.redis_endpoint +} + +output "s3_bucket_name" { + description = "S3 bucket name." + value = module.app.s3_bucket_name +} + +output "s3_cloudfront_domain" { + description = "CloudFront domain." + value = module.app.s3_cloudfront_domain != "" ? "https://${module.app.s3_cloudfront_domain}" : "" +} diff --git a/terraform/apps/playground/envs/staging/us-east-1/staging.us-east-1.tfvars b/terraform/apps/playground/envs/staging/us-east-1/staging.us-east-1.tfvars new file mode 100644 index 0000000000..7932f31368 --- /dev/null +++ b/terraform/apps/playground/envs/staging/us-east-1/staging.us-east-1.tfvars @@ -0,0 +1,44 @@ +environment = "staging" +region = "us-east-1" + +vpc_cidr_block = "10.20.0.0/16" + +public_subnets = { + a = { + cidr_block = "10.20.0.0/24" + az = "us-east-1a" + } + b = { + cidr_block = "10.20.1.0/24" + az = "us-east-1b" + } +} + +private_subnets = { + a = { + cidr_block = "10.20.10.0/24" + az = "us-east-1a" + } + b = { + cidr_block = "10.20.11.0/24" + az = "us-east-1b" + } +} + +app_s3_bucket_name = "CHANGE_ME-app-staging-us-east-1" + +db_name = "fshdb" +db_username = "fshadmin" +db_password = "CHANGE_ME_STRONG_PASSWORD" + +api_container_image = "CHANGE_ME_API_IMAGE" +api_container_port = 8080 +api_cpu = "256" +api_memory = "512" +api_desired_count = 2 + +blazor_container_image = "CHANGE_ME_BLAZOR_IMAGE" +blazor_container_port = 8080 +blazor_cpu = "256" +blazor_memory = "512" +blazor_desired_count = 2 diff --git a/terraform/apps/playground/envs/staging/us-east-1/terraform.tfvars b/terraform/apps/playground/envs/staging/us-east-1/terraform.tfvars new file mode 100644 index 0000000000..3addd3bfb5 --- /dev/null +++ b/terraform/apps/playground/envs/staging/us-east-1/terraform.tfvars @@ -0,0 +1,105 @@ +################################################################################ +# Environment Settings +################################################################################ + +environment = "staging" +region = "us-east-1" + +# Uncomment to enable HTTPS with a custom domain +# domain_name = "staging.example.com" +# enable_https = true +# acm_certificate_arn = "arn:aws:acm:us-east-1:ACCOUNT_ID:certificate/CERT_ID" + +################################################################################ +# Network Configuration +################################################################################ + +vpc_cidr_block = "10.20.0.0/16" + +public_subnets = { + a = { + cidr_block = "10.20.0.0/24" + az = "us-east-1a" + } + b = { + cidr_block = "10.20.1.0/24" + az = "us-east-1b" + } +} + +private_subnets = { + a = { + cidr_block = "10.20.10.0/24" + az = "us-east-1a" + } + b = { + cidr_block = "10.20.11.0/24" + az = "us-east-1b" + } +} + +# Use single NAT gateway in staging to reduce costs +single_nat_gateway = true + +# VPC Endpoints +enable_s3_endpoint = true +enable_ecr_endpoints = true +enable_logs_endpoint = true +enable_secretsmanager_endpoint = true + +# Enable flow logs for audit +enable_flow_logs = true + +################################################################################ +# S3 Configuration +################################################################################ + +app_s3_bucket_name = "staging-fsh-app-bucket" +app_s3_enable_public_read = false +app_s3_enable_cloudfront = true + +################################################################################ +# Database Configuration +################################################################################ + +db_name = "fshdb" +db_username = "fshadmin" + +# Use AWS Secrets Manager for password (recommended) +db_manage_master_user_password = true + +# Staging uses a larger instance class +db_instance_class = "db.t3.small" +db_enable_performance_insights = true +db_deletion_protection = true + +################################################################################ +# Redis Configuration +################################################################################ + +redis_node_type = "cache.t3.small" + +################################################################################ +# Container Images +################################################################################ + +# Single tag for all container images +container_image_tag = "staging" + +# Optional: Override defaults if needed +# container_registry = "ghcr.io/fullstackhero" +# api_image_name = "fsh-playground-api" +# blazor_image_name = "fsh-playground-blazor" + +################################################################################ +# Service Configuration +################################################################################ + +api_desired_count = 2 +api_use_fargate_spot = true + +blazor_desired_count = 2 +blazor_use_fargate_spot = true + +# Enable Container Insights for monitoring +enable_container_insights = true diff --git a/terraform/apps/playground/envs/staging/us-east-1/variables.tf b/terraform/apps/playground/envs/staging/us-east-1/variables.tf new file mode 100644 index 0000000000..5cd23de43d --- /dev/null +++ b/terraform/apps/playground/envs/staging/us-east-1/variables.tf @@ -0,0 +1,327 @@ +################################################################################ +# General Variables +################################################################################ + +variable "environment" { + type = string + description = "Environment name." + default = "staging" +} + +variable "region" { + type = string + description = "AWS region." + default = "us-east-1" +} + +variable "domain_name" { + type = string + description = "Domain name for the application (optional)." + default = null +} + +################################################################################ +# Network Variables +################################################################################ + +variable "vpc_cidr_block" { + type = string + description = "CIDR block for the VPC." +} + +variable "public_subnets" { + description = "Public subnet definitions." + type = map(object({ + cidr_block = string + az = string + })) +} + +variable "private_subnets" { + description = "Private subnet definitions." + type = map(object({ + cidr_block = string + az = string + })) +} + +variable "enable_nat_gateway" { + type = bool + description = "Enable NAT Gateway." + default = true +} + +variable "single_nat_gateway" { + type = bool + description = "Use single NAT Gateway (cost savings)." + default = false +} + +variable "enable_s3_endpoint" { + type = bool + description = "Enable S3 VPC Gateway Endpoint." + default = true +} + +variable "enable_ecr_endpoints" { + type = bool + description = "Enable ECR VPC Interface Endpoints." + default = true +} + +variable "enable_logs_endpoint" { + type = bool + description = "Enable CloudWatch Logs VPC Interface Endpoint." + default = true +} + +variable "enable_secretsmanager_endpoint" { + type = bool + description = "Enable Secrets Manager VPC Interface Endpoint." + default = true +} + +variable "enable_flow_logs" { + type = bool + description = "Enable VPC Flow Logs." + default = true +} + +################################################################################ +# ECS Variables +################################################################################ + +variable "enable_container_insights" { + type = bool + description = "Enable Container Insights." + default = true +} + +################################################################################ +# ALB Variables +################################################################################ + +variable "enable_https" { + type = bool + description = "Enable HTTPS on ALB." + default = false +} + +variable "acm_certificate_arn" { + type = string + description = "ACM certificate ARN for HTTPS." + default = null +} + +################################################################################ +# S3 Variables +################################################################################ + +variable "app_s3_bucket_name" { + type = string + description = "S3 bucket for application data." +} + +variable "app_s3_enable_public_read" { + type = bool + description = "Whether to enable public read on uploads prefix." + default = false +} + +variable "app_s3_enable_cloudfront" { + type = bool + description = "Whether to enable CloudFront in front of the app bucket." + default = true +} + +variable "app_s3_cloudfront_price_class" { + type = string + description = "Price class for CloudFront distribution." + default = "PriceClass_100" +} + +################################################################################ +# Database Variables +################################################################################ + +variable "db_name" { + type = string + description = "Database name." +} + +variable "db_username" { + type = string + description = "Database admin username." +} + +variable "db_password" { + type = string + description = "Database admin password." + sensitive = true + default = null +} + +variable "db_manage_master_user_password" { + type = bool + description = "Let AWS manage the master user password in Secrets Manager." + default = true +} + +variable "db_instance_class" { + type = string + description = "RDS instance class." + default = "db.t3.small" +} + +variable "db_engine_version" { + type = string + description = "PostgreSQL engine version." + default = "16" +} + +variable "db_multi_az" { + type = bool + description = "Enable Multi-AZ deployment." + default = false +} + +variable "db_deletion_protection" { + type = bool + description = "Enable deletion protection." + default = true +} + +variable "db_enable_performance_insights" { + type = bool + description = "Enable Performance Insights." + default = true +} + +################################################################################ +# Redis Variables +################################################################################ + +variable "redis_node_type" { + type = string + description = "ElastiCache node type." + default = "cache.t3.small" +} + +variable "redis_num_cache_clusters" { + type = number + description = "Number of cache clusters (nodes)." + default = 1 +} + +variable "redis_automatic_failover_enabled" { + type = bool + description = "Enable automatic failover." + default = false +} + +################################################################################ +# Container Image Variables +################################################################################ + +variable "container_registry" { + type = string + description = "Container registry URL." + default = "ghcr.io/fullstackhero" +} + +variable "container_image_tag" { + type = string + description = "Container image tag (shared across all services)." +} + +variable "api_image_name" { + type = string + description = "API container image name." + default = "fsh-playground-api" +} + +variable "blazor_image_name" { + type = string + description = "Blazor container image name." + default = "fsh-playground-blazor" +} + +################################################################################ +# API Service Variables +################################################################################ + +variable "api_container_port" { + type = number + description = "API container port." + default = 8080 +} + +variable "api_cpu" { + type = string + description = "API CPU units." + default = "512" +} + +variable "api_memory" { + type = string + description = "API memory." + default = "1024" +} + +variable "api_desired_count" { + type = number + description = "Desired API task count." + default = 2 +} + +variable "api_enable_circuit_breaker" { + type = bool + description = "Enable deployment circuit breaker." + default = true +} + +variable "api_use_fargate_spot" { + type = bool + description = "Use Fargate Spot capacity." + default = true +} + +################################################################################ +# Blazor Service Variables +################################################################################ + +variable "blazor_container_port" { + type = number + description = "Blazor container port." + default = 8080 +} + +variable "blazor_cpu" { + type = string + description = "Blazor CPU units." + default = "512" +} + +variable "blazor_memory" { + type = string + description = "Blazor memory." + default = "1024" +} + +variable "blazor_desired_count" { + type = number + description = "Desired Blazor task count." + default = 2 +} + +variable "blazor_enable_circuit_breaker" { + type = bool + description = "Enable deployment circuit breaker." + default = true +} + +variable "blazor_use_fargate_spot" { + type = bool + description = "Use Fargate Spot capacity." + default = true +} diff --git a/terraform/apps/restaurantpos/README.md b/terraform/apps/restaurantpos/README.md new file mode 100644 index 0000000000..112438c316 --- /dev/null +++ b/terraform/apps/restaurantpos/README.md @@ -0,0 +1,6 @@ +# RestaurantPOS App Stack (skeleton) +Placeholder for a future app using shared modules from `../../modules`. + +- Env/region stacks will live under `envs///` (backend.tf + *.tfvars + main.tf). +- App composition will live under `app_stack/` (ECS services, ALB, DB/cache/S3 as needed). +- Images will come from the RestaurantPOS app Dockerfiles and be referenced in tfvars. diff --git a/terraform/apps/restaurantpos/app_stack/main.tf b/terraform/apps/restaurantpos/app_stack/main.tf new file mode 100644 index 0000000000..31ebfc1bb7 --- /dev/null +++ b/terraform/apps/restaurantpos/app_stack/main.tf @@ -0,0 +1,190 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +locals { + common_tags = { + Environment = var.environment + Project = "dotnet-starter-kit" + } + aspnetcore_environment = var.environment == "dev" ? "Development" : "Production" +} + +module "network" { + source = "../../../modules/network" + + name = "${var.environment}-${var.region}" + cidr_block = var.vpc_cidr_block + + public_subnets = var.public_subnets + private_subnets = var.private_subnets + + tags = local.common_tags +} + +module "ecs_cluster" { + source = "../../../modules/ecs_cluster" + + name = "${var.environment}-${var.region}-cluster" +} + +resource "aws_security_group" "alb" { + name = "${var.environment}-${var.region}-alb" + description = "ALB security group" + vpc_id = module.network.vpc_id + + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = local.common_tags +} + +module "alb" { + source = "../../../modules/alb" + + name = "${var.environment}-${var.region}-alb" + subnet_ids = module.network.public_subnet_ids + security_group_id = aws_security_group.alb.id + tags = local.common_tags +} + +module "app_s3" { + source = "../../../modules/s3_bucket" + + name = var.app_s3_bucket_name + tags = local.common_tags +} + +module "rds" { + source = "../../../modules/rds_postgres" + + name = "${var.environment}-${var.region}-postgres" + vpc_id = module.network.vpc_id + subnet_ids = module.network.private_subnet_ids + allowed_security_group_ids = [ + module.api_service.security_group_id, + module.blazor_service.security_group_id, + ] + db_name = var.db_name + username = var.db_username + password = var.db_password + tags = local.common_tags +} + +locals { + db_connection_string = "Host=${module.rds.endpoint};Port=${module.rds.port};Database=${var.db_name};Username=${var.db_username};Password=${var.db_password};Pooling=true;" +} + +module "redis" { + source = "../../../modules/elasticache_redis" + + name = "${var.environment}-${var.region}-redis" + vpc_id = module.network.vpc_id + subnet_ids = module.network.private_subnet_ids + allowed_security_group_ids = [ + module.api_service.security_group_id, + module.blazor_service.security_group_id, + ] + tags = local.common_tags +} + +module "api_service" { + source = "../../../modules/ecs_service" + + name = "${var.environment}-api" + region = var.region + cluster_arn = module.ecs_cluster.arn + container_image = var.api_container_image + container_port = var.api_container_port + cpu = var.api_cpu + memory = var.api_memory + desired_count = var.api_desired_count + + vpc_id = module.network.vpc_id + vpc_cidr_block = module.network.vpc_cidr_block + subnet_ids = module.network.private_subnet_ids + assign_public_ip = false + + listener_arn = module.alb.listener_arn + listener_rule_priority = 10 + path_patterns = ["/api/*", "/scalar*", "/health*", "/swagger*", "/openapi*"] + + health_check_path = "/health/live" + + environment_variables = { + ASPNETCORE_ENVIRONMENT = local.aspnetcore_environment + DatabaseOptions__ConnectionString = local.db_connection_string + CachingOptions__Redis = "${module.redis.primary_endpoint_address}:6379,ssl=True,abortConnect=False" + } + + tags = local.common_tags +} + +module "blazor_service" { + source = "../../../modules/ecs_service" + + name = "${var.environment}-blazor" + region = var.region + cluster_arn = module.ecs_cluster.arn + container_image = var.blazor_container_image + container_port = var.blazor_container_port + cpu = var.blazor_cpu + memory = var.blazor_memory + desired_count = var.blazor_desired_count + + vpc_id = module.network.vpc_id + vpc_cidr_block = module.network.vpc_cidr_block + subnet_ids = module.network.private_subnet_ids + assign_public_ip = false + + listener_arn = module.alb.listener_arn + listener_rule_priority = 20 + path_patterns = ["/*"] + + health_check_path = "/health/live" + + environment_variables = { + ASPNETCORE_ENVIRONMENT = local.aspnetcore_environment + Api__BaseUrl = "http://${module.alb.dns_name}" + } + + tags = local.common_tags +} + +output "alb_dns_name" { + value = module.alb.dns_name +} + +output "api_url" { + value = "http://${module.alb.dns_name}/api" +} + +output "blazor_url" { + value = "http://${module.alb.dns_name}" +} + +output "rds_endpoint" { + value = module.rds.endpoint +} + +output "redis_endpoint" { + value = module.redis.primary_endpoint_address +} diff --git a/terraform/apps/restaurantpos/app_stack/variables.tf b/terraform/apps/restaurantpos/app_stack/variables.tf new file mode 100644 index 0000000000..4a1d28d675 --- /dev/null +++ b/terraform/apps/restaurantpos/app_stack/variables.tf @@ -0,0 +1,101 @@ +variable "environment" { + type = string + description = "Environment name." +} + +variable "region" { + type = string + description = "AWS region." +} + +variable "vpc_cidr_block" { + type = string + description = "CIDR block for the VPC." +} + +variable "public_subnets" { + description = "Public subnet definitions." + type = map(object({ + cidr_block = string + az = string + })) +} + +variable "private_subnets" { + description = "Private subnet definitions." + type = map(object({ + cidr_block = string + az = string + })) +} + +variable "app_s3_bucket_name" { + type = string + description = "S3 bucket for application data." +} + +variable "db_name" { + type = string + description = "Database name." +} + +variable "db_username" { + type = string + description = "Database admin username." +} + +variable "db_password" { + type = string + description = "Database admin password." +} + +variable "api_container_image" { + type = string + description = "API container image." +} + +variable "api_container_port" { + type = number + description = "API container port." +} + +variable "api_cpu" { + type = string + description = "API CPU units." +} + +variable "api_memory" { + type = string + description = "API memory." +} + +variable "api_desired_count" { + type = number + description = "Desired API task count." +} + +variable "blazor_container_image" { + type = string + description = "Blazor container image." +} + +variable "blazor_container_port" { + type = number + description = "Blazor container port." +} + +variable "blazor_cpu" { + type = string + description = "Blazor CPU units." +} + +variable "blazor_memory" { + type = string + description = "Blazor memory." +} + +variable "blazor_desired_count" { + type = number + description = "Desired Blazor task count." +} + diff --git a/terraform/bootstrap/database.tf b/terraform/bootstrap/database.tf deleted file mode 100644 index 05293f37a5..0000000000 --- a/terraform/bootstrap/database.tf +++ /dev/null @@ -1,10 +0,0 @@ -resource "aws_dynamodb_table" "dynamodb-terraform-states" { - name = "fullstackhero-state-locks" - hash_key = "LockID" - read_capacity = 20 - write_capacity = 20 - attribute { - name = "LockID" - type = "S" - } -} diff --git a/terraform/bootstrap/main.tf b/terraform/bootstrap/main.tf new file mode 100644 index 0000000000..3d7d4e6399 --- /dev/null +++ b/terraform/bootstrap/main.tf @@ -0,0 +1,212 @@ +################################################################################ +# Terraform Bootstrap - State Backend Infrastructure +# +# This module creates an S3 bucket for storing Terraform state. +# Starting with Terraform 1.10+, S3 native locking is used via use_lockfile. +# DynamoDB is no longer required for state locking. +################################################################################ + +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.80.0" + } + } + + backend "local" {} +} + +provider "aws" { + region = var.region + + default_tags { + tags = { + ManagedBy = "terraform" + Purpose = "terraform-state" + } + } +} + +################################################################################ +# S3 Bucket for Terraform State +################################################################################ + +resource "aws_s3_bucket" "tf_state" { + bucket = var.bucket_name + + lifecycle { + prevent_destroy = true + } + + tags = { + Name = var.bucket_name + Description = "Terraform state storage" + } +} + +resource "aws_s3_bucket_versioning" "tf_state" { + bucket = aws_s3_bucket.tf_state.id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "tf_state" { + bucket = aws_s3_bucket.tf_state.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = var.kms_key_arn != null ? "aws:kms" : "AES256" + kms_master_key_id = var.kms_key_arn + } + bucket_key_enabled = var.kms_key_arn != null + } +} + +resource "aws_s3_bucket_public_access_block" "tf_state" { + bucket = aws_s3_bucket.tf_state.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_lifecycle_configuration" "tf_state" { + bucket = aws_s3_bucket.tf_state.id + + rule { + id = "abort-incomplete-uploads" + status = "Enabled" + + filter { + prefix = "" + } + + abort_incomplete_multipart_upload { + days_after_initiation = 7 + } + } + + rule { + id = "noncurrent-version-expiration" + status = "Enabled" + + filter { + prefix = "" + } + + noncurrent_version_expiration { + noncurrent_days = var.state_version_retention_days + } + } + + rule { + id = "lockfile-cleanup" + status = "Enabled" + + filter { + prefix = "" + } + + expiration { + expired_object_delete_marker = true + } + } + + depends_on = [aws_s3_bucket_versioning.tf_state] +} + +################################################################################ +# S3 Bucket Policy - Enforce SSL and Required Permissions for Native Locking +################################################################################ + +resource "aws_s3_bucket_policy" "tf_state" { + bucket = aws_s3_bucket.tf_state.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "EnforceSSLOnly" + Effect = "Deny" + Principal = "*" + Action = "s3:*" + Resource = [ + aws_s3_bucket.tf_state.arn, + "${aws_s3_bucket.tf_state.arn}/*" + ] + Condition = { + Bool = { + "aws:SecureTransport" = "false" + } + } + }, + { + Sid = "EnforceTLSVersion" + Effect = "Deny" + Principal = "*" + Action = "s3:*" + Resource = [ + aws_s3_bucket.tf_state.arn, + "${aws_s3_bucket.tf_state.arn}/*" + ] + Condition = { + NumericLessThan = { + "s3:TlsVersion" = "1.2" + } + } + } + ] + }) + + depends_on = [aws_s3_bucket_public_access_block.tf_state] +} + +################################################################################ +# Outputs +################################################################################ + +output "state_bucket_name" { + description = "Name of the S3 bucket for Terraform state" + value = aws_s3_bucket.tf_state.id +} + +output "state_bucket_arn" { + description = "ARN of the S3 bucket for Terraform state" + value = aws_s3_bucket.tf_state.arn +} + +output "state_bucket_region" { + description = "Region of the S3 bucket for Terraform state" + value = var.region +} + +output "backend_config" { + description = "Backend configuration to use in other Terraform configurations (Terraform 1.10+ with S3 native locking)" + value = { + bucket = aws_s3_bucket.tf_state.id + region = var.region + encrypt = true + use_lockfile = true + } +} + +output "backend_config_hcl" { + description = "Example backend configuration block for terraform files" + value = <<-EOT + terraform { + backend "s3" { + bucket = "${aws_s3_bucket.tf_state.id}" + key = "//terraform.tfstate" + region = "${var.region}" + encrypt = true + use_lockfile = true + } + } + EOT +} diff --git a/terraform/bootstrap/storage.tf b/terraform/bootstrap/storage.tf deleted file mode 100644 index a732bd2645..0000000000 --- a/terraform/bootstrap/storage.tf +++ /dev/null @@ -1,10 +0,0 @@ -resource "aws_s3_bucket" "s3_bucket" { - bucket = "fullstackhero-terraform-backend" - tags = { - Name = "fullstackhero-terraform-backend" - Project = "fullstackhero" - } - lifecycle { - prevent_destroy = true - } -} diff --git a/terraform/bootstrap/variables.tf b/terraform/bootstrap/variables.tf new file mode 100644 index 0000000000..106b86b58b --- /dev/null +++ b/terraform/bootstrap/variables.tf @@ -0,0 +1,36 @@ +variable "region" { + type = string + description = "AWS region where the state bucket is created." + + validation { + condition = can(regex("^[a-z]{2}-[a-z]+-\\d$", var.region)) + error_message = "Region must be a valid AWS region identifier (e.g., us-east-1)." + } +} + +variable "bucket_name" { + type = string + description = "Name of the S3 bucket for Terraform remote state (must be globally unique)." + + validation { + condition = can(regex("^[a-z0-9][a-z0-9.-]*[a-z0-9]$", var.bucket_name)) + error_message = "Bucket name must contain only lowercase letters, numbers, hyphens, and periods." + } +} + +variable "kms_key_arn" { + type = string + description = "KMS key ARN for encryption. Uses AWS-managed key if not specified." + default = null +} + +variable "state_version_retention_days" { + type = number + description = "Number of days to retain non-current state file versions." + default = 90 + + validation { + condition = var.state_version_retention_days >= 1 + error_message = "State version retention must be at least 1 day." + } +} diff --git a/terraform/environments/dev/compute.tf b/terraform/environments/dev/compute.tf deleted file mode 100644 index 41201d2fea..0000000000 --- a/terraform/environments/dev/compute.tf +++ /dev/null @@ -1,45 +0,0 @@ -module "cluster" { - source = "../../modules/ecs/cluster" - cluster_name = "fullstackhero" -} - -module "webapi" { - source = "../../modules/ecs" - vpc_id = module.vpc.vpc_id - environment = var.environment - cluster_id = module.cluster.id - service_name = "webapi" - container_name = "fsh-webapi" - container_image = "ghcr.io/fullstackhero/webapi:latest" - subnet_ids = [module.vpc.private_a_id, module.vpc.private_b_id] - environment_variables = { - DatabaseOptions__ConnectionString = module.rds.connection_string - DatabaseOptions__Provider = "postgresql" - Serilog__MinimumLevel__Default = "Error" - CorsOptions__AllowedOrigins__0 = "http://${module.blazor.endpoint}" - OriginOptions__OriginUrl = "http://${module.webapi.endpoint}:8080" - } -} - -module "blazor" { - source = "../../modules/ecs" - vpc_id = module.vpc.vpc_id - cluster_id = module.cluster.id - environment = var.environment - container_port = 80 - service_name = "blazor" - container_name = "fsh-blazor" - container_image = "ghcr.io/fullstackhero/blazor:latest" - subnet_ids = [module.vpc.private_a_id, module.vpc.private_b_id] - environment_variables = { - Frontend_FSHStarterBlazorClient_Settings__AppSettingsTemplate = "/usr/share/nginx/html/appsettings.json.TEMPLATE" - Frontend_FSHStarterBlazorClient_Settings__AppSettingsJson = "/usr/share/nginx/html/appsettings.json" - FSHStarterBlazorClient_ApiBaseUrl = "http://${module.webapi.endpoint}:8080" - ApiBaseUrl = "http://${module.webapi.endpoint}:8080" - } - entry_point = [ - "/bin/sh", - "-c", - "envsubst < $${Frontend_FSHStarterBlazorClient_Settings__AppSettingsTemplate} > $${Frontend_FSHStarterBlazorClient_Settings__AppSettingsJson} || echo 'envsubst failed' && find /usr/share/nginx/html -type f | xargs chmod +r || echo 'chmod failed' && echo 'Entry point execution completed' && cat /usr/share/nginx/html/appsettings.json && exec nginx -g 'daemon off;'" - ] -} diff --git a/terraform/environments/dev/database.tf b/terraform/environments/dev/database.tf deleted file mode 100644 index 3547680027..0000000000 --- a/terraform/environments/dev/database.tf +++ /dev/null @@ -1,9 +0,0 @@ -module "rds" { - environment = var.environment - source = "../../modules/rds" - vpc_id = module.vpc.vpc_id - subnet_ids = [module.vpc.private_a_id, module.vpc.private_b_id] - multi_az = false - database_name = "fsh" - cidr_block = module.vpc.cidr_block -} diff --git a/terraform/environments/dev/main.tf b/terraform/environments/dev/main.tf deleted file mode 100644 index e789efddf1..0000000000 --- a/terraform/environments/dev/main.tf +++ /dev/null @@ -1,8 +0,0 @@ -terraform { - backend "s3" { - bucket = "fullstackhero-terraform-backend" - key = "fullstackhero/dev/terraform.tfstate" - region = "us-east-1" - dynamodb_table = "fullstackhero-state-locks" - } -} diff --git a/terraform/environments/dev/network.tf b/terraform/environments/dev/network.tf deleted file mode 100644 index 55e55df792..0000000000 --- a/terraform/environments/dev/network.tf +++ /dev/null @@ -1,3 +0,0 @@ -module "vpc" { - source = "../../modules/vpc" -} diff --git a/terraform/environments/dev/outputs.tf b/terraform/environments/dev/outputs.tf deleted file mode 100644 index 3a04c8c615..0000000000 --- a/terraform/environments/dev/outputs.tf +++ /dev/null @@ -1,7 +0,0 @@ -output "webapi" { - value = "http://${module.webapi.endpoint}:8080" -} - -output "blazor" { - value = "http://${module.blazor.endpoint}" -} diff --git a/terraform/environments/dev/providers.tf b/terraform/environments/dev/providers.tf deleted file mode 100644 index 8e8b661f74..0000000000 --- a/terraform/environments/dev/providers.tf +++ /dev/null @@ -1,16 +0,0 @@ -terraform { - required_version = "~> 1.8" - required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 5.0" - } - } -} - -provider "aws" { - region = var.aws_region - default_tags { - tags = merge(local.common_tags) - } -} diff --git a/terraform/environments/dev/terraform.tfvars b/terraform/environments/dev/terraform.tfvars deleted file mode 100644 index 7ac85169db..0000000000 --- a/terraform/environments/dev/terraform.tfvars +++ /dev/null @@ -1,5 +0,0 @@ -// Default Project Tags -environment = "dev" -owner = "Mukesh Murugan" -project_name = "fullstackhero" -repository = "https://github.com/fullstackhero/dotnet-starter-kit" diff --git a/terraform/environments/dev/variables.tf b/terraform/environments/dev/variables.tf deleted file mode 100644 index 87662c7ee0..0000000000 --- a/terraform/environments/dev/variables.tf +++ /dev/null @@ -1,31 +0,0 @@ -variable "aws_region" { - description = "The AWS region to deploy resources in" - type = string - default = "us-east-1" -} - -variable "environment" { - type = string - default = "dev" -} -variable "owner" { - type = string -} - -variable "project_name" { - type = string -} - -variable "repository" { - type = string -} - - -locals { - common_tags = { - Environment = var.environment - Owner = var.owner - Project = var.project_name - Repository = var.repository - } -} diff --git a/terraform/modules/alb/main.tf b/terraform/modules/alb/main.tf new file mode 100644 index 0000000000..8a6439169b --- /dev/null +++ b/terraform/modules/alb/main.tf @@ -0,0 +1,100 @@ +################################################################################ +# Application Load Balancer +################################################################################ + +resource "aws_lb" "this" { + name = var.name + internal = var.internal + load_balancer_type = "application" + security_groups = [var.security_group_id] + subnets = var.subnet_ids + + enable_deletion_protection = var.enable_deletion_protection + enable_http2 = var.enable_http2 + idle_timeout = var.idle_timeout + drop_invalid_header_fields = var.drop_invalid_header_fields + + dynamic "access_logs" { + for_each = var.access_logs_bucket != null ? [1] : [] + content { + bucket = var.access_logs_bucket + prefix = var.access_logs_prefix + enabled = true + } + } + + tags = merge(var.tags, { + Name = var.name + }) +} + +################################################################################ +# HTTP Listener +################################################################################ + +resource "aws_lb_listener" "http" { + load_balancer_arn = aws_lb.this.arn + port = 80 + protocol = "HTTP" + + default_action { + type = var.enable_https ? "redirect" : "fixed-response" + + dynamic "redirect" { + for_each = var.enable_https ? [1] : [] + content { + port = "443" + protocol = "HTTPS" + status_code = "HTTP_301" + } + } + + dynamic "fixed_response" { + for_each = var.enable_https ? [] : [1] + content { + content_type = "text/plain" + message_body = "Not configured" + status_code = "404" + } + } + } + + tags = var.tags +} + +################################################################################ +# HTTPS Listener (Optional) +################################################################################ + +resource "aws_lb_listener" "https" { + count = var.enable_https ? 1 : 0 + + load_balancer_arn = aws_lb.this.arn + port = 443 + protocol = "HTTPS" + ssl_policy = var.ssl_policy + certificate_arn = var.certificate_arn + + default_action { + type = "fixed-response" + + fixed_response { + content_type = "text/plain" + message_body = "Not configured" + status_code = "404" + } + } + + tags = var.tags +} + +################################################################################ +# Additional Certificates (Optional) +################################################################################ + +resource "aws_lb_listener_certificate" "additional" { + for_each = var.enable_https ? toset(var.additional_certificate_arns) : [] + + listener_arn = aws_lb_listener.https[0].arn + certificate_arn = each.value +} diff --git a/terraform/modules/alb/outputs.tf b/terraform/modules/alb/outputs.tf new file mode 100644 index 0000000000..a8b4e66555 --- /dev/null +++ b/terraform/modules/alb/outputs.tf @@ -0,0 +1,34 @@ +output "arn" { + description = "The ARN of the load balancer" + value = aws_lb.this.arn +} + +output "id" { + description = "The ID of the load balancer" + value = aws_lb.this.id +} + +output "dns_name" { + description = "The DNS name of the load balancer" + value = aws_lb.this.dns_name +} + +output "zone_id" { + description = "The canonical hosted zone ID of the load balancer" + value = aws_lb.this.zone_id +} + +output "http_listener_arn" { + description = "The ARN of the HTTP listener" + value = aws_lb_listener.http.arn +} + +output "https_listener_arn" { + description = "The ARN of the HTTPS listener (if enabled)" + value = var.enable_https ? aws_lb_listener.https[0].arn : null +} + +output "listener_arn" { + description = "The ARN of the primary listener (HTTPS if enabled, otherwise HTTP)" + value = var.enable_https ? aws_lb_listener.https[0].arn : aws_lb_listener.http.arn +} diff --git a/terraform/modules/alb/variables.tf b/terraform/modules/alb/variables.tf new file mode 100644 index 0000000000..2430c1c2f2 --- /dev/null +++ b/terraform/modules/alb/variables.tf @@ -0,0 +1,132 @@ +################################################################################ +# Required Variables +################################################################################ + +variable "name" { + type = string + description = "Name of the ALB." + + validation { + condition = can(regex("^[a-zA-Z0-9-]+$", var.name)) + error_message = "ALB name must contain only alphanumeric characters and hyphens." + } +} + +variable "subnet_ids" { + type = list(string) + description = "Subnets for the ALB." + + validation { + condition = length(var.subnet_ids) >= 2 + error_message = "At least two subnets are required for ALB." + } +} + +variable "security_group_id" { + type = string + description = "Security group for the ALB." +} + +################################################################################ +# ALB Configuration +################################################################################ + +variable "internal" { + type = bool + description = "Whether the ALB is internal." + default = false +} + +variable "enable_deletion_protection" { + type = bool + description = "Enable deletion protection." + default = false +} + +variable "enable_http2" { + type = bool + description = "Enable HTTP/2." + default = true +} + +variable "idle_timeout" { + type = number + description = "Idle timeout in seconds." + default = 60 + + validation { + condition = var.idle_timeout >= 1 && var.idle_timeout <= 4000 + error_message = "Idle timeout must be between 1 and 4000 seconds." + } +} + +variable "drop_invalid_header_fields" { + type = bool + description = "Drop invalid HTTP headers." + default = true +} + +################################################################################ +# HTTPS Configuration +################################################################################ + +variable "enable_https" { + type = bool + description = "Enable HTTPS listener (requires certificate_arn)." + default = false +} + +variable "certificate_arn" { + type = string + description = "ARN of the default SSL certificate." + default = null +} + +variable "additional_certificate_arns" { + type = list(string) + description = "Additional SSL certificate ARNs for SNI." + default = [] +} + +variable "ssl_policy" { + type = string + description = "SSL policy for HTTPS listener." + default = "ELBSecurityPolicy-TLS13-1-2-2021-06" + + validation { + condition = contains([ + "ELBSecurityPolicy-TLS13-1-2-2021-06", + "ELBSecurityPolicy-TLS13-1-3-2021-06", + "ELBSecurityPolicy-FS-1-2-Res-2020-10", + "ELBSecurityPolicy-FS-1-2-2019-08", + "ELBSecurityPolicy-2016-08" + ], var.ssl_policy) + error_message = "SSL policy must be a valid ELB security policy." + } +} + +################################################################################ +# Access Logs +################################################################################ + +variable "access_logs_bucket" { + type = string + description = "S3 bucket for access logs." + default = null +} + +variable "access_logs_prefix" { + type = string + description = "S3 prefix for access logs." + default = "alb-logs" +} + +################################################################################ +# Tags +################################################################################ + +variable "tags" { + type = map(string) + description = "Tags to apply to ALB resources." + default = {} +} diff --git a/terraform/modules/alb/versions.tf b/terraform/modules/alb/versions.tf new file mode 100644 index 0000000000..2ca9c8626e --- /dev/null +++ b/terraform/modules/alb/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.80.0" + } + } +} diff --git a/terraform/modules/cloudwatch/main.tf b/terraform/modules/cloudwatch/main.tf deleted file mode 100644 index 3d571382ed..0000000000 --- a/terraform/modules/cloudwatch/main.tf +++ /dev/null @@ -1,4 +0,0 @@ -resource "aws_cloudwatch_log_group" "this" { - name = var.log_group_name - retention_in_days = var.retention_period -} diff --git a/terraform/modules/cloudwatch/variables.tf b/terraform/modules/cloudwatch/variables.tf deleted file mode 100644 index e2eac862f0..0000000000 --- a/terraform/modules/cloudwatch/variables.tf +++ /dev/null @@ -1,8 +0,0 @@ -variable "log_group_name" { - type = string -} - -variable "retention_period" { - type = number - default = 60 -} diff --git a/terraform/modules/ecs/cluster/main.tf b/terraform/modules/ecs/cluster/main.tf deleted file mode 100644 index 236f30bd34..0000000000 --- a/terraform/modules/ecs/cluster/main.tf +++ /dev/null @@ -1,13 +0,0 @@ -resource "aws_ecs_cluster" "this" { - name = var.cluster_name -} - -resource "aws_ecs_cluster_capacity_providers" "this" { - cluster_name = aws_ecs_cluster.this.name - capacity_providers = ["FARGATE"] - default_capacity_provider_strategy { - base = 1 - weight = 100 - capacity_provider = "FARGATE" - } -} diff --git a/terraform/modules/ecs/cluster/outputs.tf b/terraform/modules/ecs/cluster/outputs.tf deleted file mode 100644 index af88b19431..0000000000 --- a/terraform/modules/ecs/cluster/outputs.tf +++ /dev/null @@ -1,3 +0,0 @@ -output "id" { - value = aws_ecs_cluster.this.id -} diff --git a/terraform/modules/ecs/cluster/variables.tf b/terraform/modules/ecs/cluster/variables.tf deleted file mode 100644 index abbf86f798..0000000000 --- a/terraform/modules/ecs/cluster/variables.tf +++ /dev/null @@ -1,3 +0,0 @@ -variable "cluster_name" { - type = string -} diff --git a/terraform/modules/ecs/iam.tf b/terraform/modules/ecs/iam.tf deleted file mode 100644 index 0533ec0115..0000000000 --- a/terraform/modules/ecs/iam.tf +++ /dev/null @@ -1,41 +0,0 @@ -resource "aws_iam_role" "ecs_task_execution_role" { - name = "${var.service_name}-ecs-ter" - assume_role_policy = < 0 ? 1 : 0 + + name = "${var.name}-secrets-access" + role = aws_iam_role.task_execution.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "secretsmanager:GetSecretValue" + ] + # Use the secret ARN directly - callers should provide the base secret ARN + Resource = [for s in var.secrets : s.valueFrom] + } + ] + }) +} + +################################################################################ +# Task Definition +################################################################################ + +resource "aws_ecs_task_definition" "this" { + family = var.name + cpu = var.cpu + memory = var.memory + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + execution_role_arn = aws_iam_role.task_execution.arn + task_role_arn = var.task_role_arn + + container_definitions = jsonencode([ + merge( + { + name = var.name + image = var.container_image + essential = true + + portMappings = [ + { + containerPort = var.container_port + hostPort = var.container_port + protocol = "tcp" + name = var.name + } + ] + + environment = [ + for k, v in var.environment_variables : + { + name = k + value = v + } + ] + + secrets = length(var.secrets) > 0 ? [ + for s in var.secrets : + { + name = s.name + valueFrom = s.valueFrom + } + ] : [] + + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.this.name + awslogs-region = var.region + awslogs-stream-prefix = var.name + } + } + }, + var.container_health_check != null ? { + healthCheck = { + command = var.container_health_check.command + interval = var.container_health_check.interval + timeout = var.container_health_check.timeout + retries = var.container_health_check.retries + startPeriod = var.container_health_check.start_period + } + } : {} + ) + ]) + + runtime_platform { + cpu_architecture = var.cpu_architecture + operating_system_family = "LINUX" + } + + tags = var.tags +} + +################################################################################ +# Local Variables +################################################################################ + +locals { + # Determine if we should use capacity provider strategy + use_capacity_providers = var.use_fargate_spot || var.use_capacity_provider_strategy + + # Build the capacity provider strategy based on use_fargate_spot or custom strategy + effective_capacity_provider_strategy = var.use_fargate_spot ? [ + { + capacity_provider = "FARGATE_SPOT" + weight = 1 + base = 0 + } + ] : var.capacity_provider_strategy +} + +################################################################################ +# ECS Service +################################################################################ + +resource "aws_ecs_service" "this" { + name = var.name + cluster = var.cluster_arn + task_definition = aws_ecs_task_definition.this.arn + desired_count = var.desired_count + launch_type = local.use_capacity_providers ? null : "FARGATE" + enable_execute_command = var.enable_execute_command + health_check_grace_period_seconds = var.health_check_grace_period_seconds + deployment_minimum_healthy_percent = var.deployment_minimum_healthy_percent + deployment_maximum_percent = var.deployment_maximum_percent + + dynamic "capacity_provider_strategy" { + for_each = local.use_capacity_providers ? local.effective_capacity_provider_strategy : [] + content { + capacity_provider = capacity_provider_strategy.value.capacity_provider + weight = capacity_provider_strategy.value.weight + base = capacity_provider_strategy.value.base + } + } + + network_configuration { + subnets = var.subnet_ids + security_groups = [aws_security_group.ecs_service.id] + assign_public_ip = var.assign_public_ip + } + + load_balancer { + target_group_arn = aws_lb_target_group.this.arn + container_name = var.name + container_port = var.container_port + } + + deployment_circuit_breaker { + enable = var.enable_circuit_breaker + rollback = var.enable_circuit_breaker_rollback + } + + lifecycle { + ignore_changes = [desired_count] + } + + depends_on = [aws_lb_listener_rule.this] + + tags = var.tags +} diff --git a/terraform/modules/ecs_service/outputs.tf b/terraform/modules/ecs_service/outputs.tf new file mode 100644 index 0000000000..f4f85b08cf --- /dev/null +++ b/terraform/modules/ecs_service/outputs.tf @@ -0,0 +1,44 @@ +output "service_id" { + description = "The ID of the ECS service" + value = aws_ecs_service.this.id +} + +output "service_name" { + description = "The name of the ECS service" + value = aws_ecs_service.this.name +} + +output "task_definition_arn" { + description = "The ARN of the task definition" + value = aws_ecs_task_definition.this.arn +} + +output "task_definition_family" { + description = "The family of the task definition" + value = aws_ecs_task_definition.this.family +} + +output "security_group_id" { + description = "The ID of the ECS service security group" + value = aws_security_group.ecs_service.id +} + +output "target_group_arn" { + description = "The ARN of the target group" + value = aws_lb_target_group.this.arn +} + +output "cloudwatch_log_group_name" { + description = "The name of the CloudWatch log group" + value = aws_cloudwatch_log_group.this.name +} + +output "cloudwatch_log_group_arn" { + description = "The ARN of the CloudWatch log group" + value = aws_cloudwatch_log_group.this.arn +} + +output "execution_role_arn" { + description = "The ARN of the task execution role" + value = aws_iam_role.task_execution.arn +} diff --git a/terraform/modules/ecs_service/variables.tf b/terraform/modules/ecs_service/variables.tf new file mode 100644 index 0000000000..f57a1e4289 --- /dev/null +++ b/terraform/modules/ecs_service/variables.tf @@ -0,0 +1,312 @@ +################################################################################ +# Required Variables +################################################################################ + +variable "name" { + type = string + description = "Name of the ECS service." + + validation { + condition = can(regex("^[a-zA-Z0-9-_]+$", var.name)) + error_message = "Service name must contain only alphanumeric characters, hyphens, and underscores." + } +} + +variable "region" { + type = string + description = "AWS region." +} + +variable "cluster_arn" { + type = string + description = "ARN of the ECS cluster." +} + +variable "container_image" { + type = string + description = "Container image to deploy." + + validation { + condition = length(var.container_image) > 0 + error_message = "Container image must not be empty." + } +} + +variable "container_port" { + type = number + description = "Container port exposed by the service." + + validation { + condition = var.container_port > 0 && var.container_port <= 65535 + error_message = "Container port must be between 1 and 65535." + } +} + +variable "cpu" { + type = string + description = "Fargate CPU units (256, 512, 1024, 2048, 4096, 8192, 16384)." + + validation { + condition = contains(["256", "512", "1024", "2048", "4096", "8192", "16384"], var.cpu) + error_message = "CPU must be one of: 256, 512, 1024, 2048, 4096, 8192, 16384." + } +} + +variable "memory" { + type = string + description = "Fargate memory in MiB." +} + +variable "vpc_id" { + type = string + description = "VPC ID for the service." +} + +variable "vpc_cidr_block" { + type = string + description = "CIDR block of the VPC." +} + +variable "subnet_ids" { + type = list(string) + description = "Subnets for ECS tasks." + + validation { + condition = length(var.subnet_ids) >= 1 + error_message = "At least one subnet must be specified." + } +} + +variable "listener_arn" { + type = string + description = "ALB listener ARN." +} + +variable "listener_rule_priority" { + type = number + description = "Priority for the ALB listener rule." + + validation { + condition = var.listener_rule_priority >= 1 && var.listener_rule_priority <= 50000 + error_message = "Listener rule priority must be between 1 and 50000." + } +} + +################################################################################ +# Optional Variables - Deployment +################################################################################ + +variable "desired_count" { + type = number + description = "Desired number of tasks." + default = 1 + + validation { + condition = var.desired_count >= 0 + error_message = "Desired count must be non-negative." + } +} + +variable "assign_public_ip" { + type = bool + description = "Assign public IP to tasks." + default = false +} + +variable "cpu_architecture" { + type = string + description = "CPU architecture (X86_64 or ARM64)." + default = "X86_64" + + validation { + condition = contains(["X86_64", "ARM64"], var.cpu_architecture) + error_message = "CPU architecture must be X86_64 or ARM64." + } +} + +variable "enable_execute_command" { + type = bool + description = "Enable ECS Exec for debugging." + default = false +} + +variable "use_capacity_provider_strategy" { + type = bool + description = "Use capacity provider strategy instead of launch type. Automatically set to true if use_fargate_spot is true." + default = false +} + +variable "use_fargate_spot" { + type = bool + description = "Use Fargate Spot capacity (convenience variable that automatically configures capacity provider strategy)." + default = false +} + +variable "capacity_provider_strategy" { + type = list(object({ + capacity_provider = string + weight = number + base = optional(number, 0) + })) + description = "Capacity provider strategy (requires use_capacity_provider_strategy = true). Ignored if use_fargate_spot is true." + default = [ + { + capacity_provider = "FARGATE" + weight = 1 + base = 1 + } + ] +} + +################################################################################ +# Optional Variables - Deployment Strategy +################################################################################ + +variable "deployment_minimum_healthy_percent" { + type = number + description = "Minimum healthy percent during deployment." + default = 100 +} + +variable "deployment_maximum_percent" { + type = number + description = "Maximum percent during deployment." + default = 200 +} + +variable "enable_circuit_breaker" { + type = bool + description = "Enable deployment circuit breaker." + default = true +} + +variable "enable_circuit_breaker_rollback" { + type = bool + description = "Enable automatic rollback on deployment failure." + default = true +} + +################################################################################ +# Optional Variables - Health Check +################################################################################ + +variable "path_patterns" { + type = list(string) + description = "Path patterns for ALB listener rule." + default = ["/*"] +} + +variable "health_check_path" { + type = string + description = "Health check path for the target group." + default = "/" +} + +variable "health_check_matcher" { + type = string + description = "HTTP status codes for healthy response." + default = "200-399" +} + +variable "health_check_interval" { + type = number + description = "Health check interval in seconds." + default = 30 +} + +variable "health_check_timeout" { + type = number + description = "Health check timeout in seconds." + default = 5 +} + +variable "health_check_healthy_threshold" { + type = number + description = "Number of consecutive successful health checks." + default = 2 +} + +variable "health_check_unhealthy_threshold" { + type = number + description = "Number of consecutive failed health checks." + default = 5 +} + +variable "health_check_grace_period_seconds" { + type = number + description = "Seconds to wait before health checks start." + default = 60 +} + +variable "deregistration_delay" { + type = number + description = "Time to wait for in-flight requests before deregistering." + default = 30 +} + +variable "container_health_check" { + type = object({ + command = list(string) + interval = optional(number, 30) + timeout = optional(number, 5) + retries = optional(number, 3) + start_period = optional(number, 60) + }) + description = "Container health check configuration." + default = null +} + +################################################################################ +# Optional Variables - Logging +################################################################################ + +variable "log_retention_in_days" { + type = number + description = "CloudWatch log retention in days." + default = 30 + + validation { + condition = contains([0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653], var.log_retention_in_days) + error_message = "Log retention must be a valid CloudWatch Logs retention value." + } +} + +################################################################################ +# Optional Variables - Environment & Secrets +################################################################################ + +variable "environment_variables" { + type = map(string) + description = "Plain environment variables for the container." + default = {} + sensitive = true +} + +variable "secrets" { + type = list(object({ + name = string + valueFrom = string + })) + description = "Secrets from Secrets Manager or Parameter Store." + default = [] +} + +################################################################################ +# Optional Variables - IAM +################################################################################ + +variable "task_role_arn" { + type = string + description = "Optional task role ARN to attach to the task definition." + default = null +} + +################################################################################ +# Tags +################################################################################ + +variable "tags" { + type = map(string) + description = "Tags to apply to resources." + default = {} +} diff --git a/terraform/modules/ecs_service/versions.tf b/terraform/modules/ecs_service/versions.tf new file mode 100644 index 0000000000..2ca9c8626e --- /dev/null +++ b/terraform/modules/ecs_service/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.80.0" + } + } +} diff --git a/terraform/modules/elasticache_redis/main.tf b/terraform/modules/elasticache_redis/main.tf new file mode 100644 index 0000000000..cd3452088f --- /dev/null +++ b/terraform/modules/elasticache_redis/main.tf @@ -0,0 +1,136 @@ +################################################################################ +# Security Group +################################################################################ + +resource "aws_security_group" "this" { + name = "${var.name}-sg" + description = "Security group for ElastiCache Redis ${var.name}" + vpc_id = var.vpc_id + + tags = merge(var.tags, { + Name = "${var.name}-redis-sg" + }) +} + +resource "aws_vpc_security_group_ingress_rule" "redis_sg" { + for_each = toset(var.allowed_security_group_ids) + + security_group_id = aws_security_group.this.id + description = "Redis access from allowed security group" + from_port = 6379 + to_port = 6379 + ip_protocol = "tcp" + referenced_security_group_id = each.value + + tags = var.tags +} + +resource "aws_vpc_security_group_ingress_rule" "redis_cidr" { + count = length(var.allowed_cidr_blocks) + + security_group_id = aws_security_group.this.id + description = "Redis access from allowed CIDR block" + from_port = 6379 + to_port = 6379 + ip_protocol = "tcp" + cidr_ipv4 = var.allowed_cidr_blocks[count.index] + + tags = var.tags +} + +resource "aws_vpc_security_group_egress_rule" "all" { + security_group_id = aws_security_group.this.id + description = "Allow all outbound traffic" + ip_protocol = "-1" + cidr_ipv4 = "0.0.0.0/0" + + tags = var.tags +} + +################################################################################ +# Subnet Group +################################################################################ + +resource "aws_elasticache_subnet_group" "this" { + name = "${var.name}-subnets" + subnet_ids = var.subnet_ids + + tags = merge(var.tags, { + Name = "${var.name}-subnet-group" + }) +} + +################################################################################ +# Parameter Group (Optional) +################################################################################ + +resource "aws_elasticache_parameter_group" "this" { + count = var.create_parameter_group ? 1 : 0 + + name = "${var.name}-params" + family = "redis${split(".", var.engine_version)[0]}" + + dynamic "parameter" { + for_each = var.parameters + content { + name = parameter.value.name + value = parameter.value.value + } + } + + tags = var.tags + + lifecycle { + create_before_destroy = true + } +} + +################################################################################ +# Replication Group +################################################################################ + +resource "aws_elasticache_replication_group" "this" { + replication_group_id = var.name + description = var.description != "" ? var.description : "Redis for ${var.name}" + + # Engine + engine = "redis" + engine_version = var.engine_version + node_type = var.node_type + + # Cluster Configuration + num_cache_clusters = var.num_cache_clusters + automatic_failover_enabled = var.automatic_failover_enabled + multi_az_enabled = var.multi_az_enabled + + # Network + port = 6379 + subnet_group_name = aws_elasticache_subnet_group.this.name + security_group_ids = [aws_security_group.this.id] + parameter_group_name = var.create_parameter_group ? aws_elasticache_parameter_group.this[0].name : null + + # Security + at_rest_encryption_enabled = true + transit_encryption_enabled = var.transit_encryption_enabled + auth_token = var.auth_token + kms_key_id = var.kms_key_id + + # Maintenance + auto_minor_version_upgrade = var.auto_minor_version_upgrade + apply_immediately = var.apply_immediately + maintenance_window = var.maintenance_window + + # Snapshots + snapshot_retention_limit = var.snapshot_retention_limit + snapshot_window = var.snapshot_window + final_snapshot_identifier = var.skip_final_snapshot ? null : "${var.name}-final-snapshot" + + # Notifications + notification_topic_arn = var.notification_topic_arn + + tags = var.tags + + lifecycle { + ignore_changes = [auth_token] + } +} diff --git a/terraform/modules/elasticache_redis/outputs.tf b/terraform/modules/elasticache_redis/outputs.tf new file mode 100644 index 0000000000..367586a4d4 --- /dev/null +++ b/terraform/modules/elasticache_redis/outputs.tf @@ -0,0 +1,39 @@ +output "primary_endpoint_address" { + description = "The primary endpoint address for the Redis replication group" + value = aws_elasticache_replication_group.this.primary_endpoint_address +} + +output "reader_endpoint_address" { + description = "The reader endpoint address for the Redis replication group" + value = aws_elasticache_replication_group.this.reader_endpoint_address +} + +output "port" { + description = "The Redis port" + value = aws_elasticache_replication_group.this.port +} + +output "replication_group_id" { + description = "The ID of the ElastiCache replication group" + value = aws_elasticache_replication_group.this.id +} + +output "arn" { + description = "The ARN of the ElastiCache replication group" + value = aws_elasticache_replication_group.this.arn +} + +output "security_group_id" { + description = "The ID of the Redis security group" + value = aws_security_group.this.id +} + +output "subnet_group_name" { + description = "The name of the ElastiCache subnet group" + value = aws_elasticache_subnet_group.this.name +} + +output "connection_string" { + description = "Redis connection string for .NET applications" + value = "${aws_elasticache_replication_group.this.primary_endpoint_address}:${aws_elasticache_replication_group.this.port},ssl=True,abortConnect=False" +} diff --git a/terraform/modules/elasticache_redis/variables.tf b/terraform/modules/elasticache_redis/variables.tf new file mode 100644 index 0000000000..06961bb56a --- /dev/null +++ b/terraform/modules/elasticache_redis/variables.tf @@ -0,0 +1,200 @@ +################################################################################ +# Required Variables +################################################################################ + +variable "name" { + type = string + description = "Name identifier for the Redis replication group." + + validation { + condition = can(regex("^[a-z][a-z0-9-]*$", var.name)) + error_message = "Name must start with a letter and contain only lowercase letters, numbers, and hyphens." + } +} + +variable "vpc_id" { + type = string + description = "VPC ID." +} + +variable "subnet_ids" { + type = list(string) + description = "Subnets for ElastiCache." + + validation { + condition = length(var.subnet_ids) >= 1 + error_message = "At least one subnet must be specified." + } +} + +variable "allowed_security_group_ids" { + type = list(string) + description = "Security groups allowed to access Redis (use when SG IDs are known at plan time)." + default = [] +} + +variable "allowed_cidr_blocks" { + type = list(string) + description = "CIDR blocks allowed to access Redis (use when security groups are not yet created)." + default = [] +} + +################################################################################ +# Engine Configuration +################################################################################ + +variable "engine_version" { + type = string + description = "Redis engine version." + default = "7.1" +} + +variable "node_type" { + type = string + description = "Node instance type." + default = "cache.t4g.micro" +} + +variable "description" { + type = string + description = "Description of the replication group." + default = "" +} + +################################################################################ +# Cluster Configuration +################################################################################ + +variable "num_cache_clusters" { + type = number + description = "Number of cache clusters (nodes)." + default = 1 + + validation { + condition = var.num_cache_clusters >= 1 && var.num_cache_clusters <= 6 + error_message = "Number of cache clusters must be between 1 and 6." + } +} + +variable "automatic_failover_enabled" { + type = bool + description = "Enable automatic failover (requires num_cache_clusters >= 2)." + default = false +} + +variable "multi_az_enabled" { + type = bool + description = "Enable Multi-AZ (requires automatic_failover_enabled)." + default = false +} + +################################################################################ +# Security Configuration +################################################################################ + +variable "transit_encryption_enabled" { + type = bool + description = "Enable encryption in transit." + default = true +} + +variable "auth_token" { + type = string + description = "Auth token for Redis AUTH (requires transit_encryption_enabled)." + default = null + sensitive = true +} + +variable "kms_key_id" { + type = string + description = "KMS key ID for at-rest encryption. Uses default AWS key if not specified." + default = null +} + +################################################################################ +# Maintenance Configuration +################################################################################ + +variable "auto_minor_version_upgrade" { + type = bool + description = "Enable automatic minor version upgrades." + default = true +} + +variable "apply_immediately" { + type = bool + description = "Apply changes immediately instead of during maintenance window." + default = false +} + +variable "maintenance_window" { + type = string + description = "Maintenance window (UTC)." + default = "sun:05:00-sun:06:00" +} + +################################################################################ +# Snapshot Configuration +################################################################################ + +variable "snapshot_retention_limit" { + type = number + description = "Days to retain snapshots (0 to disable)." + default = 7 + + validation { + condition = var.snapshot_retention_limit >= 0 && var.snapshot_retention_limit <= 35 + error_message = "Snapshot retention must be between 0 and 35 days." + } +} + +variable "snapshot_window" { + type = string + description = "Daily snapshot window (UTC)." + default = "03:00-04:00" +} + +variable "skip_final_snapshot" { + type = bool + description = "Skip final snapshot on destroy." + default = false +} + +################################################################################ +# Parameter Group Configuration +################################################################################ + +variable "create_parameter_group" { + type = bool + description = "Create a custom parameter group." + default = false +} + +variable "parameters" { + type = list(object({ + name = string + value = string + })) + description = "List of Redis parameters to apply." + default = [] +} + +################################################################################ +# Notifications +################################################################################ + +variable "notification_topic_arn" { + type = string + description = "SNS topic ARN for notifications." + default = null +} + +################################################################################ +# Tags +################################################################################ + +variable "tags" { + type = map(string) + description = "Tags to apply to Redis resources." + default = {} +} diff --git a/terraform/modules/elasticache_redis/versions.tf b/terraform/modules/elasticache_redis/versions.tf new file mode 100644 index 0000000000..2ca9c8626e --- /dev/null +++ b/terraform/modules/elasticache_redis/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.80.0" + } + } +} diff --git a/terraform/modules/network/main.tf b/terraform/modules/network/main.tf new file mode 100644 index 0000000000..3e5cae9b71 --- /dev/null +++ b/terraform/modules/network/main.tf @@ -0,0 +1,318 @@ +################################################################################ +# VPC +################################################################################ + +resource "aws_vpc" "this" { + cidr_block = var.cidr_block + enable_dns_support = true + enable_dns_hostnames = true + + tags = merge(var.tags, { + Name = "${var.name}-vpc" + }) +} + +################################################################################ +# Internet Gateway +################################################################################ + +resource "aws_internet_gateway" "this" { + vpc_id = aws_vpc.this.id + + tags = merge(var.tags, { + Name = "${var.name}-igw" + }) +} + +################################################################################ +# Public Subnets +################################################################################ + +resource "aws_subnet" "public" { + for_each = var.public_subnets + + vpc_id = aws_vpc.this.id + cidr_block = each.value.cidr_block + availability_zone = each.value.az + map_public_ip_on_launch = true + + tags = merge(var.tags, { + Name = "${var.name}-public-${each.key}" + "kubernetes.io/role/elb" = "1" + Type = "public" + }) +} + +################################################################################ +# Private Subnets +################################################################################ + +resource "aws_subnet" "private" { + for_each = var.private_subnets + + vpc_id = aws_vpc.this.id + cidr_block = each.value.cidr_block + availability_zone = each.value.az + + tags = merge(var.tags, { + Name = "${var.name}-private-${each.key}" + "kubernetes.io/role/internal-elb" = "1" + Type = "private" + }) +} + +################################################################################ +# NAT Gateways +################################################################################ + +resource "aws_eip" "nat" { + for_each = var.enable_nat_gateway ? (var.single_nat_gateway ? { "single" = aws_subnet.public[keys(aws_subnet.public)[0]] } : aws_subnet.public) : {} + + domain = "vpc" + + tags = merge(var.tags, { + Name = "${var.name}-nat-${each.key}" + }) + + depends_on = [aws_internet_gateway.this] +} + +resource "aws_nat_gateway" "this" { + for_each = var.enable_nat_gateway ? (var.single_nat_gateway ? { "single" = aws_subnet.public[keys(aws_subnet.public)[0]] } : aws_subnet.public) : {} + + allocation_id = aws_eip.nat[each.key].id + subnet_id = each.value.id + + tags = merge(var.tags, { + Name = "${var.name}-nat-${each.key}" + }) + + depends_on = [aws_internet_gateway.this] +} + +################################################################################ +# Route Tables +################################################################################ + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.this.id + + tags = merge(var.tags, { + Name = "${var.name}-public" + }) +} + +resource "aws_route" "public_internet_gateway" { + route_table_id = aws_route_table.public.id + destination_cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.this.id +} + +resource "aws_route_table_association" "public" { + for_each = aws_subnet.public + subnet_id = each.value.id + route_table_id = aws_route_table.public.id +} + +resource "aws_route_table" "private" { + for_each = var.enable_nat_gateway ? (var.single_nat_gateway ? { "single" = null } : aws_subnet.private) : aws_subnet.private + + vpc_id = aws_vpc.this.id + + tags = merge(var.tags, { + Name = "${var.name}-private-${each.key}" + }) +} + +resource "aws_route" "private_nat_gateway" { + for_each = var.enable_nat_gateway ? aws_route_table.private : {} + + route_table_id = each.value.id + destination_cidr_block = "0.0.0.0/0" + nat_gateway_id = var.single_nat_gateway ? aws_nat_gateway.this["single"].id : aws_nat_gateway.this[each.key].id +} + +resource "aws_route_table_association" "private" { + for_each = aws_subnet.private + subnet_id = each.value.id + route_table_id = var.single_nat_gateway && var.enable_nat_gateway ? aws_route_table.private["single"].id : aws_route_table.private[each.key].id +} + +################################################################################ +# VPC Endpoints (Cost Optimization) +################################################################################ + +resource "aws_vpc_endpoint" "s3" { + count = var.enable_s3_endpoint ? 1 : 0 + + vpc_id = aws_vpc.this.id + service_name = "com.amazonaws.${data.aws_region.current.id}.s3" + vpc_endpoint_type = "Gateway" + route_table_ids = concat([aws_route_table.public.id], [for rt in aws_route_table.private : rt.id]) + + tags = merge(var.tags, { + Name = "${var.name}-s3-endpoint" + }) +} + +resource "aws_vpc_endpoint" "ecr_api" { + count = var.enable_ecr_endpoints ? 1 : 0 + + vpc_id = aws_vpc.this.id + service_name = "com.amazonaws.${data.aws_region.current.id}.ecr.api" + vpc_endpoint_type = "Interface" + subnet_ids = [for s in aws_subnet.private : s.id] + security_group_ids = [aws_security_group.vpc_endpoints[0].id] + private_dns_enabled = true + + tags = merge(var.tags, { + Name = "${var.name}-ecr-api-endpoint" + }) +} + +resource "aws_vpc_endpoint" "ecr_dkr" { + count = var.enable_ecr_endpoints ? 1 : 0 + + vpc_id = aws_vpc.this.id + service_name = "com.amazonaws.${data.aws_region.current.id}.ecr.dkr" + vpc_endpoint_type = "Interface" + subnet_ids = [for s in aws_subnet.private : s.id] + security_group_ids = [aws_security_group.vpc_endpoints[0].id] + private_dns_enabled = true + + tags = merge(var.tags, { + Name = "${var.name}-ecr-dkr-endpoint" + }) +} + +resource "aws_vpc_endpoint" "logs" { + count = var.enable_logs_endpoint ? 1 : 0 + + vpc_id = aws_vpc.this.id + service_name = "com.amazonaws.${data.aws_region.current.id}.logs" + vpc_endpoint_type = "Interface" + subnet_ids = [for s in aws_subnet.private : s.id] + security_group_ids = [aws_security_group.vpc_endpoints[0].id] + private_dns_enabled = true + + tags = merge(var.tags, { + Name = "${var.name}-logs-endpoint" + }) +} + +resource "aws_vpc_endpoint" "secretsmanager" { + count = var.enable_secretsmanager_endpoint ? 1 : 0 + + vpc_id = aws_vpc.this.id + service_name = "com.amazonaws.${data.aws_region.current.id}.secretsmanager" + vpc_endpoint_type = "Interface" + subnet_ids = [for s in aws_subnet.private : s.id] + security_group_ids = [aws_security_group.vpc_endpoints[0].id] + private_dns_enabled = true + + tags = merge(var.tags, { + Name = "${var.name}-secretsmanager-endpoint" + }) +} + +resource "aws_security_group" "vpc_endpoints" { + count = var.enable_ecr_endpoints || var.enable_logs_endpoint || var.enable_secretsmanager_endpoint ? 1 : 0 + + name = "${var.name}-vpc-endpoints" + description = "Security group for VPC endpoints" + vpc_id = aws_vpc.this.id + + ingress { + description = "HTTPS from VPC" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = [aws_vpc.this.cidr_block] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge(var.tags, { + Name = "${var.name}-vpc-endpoints-sg" + }) +} + +################################################################################ +# Flow Logs (Optional) +################################################################################ + +resource "aws_flow_log" "this" { + count = var.enable_flow_logs ? 1 : 0 + + vpc_id = aws_vpc.this.id + traffic_type = "ALL" + iam_role_arn = aws_iam_role.flow_logs[0].arn + log_destination_type = "cloud-watch-logs" + log_destination = aws_cloudwatch_log_group.flow_logs[0].arn + max_aggregation_interval = 60 + + tags = merge(var.tags, { + Name = "${var.name}-flow-logs" + }) +} + +resource "aws_cloudwatch_log_group" "flow_logs" { + count = var.enable_flow_logs ? 1 : 0 + + name = "/aws/vpc/${var.name}/flow-logs" + retention_in_days = var.flow_logs_retention_days + + tags = var.tags +} + +resource "aws_iam_role" "flow_logs" { + count = var.enable_flow_logs ? 1 : 0 + + name = "${var.name}-flow-logs-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "vpc-flow-logs.amazonaws.com" + } + }] + }) + + tags = var.tags +} + +resource "aws_iam_role_policy" "flow_logs" { + count = var.enable_flow_logs ? 1 : 0 + + name = "${var.name}-flow-logs-policy" + role = aws_iam_role.flow_logs[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:DescribeLogGroups", + "logs:DescribeLogStreams" + ] + Effect = "Allow" + Resource = "*" + }] + }) +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_region" "current" {} diff --git a/terraform/modules/network/outputs.tf b/terraform/modules/network/outputs.tf new file mode 100644 index 0000000000..1ca8354cb1 --- /dev/null +++ b/terraform/modules/network/outputs.tf @@ -0,0 +1,54 @@ +output "vpc_id" { + description = "The ID of the VPC" + value = aws_vpc.this.id +} + +output "vpc_arn" { + description = "The ARN of the VPC" + value = aws_vpc.this.arn +} + +output "vpc_cidr_block" { + description = "The CIDR block of the VPC" + value = aws_vpc.this.cidr_block +} + +output "public_subnet_ids" { + description = "List of public subnet IDs" + value = [for s in aws_subnet.public : s.id] +} + +output "private_subnet_ids" { + description = "List of private subnet IDs" + value = [for s in aws_subnet.private : s.id] +} + +output "public_subnets" { + description = "Map of public subnet objects" + value = aws_subnet.public +} + +output "private_subnets" { + description = "Map of private subnet objects" + value = aws_subnet.private +} + +output "nat_gateway_ids" { + description = "List of NAT Gateway IDs" + value = [for nat in aws_nat_gateway.this : nat.id] +} + +output "internet_gateway_id" { + description = "The ID of the Internet Gateway" + value = aws_internet_gateway.this.id +} + +output "public_route_table_id" { + description = "The ID of the public route table" + value = aws_route_table.public.id +} + +output "private_route_table_ids" { + description = "Map of private route table IDs" + value = { for k, rt in aws_route_table.private : k => rt.id } +} diff --git a/terraform/modules/network/variables.tf b/terraform/modules/network/variables.tf new file mode 100644 index 0000000000..b2555fd3bd --- /dev/null +++ b/terraform/modules/network/variables.tf @@ -0,0 +1,116 @@ +variable "name" { + type = string + description = "Name prefix for networking resources." + + validation { + condition = can(regex("^[a-z0-9-]+$", var.name)) + error_message = "Name must contain only lowercase letters, numbers, and hyphens." + } +} + +variable "cidr_block" { + type = string + description = "CIDR block for the VPC." + + validation { + condition = can(cidrhost(var.cidr_block, 0)) + error_message = "Must be a valid CIDR block." + } +} + +variable "public_subnets" { + description = "Map of public subnet definitions." + type = map(object({ + cidr_block = string + az = string + })) + + validation { + condition = length(var.public_subnets) >= 1 + error_message = "At least one public subnet must be defined." + } +} + +variable "private_subnets" { + description = "Map of private subnet definitions." + type = map(object({ + cidr_block = string + az = string + })) + + validation { + condition = length(var.private_subnets) >= 1 + error_message = "At least one private subnet must be defined." + } +} + +variable "tags" { + type = map(string) + description = "Tags to apply to networking resources." + default = {} +} + +################################################################################ +# NAT Gateway Options +################################################################################ + +variable "enable_nat_gateway" { + type = bool + description = "Enable NAT Gateway for private subnets." + default = true +} + +variable "single_nat_gateway" { + type = bool + description = "Use a single NAT Gateway for all private subnets (cost saving for non-prod)." + default = false +} + +################################################################################ +# VPC Endpoints (Cost Optimization) +################################################################################ + +variable "enable_s3_endpoint" { + type = bool + description = "Enable S3 Gateway endpoint (free, reduces NAT costs)." + default = true +} + +variable "enable_ecr_endpoints" { + type = bool + description = "Enable ECR Interface endpoints for private container pulls." + default = false +} + +variable "enable_logs_endpoint" { + type = bool + description = "Enable CloudWatch Logs Interface endpoint." + default = false +} + +variable "enable_secretsmanager_endpoint" { + type = bool + description = "Enable Secrets Manager Interface endpoint." + default = false +} + +################################################################################ +# Flow Logs +################################################################################ + +variable "enable_flow_logs" { + type = bool + description = "Enable VPC Flow Logs." + default = false +} + +variable "flow_logs_retention_days" { + type = number + description = "Number of days to retain flow logs in CloudWatch." + default = 14 + + validation { + condition = contains([0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653], var.flow_logs_retention_days) + error_message = "Flow logs retention must be a valid CloudWatch Logs retention value." + } +} diff --git a/terraform/modules/network/versions.tf b/terraform/modules/network/versions.tf new file mode 100644 index 0000000000..2ca9c8626e --- /dev/null +++ b/terraform/modules/network/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.80.0" + } + } +} diff --git a/terraform/modules/rds/main.tf b/terraform/modules/rds/main.tf deleted file mode 100644 index 8b6486dd57..0000000000 --- a/terraform/modules/rds/main.tf +++ /dev/null @@ -1,33 +0,0 @@ -resource "aws_db_subnet_group" "this" { - name = "${var.environment}-rds-dsg" - subnet_ids = var.subnet_ids -} - -resource "aws_security_group" "this" { - vpc_id = var.vpc_id - name = "${var.environment}-rds-sg" - ingress { - from_port = 5432 - to_port = 5432 - protocol = "tcp" - cidr_blocks = [var.cidr_block] - } -} - -resource "aws_db_instance" "this" { - identifier = "${var.database_name}-${var.environment}" - allocated_storage = var.allocated_storage - engine = "postgres" - engine_version = "16.4" - instance_class = var.instance_class - multi_az = var.multi_az - db_name = var.database_name - username = var.database_username - password = var.database_password - db_subnet_group_name = aws_db_subnet_group.this.name - vpc_security_group_ids = [aws_security_group.this.id] - skip_final_snapshot = true - storage_encrypted = true - backup_retention_period = var.backup_retention_period - auto_minor_version_upgrade = true -} diff --git a/terraform/modules/rds/outputs.tf b/terraform/modules/rds/outputs.tf deleted file mode 100644 index 566477be2c..0000000000 --- a/terraform/modules/rds/outputs.tf +++ /dev/null @@ -1,11 +0,0 @@ -output "db_instance_address" { - value = aws_db_instance.this.address -} - -output "db_instance_id" { - value = aws_db_instance.this.id -} - -output "connection_string" { - value = "Server=${aws_db_instance.this.address};Port=5432;Database=${var.database_name};User Id=${var.database_username};Password=${var.database_password}" -} diff --git a/terraform/modules/rds/variables.tf b/terraform/modules/rds/variables.tf deleted file mode 100644 index 293f60aa29..0000000000 --- a/terraform/modules/rds/variables.tf +++ /dev/null @@ -1,39 +0,0 @@ -variable "environment" { -} - -variable "subnet_ids" { - type = list(string) -} - -variable "vpc_id" { -} - -variable "allocated_storage" { - default = 10 -} - -variable "instance_class" { - default = "db.t3.micro" -} - -variable "multi_az" { - default = false -} - -variable "database_name" { -} - -variable "database_username" { - default = "superuser" -} - -variable "database_password" { - default = "123Pa$$word!" -} - -variable "cidr_block" { -} - -variable "backup_retention_period" { - default = 10 -} diff --git a/terraform/modules/rds_postgres/main.tf b/terraform/modules/rds_postgres/main.tf new file mode 100644 index 0000000000..59aec787a3 --- /dev/null +++ b/terraform/modules/rds_postgres/main.tf @@ -0,0 +1,189 @@ +################################################################################ +# DB Subnet Group +################################################################################ + +resource "aws_db_subnet_group" "this" { + name = "${var.name}-subnets" + subnet_ids = var.subnet_ids + + tags = merge(var.tags, { + Name = "${var.name}-subnet-group" + }) +} + +################################################################################ +# Security Group +################################################################################ + +resource "aws_security_group" "this" { + name = "${var.name}-sg" + description = "Security group for RDS PostgreSQL ${var.name}" + vpc_id = var.vpc_id + + tags = merge(var.tags, { + Name = "${var.name}-rds-sg" + }) +} + +resource "aws_vpc_security_group_ingress_rule" "postgres_sg" { + for_each = toset(var.allowed_security_group_ids) + + security_group_id = aws_security_group.this.id + description = "PostgreSQL access from allowed security group" + from_port = 5432 + to_port = 5432 + ip_protocol = "tcp" + referenced_security_group_id = each.value + + tags = var.tags +} + +resource "aws_vpc_security_group_ingress_rule" "postgres_cidr" { + count = length(var.allowed_cidr_blocks) + + security_group_id = aws_security_group.this.id + description = "PostgreSQL access from allowed CIDR block" + from_port = 5432 + to_port = 5432 + ip_protocol = "tcp" + cidr_ipv4 = var.allowed_cidr_blocks[count.index] + + tags = var.tags +} + +resource "aws_vpc_security_group_egress_rule" "all" { + security_group_id = aws_security_group.this.id + description = "Allow all outbound traffic" + ip_protocol = "-1" + cidr_ipv4 = "0.0.0.0/0" + + tags = var.tags +} + +################################################################################ +# Parameter Group (Optional) +################################################################################ + +resource "aws_db_parameter_group" "this" { + count = var.create_parameter_group ? 1 : 0 + + name = "${var.name}-params" + family = "postgres${split(".", var.engine_version)[0]}" + + dynamic "parameter" { + for_each = var.parameters + content { + name = parameter.value.name + value = parameter.value.value + apply_method = parameter.value.apply_method + } + } + + tags = var.tags + + lifecycle { + create_before_destroy = true + } +} + +################################################################################ +# RDS Instance +################################################################################ + +resource "aws_db_instance" "this" { + identifier = var.name + + # Engine + engine = "postgres" + engine_version = var.engine_version + + # Instance + instance_class = var.instance_class + allocated_storage = var.allocated_storage + max_allocated_storage = var.max_allocated_storage + storage_type = var.storage_type + iops = var.storage_type == "io1" || var.storage_type == "io2" ? var.iops : null + + # Database + db_name = var.db_name + + # Credentials - Support both managed and manual password + username = var.username + password = var.manage_master_user_password ? null : var.password + manage_master_user_password = var.manage_master_user_password + + # Network + db_subnet_group_name = aws_db_subnet_group.this.name + vpc_security_group_ids = [aws_security_group.this.id] + publicly_accessible = false + port = 5432 + + # Parameters + parameter_group_name = var.create_parameter_group ? aws_db_parameter_group.this[0].name : null + + # Storage Encryption + storage_encrypted = true + kms_key_id = var.kms_key_id + + # High Availability + multi_az = var.multi_az + + # Backup + backup_retention_period = var.backup_retention_period + backup_window = var.backup_window + maintenance_window = var.maintenance_window + copy_tags_to_snapshot = true + delete_automated_backups = var.delete_automated_backups + final_snapshot_identifier = var.skip_final_snapshot ? null : coalesce(var.final_snapshot_identifier, "${var.name}-final-snapshot") + skip_final_snapshot = var.skip_final_snapshot + + # Monitoring + performance_insights_enabled = var.performance_insights_enabled + performance_insights_retention_period = var.performance_insights_enabled ? var.performance_insights_retention_period : null + monitoring_interval = var.monitoring_interval + monitoring_role_arn = var.monitoring_interval > 0 ? aws_iam_role.rds_monitoring[0].arn : null + + # Upgrades + auto_minor_version_upgrade = var.auto_minor_version_upgrade + allow_major_version_upgrade = var.allow_major_version_upgrade + apply_immediately = var.apply_immediately + + # Deletion Protection + deletion_protection = var.deletion_protection + + tags = var.tags + + lifecycle { + ignore_changes = [password] + } +} + +################################################################################ +# Enhanced Monitoring IAM Role +################################################################################ + +resource "aws_iam_role" "rds_monitoring" { + count = var.monitoring_interval > 0 ? 1 : 0 + + name = "${var.name}-rds-monitoring" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "monitoring.rds.amazonaws.com" + } + }] + }) + + tags = var.tags +} + +resource "aws_iam_role_policy_attachment" "rds_monitoring" { + count = var.monitoring_interval > 0 ? 1 : 0 + + role = aws_iam_role.rds_monitoring[0].name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonRDSEnhancedMonitoringRole" +} diff --git a/terraform/modules/rds_postgres/outputs.tf b/terraform/modules/rds_postgres/outputs.tf new file mode 100644 index 0000000000..0d71862e32 --- /dev/null +++ b/terraform/modules/rds_postgres/outputs.tf @@ -0,0 +1,45 @@ +output "endpoint" { + description = "The connection endpoint address" + value = aws_db_instance.this.address +} + +output "port" { + description = "The database port" + value = aws_db_instance.this.port +} + +output "identifier" { + description = "The RDS instance identifier" + value = aws_db_instance.this.identifier +} + +output "arn" { + description = "The ARN of the RDS instance" + value = aws_db_instance.this.arn +} + +output "db_name" { + description = "The database name" + value = aws_db_instance.this.db_name +} + +output "security_group_id" { + description = "The ID of the RDS security group" + value = aws_security_group.this.id +} + +output "db_subnet_group_name" { + description = "The name of the DB subnet group" + value = aws_db_subnet_group.this.name +} + +output "connection_string" { + description = "PostgreSQL connection string (without password)" + value = "Host=${aws_db_instance.this.address};Port=${aws_db_instance.this.port};Database=${aws_db_instance.this.db_name};Username=${var.username}" + sensitive = true +} + +output "secret_arn" { + description = "The ARN of the Secrets Manager secret (if managed password is enabled)" + value = var.manage_master_user_password ? aws_db_instance.this.master_user_secret[0].secret_arn : null +} diff --git a/terraform/modules/rds_postgres/variables.tf b/terraform/modules/rds_postgres/variables.tf new file mode 100644 index 0000000000..a958629829 --- /dev/null +++ b/terraform/modules/rds_postgres/variables.tf @@ -0,0 +1,287 @@ +################################################################################ +# Required Variables +################################################################################ + +variable "name" { + type = string + description = "Name identifier for the RDS instance." + + validation { + condition = can(regex("^[a-z][a-z0-9-]*$", var.name)) + error_message = "Name must start with a letter and contain only lowercase letters, numbers, and hyphens." + } +} + +variable "vpc_id" { + type = string + description = "VPC ID for RDS." +} + +variable "subnet_ids" { + type = list(string) + description = "Subnets for RDS subnet group." + + validation { + condition = length(var.subnet_ids) >= 2 + error_message = "At least two subnets in different AZs are required." + } +} + +variable "allowed_security_group_ids" { + type = list(string) + description = "Security groups allowed to access RDS (use when SG IDs are known at plan time)." + default = [] +} + +variable "allowed_cidr_blocks" { + type = list(string) + description = "CIDR blocks allowed to access RDS (use when security groups are not yet created)." + default = [] +} + +variable "db_name" { + type = string + description = "Database name." + + validation { + condition = can(regex("^[a-zA-Z][a-zA-Z0-9_]*$", var.db_name)) + error_message = "Database name must start with a letter and contain only alphanumeric characters and underscores." + } +} + +variable "username" { + type = string + description = "Database admin username." + sensitive = true + + validation { + condition = can(regex("^[a-zA-Z][a-zA-Z0-9_]*$", var.username)) + error_message = "Username must start with a letter and contain only alphanumeric characters and underscores." + } +} + +################################################################################ +# Password Options +################################################################################ + +variable "password" { + type = string + description = "Database admin password. Required if manage_master_user_password is false." + default = null + sensitive = true +} + +variable "manage_master_user_password" { + type = bool + description = "Use AWS Secrets Manager to manage the master password." + default = false +} + +################################################################################ +# Engine Configuration +################################################################################ + +variable "engine_version" { + type = string + description = "PostgreSQL engine version." + default = "16" +} + +variable "instance_class" { + type = string + description = "RDS instance class." + default = "db.t4g.micro" +} + +################################################################################ +# Storage Configuration +################################################################################ + +variable "allocated_storage" { + type = number + description = "Allocated storage in GB." + default = 20 + + validation { + condition = var.allocated_storage >= 20 + error_message = "Allocated storage must be at least 20 GB." + } +} + +variable "max_allocated_storage" { + type = number + description = "Maximum allocated storage for autoscaling (0 to disable)." + default = 100 +} + +variable "storage_type" { + type = string + description = "Storage type (gp2, gp3, io1, io2)." + default = "gp3" + + validation { + condition = contains(["gp2", "gp3", "io1", "io2"], var.storage_type) + error_message = "Storage type must be gp2, gp3, io1, or io2." + } +} + +variable "iops" { + type = number + description = "Provisioned IOPS (for io1/io2 storage)." + default = null +} + +variable "kms_key_id" { + type = string + description = "KMS key ID for storage encryption. Uses default AWS key if not specified." + default = null +} + +################################################################################ +# Backup Configuration +################################################################################ + +variable "backup_retention_period" { + type = number + description = "Backup retention period in days." + default = 7 + + validation { + condition = var.backup_retention_period >= 0 && var.backup_retention_period <= 35 + error_message = "Backup retention period must be between 0 and 35 days." + } +} + +variable "backup_window" { + type = string + description = "Preferred backup window (UTC)." + default = "03:00-04:00" +} + +variable "maintenance_window" { + type = string + description = "Preferred maintenance window (UTC)." + default = "sun:05:00-sun:06:00" +} + +variable "skip_final_snapshot" { + type = bool + description = "Skip final snapshot on destroy." + default = false +} + +variable "final_snapshot_identifier" { + type = string + description = "Name of the final snapshot. Auto-generated from instance name if not specified." + default = null +} + +variable "delete_automated_backups" { + type = bool + description = "Delete automated backups on instance deletion." + default = true +} + +################################################################################ +# High Availability Configuration +################################################################################ + +variable "multi_az" { + type = bool + description = "Enable Multi-AZ deployment for high availability." + default = false +} + +################################################################################ +# Monitoring Configuration +################################################################################ + +variable "performance_insights_enabled" { + type = bool + description = "Enable Performance Insights." + default = false +} + +variable "performance_insights_retention_period" { + type = number + description = "Performance Insights retention period (7 or 731 days)." + default = 7 + + validation { + condition = contains([7, 731], var.performance_insights_retention_period) + error_message = "Performance Insights retention must be 7 or 731 days." + } +} + +variable "monitoring_interval" { + type = number + description = "Enhanced Monitoring interval in seconds (0 to disable, 1, 5, 10, 15, 30, or 60)." + default = 0 + + validation { + condition = contains([0, 1, 5, 10, 15, 30, 60], var.monitoring_interval) + error_message = "Monitoring interval must be 0, 1, 5, 10, 15, 30, or 60 seconds." + } +} + +################################################################################ +# Upgrade Configuration +################################################################################ + +variable "auto_minor_version_upgrade" { + type = bool + description = "Enable automatic minor version upgrades." + default = true +} + +variable "allow_major_version_upgrade" { + type = bool + description = "Allow major version upgrades." + default = false +} + +variable "apply_immediately" { + type = bool + description = "Apply changes immediately instead of during maintenance window." + default = false +} + +################################################################################ +# Protection Configuration +################################################################################ + +variable "deletion_protection" { + type = bool + description = "Enable deletion protection." + default = false +} + +################################################################################ +# Parameter Group Configuration +################################################################################ + +variable "create_parameter_group" { + type = bool + description = "Create a custom parameter group." + default = false +} + +variable "parameters" { + type = list(object({ + name = string + value = string + apply_method = optional(string, "immediate") + })) + description = "List of DB parameters to apply." + default = [] +} + +################################################################################ +# Tags +################################################################################ + +variable "tags" { + type = map(string) + description = "Tags to apply to RDS resources." + default = {} +} diff --git a/terraform/modules/rds_postgres/versions.tf b/terraform/modules/rds_postgres/versions.tf new file mode 100644 index 0000000000..c82884e29b --- /dev/null +++ b/terraform/modules/rds_postgres/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.80.0" + } + random = { + source = "hashicorp/random" + version = ">= 3.6.0" + } + } +} diff --git a/terraform/modules/s3/main.tf b/terraform/modules/s3/main.tf deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/terraform/modules/s3/variables.tf b/terraform/modules/s3/variables.tf deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/terraform/modules/s3_bucket/main.tf b/terraform/modules/s3_bucket/main.tf new file mode 100644 index 0000000000..f692db9f3c --- /dev/null +++ b/terraform/modules/s3_bucket/main.tf @@ -0,0 +1,280 @@ +################################################################################ +# S3 Bucket +################################################################################ + +resource "aws_s3_bucket" "this" { + bucket = var.name + force_destroy = var.force_destroy + + tags = merge(var.tags, { + Name = var.name + }) +} + +################################################################################ +# Bucket Ownership Controls +################################################################################ + +resource "aws_s3_bucket_ownership_controls" "this" { + bucket = aws_s3_bucket.this.id + + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +################################################################################ +# Versioning +################################################################################ + +resource "aws_s3_bucket_versioning" "this" { + bucket = aws_s3_bucket.this.id + + versioning_configuration { + status = var.versioning_enabled ? "Enabled" : "Suspended" + } +} + +################################################################################ +# Server-Side Encryption +################################################################################ + +resource "aws_s3_bucket_server_side_encryption_configuration" "this" { + bucket = aws_s3_bucket.this.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = var.kms_key_arn != null ? "aws:kms" : "AES256" + kms_master_key_id = var.kms_key_arn + } + bucket_key_enabled = var.kms_key_arn != null + } +} + +################################################################################ +# Public Access Block +################################################################################ + +resource "aws_s3_bucket_public_access_block" "this" { + bucket = aws_s3_bucket.this.id + block_public_acls = true + ignore_public_acls = true + block_public_policy = var.enable_public_read ? false : true + restrict_public_buckets = var.enable_public_read ? false : true +} + +################################################################################ +# Lifecycle Rules +################################################################################ + +resource "aws_s3_bucket_lifecycle_configuration" "this" { + count = length(var.lifecycle_rules) > 0 || var.enable_intelligent_tiering ? 1 : 0 + + bucket = aws_s3_bucket.this.id + + dynamic "rule" { + for_each = var.lifecycle_rules + content { + id = rule.value.id + status = rule.value.enabled ? "Enabled" : "Disabled" + + filter { + prefix = rule.value.prefix + } + + dynamic "transition" { + for_each = rule.value.transitions + content { + days = transition.value.days + storage_class = transition.value.storage_class + } + } + + dynamic "expiration" { + for_each = rule.value.expiration_days != null ? [1] : [] + content { + days = rule.value.expiration_days + } + } + + dynamic "noncurrent_version_transition" { + for_each = rule.value.noncurrent_version_transitions + content { + noncurrent_days = noncurrent_version_transition.value.days + storage_class = noncurrent_version_transition.value.storage_class + } + } + + dynamic "noncurrent_version_expiration" { + for_each = rule.value.noncurrent_version_expiration_days != null ? [1] : [] + content { + noncurrent_days = rule.value.noncurrent_version_expiration_days + } + } + + dynamic "abort_incomplete_multipart_upload" { + for_each = rule.value.abort_incomplete_multipart_upload_days != null ? [1] : [] + content { + days_after_initiation = rule.value.abort_incomplete_multipart_upload_days + } + } + } + } + + dynamic "rule" { + for_each = var.enable_intelligent_tiering ? [1] : [] + content { + id = "intelligent-tiering" + status = "Enabled" + + filter { + prefix = "" + } + + transition { + storage_class = "INTELLIGENT_TIERING" + } + } + } + + depends_on = [aws_s3_bucket_versioning.this] +} + +################################################################################ +# CORS Configuration +################################################################################ + +resource "aws_s3_bucket_cors_configuration" "this" { + count = length(var.cors_rules) > 0 ? 1 : 0 + + bucket = aws_s3_bucket.this.id + + dynamic "cors_rule" { + for_each = var.cors_rules + content { + allowed_headers = cors_rule.value.allowed_headers + allowed_methods = cors_rule.value.allowed_methods + allowed_origins = cors_rule.value.allowed_origins + expose_headers = cors_rule.value.expose_headers + max_age_seconds = cors_rule.value.max_age_seconds + } + } +} + +################################################################################ +# CloudFront Origin Access Control +################################################################################ + +resource "aws_cloudfront_origin_access_control" "this" { + count = var.enable_cloudfront ? 1 : 0 + + name = "${aws_s3_bucket.this.bucket}-oac" + description = "Access control for ${aws_s3_bucket.this.bucket}" + origin_access_control_origin_type = "s3" + signing_behavior = "always" + signing_protocol = "sigv4" +} + +################################################################################ +# CloudFront Distribution +################################################################################ + +resource "aws_cloudfront_distribution" "this" { + count = var.enable_cloudfront ? 1 : 0 + + enabled = true + comment = var.cloudfront_comment != "" ? var.cloudfront_comment : "Public assets for ${aws_s3_bucket.this.bucket}" + price_class = var.cloudfront_price_class + default_root_object = var.cloudfront_default_root_object + aliases = var.cloudfront_aliases + http_version = "http2and3" + + origin { + domain_name = aws_s3_bucket.this.bucket_regional_domain_name + origin_id = "s3-${aws_s3_bucket.this.bucket}" + origin_access_control_id = aws_cloudfront_origin_access_control.this[0].id + } + + default_cache_behavior { + target_origin_id = "s3-${aws_s3_bucket.this.bucket}" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD"] + compress = true + + cache_policy_id = var.cloudfront_cache_policy_id + origin_request_policy_id = var.cloudfront_origin_request_policy_id + + dynamic "forwarded_values" { + for_each = var.cloudfront_cache_policy_id == null ? [1] : [] + content { + query_string = false + cookies { + forward = "none" + } + } + } + } + + restrictions { + geo_restriction { + restriction_type = var.cloudfront_geo_restriction_type + locations = var.cloudfront_geo_restriction_locations + } + } + + viewer_certificate { + cloudfront_default_certificate = var.cloudfront_acm_certificate_arn == null + acm_certificate_arn = var.cloudfront_acm_certificate_arn + ssl_support_method = var.cloudfront_acm_certificate_arn != null ? "sni-only" : null + minimum_protocol_version = var.cloudfront_acm_certificate_arn != null ? "TLSv1.2_2021" : null + } + + tags = var.tags +} + +################################################################################ +# Bucket Policy +################################################################################ + +locals { + bucket_policy_statements = concat( + var.enable_public_read && length(var.public_read_prefix) > 0 ? [ + { + Sid = "AllowPublicReadUploads" + Effect = "Allow" + Principal = "*" + Action = ["s3:GetObject"] + Resource = "arn:aws:s3:::${aws_s3_bucket.this.bucket}/${var.public_read_prefix}*" + } + ] : [], + var.enable_cloudfront ? [ + { + Sid = "AllowCloudFrontRead" + Effect = "Allow" + Principal = { + Service = "cloudfront.amazonaws.com" + } + Action = ["s3:GetObject"] + Resource = "arn:aws:s3:::${aws_s3_bucket.this.bucket}/*" + Condition = { + StringEquals = { + "AWS:SourceArn" = aws_cloudfront_distribution.this[0].arn + } + } + } + ] : [], + var.additional_bucket_policy_statements + ) +} + +resource "aws_s3_bucket_policy" "this" { + count = length(local.bucket_policy_statements) > 0 ? 1 : 0 + bucket = aws_s3_bucket.this.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = local.bucket_policy_statements + }) + + depends_on = [aws_s3_bucket_public_access_block.this] +} diff --git a/terraform/modules/s3_bucket/outputs.tf b/terraform/modules/s3_bucket/outputs.tf new file mode 100644 index 0000000000..0c01145ea0 --- /dev/null +++ b/terraform/modules/s3_bucket/outputs.tf @@ -0,0 +1,34 @@ +output "bucket_name" { + description = "The name of the S3 bucket" + value = aws_s3_bucket.this.id +} + +output "bucket_arn" { + description = "The ARN of the S3 bucket" + value = aws_s3_bucket.this.arn +} + +output "bucket_domain_name" { + description = "The bucket domain name" + value = aws_s3_bucket.this.bucket_domain_name +} + +output "bucket_regional_domain_name" { + description = "The bucket region-specific domain name" + value = aws_s3_bucket.this.bucket_regional_domain_name +} + +output "cloudfront_domain_name" { + description = "CloudFront domain for public access (when enabled)" + value = var.enable_cloudfront ? aws_cloudfront_distribution.this[0].domain_name : "" +} + +output "cloudfront_distribution_id" { + description = "CloudFront distribution ID (when enabled)" + value = var.enable_cloudfront ? aws_cloudfront_distribution.this[0].id : "" +} + +output "cloudfront_distribution_arn" { + description = "CloudFront distribution ARN (when enabled)" + value = var.enable_cloudfront ? aws_cloudfront_distribution.this[0].arn : "" +} diff --git a/terraform/modules/s3_bucket/variables.tf b/terraform/modules/s3_bucket/variables.tf new file mode 100644 index 0000000000..610320448e --- /dev/null +++ b/terraform/modules/s3_bucket/variables.tf @@ -0,0 +1,192 @@ +################################################################################ +# Required Variables +################################################################################ + +variable "name" { + type = string + description = "Bucket name (must be globally unique)." + + validation { + condition = can(regex("^[a-z0-9][a-z0-9.-]*[a-z0-9]$", var.name)) + error_message = "Bucket name must contain only lowercase letters, numbers, hyphens, and periods, and must start and end with a letter or number." + } +} + +################################################################################ +# Bucket Configuration +################################################################################ + +variable "force_destroy" { + type = bool + description = "Allow bucket destruction even if not empty." + default = false +} + +variable "versioning_enabled" { + type = bool + description = "Enable versioning." + default = true +} + +variable "kms_key_arn" { + type = string + description = "KMS key ARN for server-side encryption. Uses AES256 if not specified." + default = null +} + +################################################################################ +# Public Access Configuration +################################################################################ + +variable "enable_public_read" { + type = bool + description = "Set to true to allow public read on the specified prefix via bucket policy." + default = false +} + +variable "public_read_prefix" { + type = string + description = "Prefix to allow public read (e.g., uploads/). Leave empty to disable public policy." + default = "uploads/" +} + +################################################################################ +# Lifecycle Rules +################################################################################ + +variable "enable_intelligent_tiering" { + type = bool + description = "Enable automatic transition to Intelligent-Tiering." + default = false +} + +variable "lifecycle_rules" { + type = list(object({ + id = string + enabled = optional(bool, true) + prefix = optional(string, "") + expiration_days = optional(number) + noncurrent_version_expiration_days = optional(number) + abort_incomplete_multipart_upload_days = optional(number, 7) + transitions = optional(list(object({ + days = number + storage_class = string + })), []) + noncurrent_version_transitions = optional(list(object({ + days = number + storage_class = string + })), []) + })) + description = "List of lifecycle rules." + default = [] +} + +################################################################################ +# CORS Configuration +################################################################################ + +variable "cors_rules" { + type = list(object({ + allowed_headers = optional(list(string), ["*"]) + allowed_methods = list(string) + allowed_origins = list(string) + expose_headers = optional(list(string), []) + max_age_seconds = optional(number, 3000) + })) + description = "List of CORS rules." + default = [] +} + +################################################################################ +# CloudFront Configuration +################################################################################ + +variable "enable_cloudfront" { + type = bool + description = "Set to true to provision a CloudFront distribution in front of the bucket." + default = false +} + +variable "cloudfront_price_class" { + type = string + description = "CloudFront price class." + default = "PriceClass_100" + + validation { + condition = contains(["PriceClass_All", "PriceClass_200", "PriceClass_100"], var.cloudfront_price_class) + error_message = "Price class must be PriceClass_All, PriceClass_200, or PriceClass_100." + } +} + +variable "cloudfront_comment" { + type = string + description = "Optional comment for the CloudFront distribution." + default = "" +} + +variable "cloudfront_default_root_object" { + type = string + description = "Default root object for CloudFront." + default = "" +} + +variable "cloudfront_aliases" { + type = list(string) + description = "Alternative domain names (CNAMEs) for CloudFront." + default = [] +} + +variable "cloudfront_acm_certificate_arn" { + type = string + description = "ACM certificate ARN for CloudFront (required if using aliases)." + default = null +} + +variable "cloudfront_cache_policy_id" { + type = string + description = "CloudFront cache policy ID. Uses default if not specified." + default = null +} + +variable "cloudfront_origin_request_policy_id" { + type = string + description = "CloudFront origin request policy ID." + default = null +} + +variable "cloudfront_geo_restriction_type" { + type = string + description = "CloudFront geo restriction type (none, whitelist, blacklist)." + default = "none" + + validation { + condition = contains(["none", "whitelist", "blacklist"], var.cloudfront_geo_restriction_type) + error_message = "Geo restriction type must be none, whitelist, or blacklist." + } +} + +variable "cloudfront_geo_restriction_locations" { + type = list(string) + description = "Country codes for geo restriction." + default = [] +} + +################################################################################ +# Additional Bucket Policy +################################################################################ + +variable "additional_bucket_policy_statements" { + type = any + description = "Additional bucket policy statements." + default = [] +} + +################################################################################ +# Tags +################################################################################ + +variable "tags" { + type = map(string) + description = "Tags to apply to the bucket." + default = {} +} diff --git a/terraform/modules/s3_bucket/versions.tf b/terraform/modules/s3_bucket/versions.tf new file mode 100644 index 0000000000..2ca9c8626e --- /dev/null +++ b/terraform/modules/s3_bucket/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.80.0" + } + } +} diff --git a/terraform/modules/vpc/main.tf b/terraform/modules/vpc/main.tf deleted file mode 100644 index 829133550f..0000000000 --- a/terraform/modules/vpc/main.tf +++ /dev/null @@ -1,59 +0,0 @@ -resource "aws_vpc" "this" { - cidr_block = var.cidr_block - enable_dns_hostnames = true - enable_dns_support = true -} - -resource "aws_subnet" "private_a" { - vpc_id = aws_vpc.this.id - cidr_block = var.private_cidr_a - availability_zone = var.availability_zone_a -} - -resource "aws_subnet" "private_b" { - vpc_id = aws_vpc.this.id - cidr_block = var.private_cidr_b - availability_zone = var.availability_zone_b -} - -resource "aws_network_acl" "this" { - vpc_id = aws_vpc.this.id - - egress { - protocol = "-1" - rule_no = 100 - action = "allow" - cidr_block = "0.0.0.0/0" - from_port = 0 - to_port = 0 - } - - ingress { - protocol = "-1" - rule_no = 100 - action = "allow" - cidr_block = "0.0.0.0/0" - from_port = 0 - to_port = 0 - } -} - -resource "aws_network_acl_association" "private_a_association" { - subnet_id = aws_subnet.private_a.id - network_acl_id = aws_network_acl.this.id -} - -resource "aws_network_acl_association" "private_b_association" { - subnet_id = aws_subnet.private_b.id - network_acl_id = aws_network_acl.this.id -} - -resource "aws_internet_gateway" "this" { - vpc_id = aws_vpc.this.id -} - -resource "aws_route" "internet_access" { - route_table_id = aws_vpc.this.main_route_table_id - destination_cidr_block = "0.0.0.0/0" - gateway_id = aws_internet_gateway.this.id -} diff --git a/terraform/modules/vpc/outputs.tf b/terraform/modules/vpc/outputs.tf deleted file mode 100644 index 152d043c3b..0000000000 --- a/terraform/modules/vpc/outputs.tf +++ /dev/null @@ -1,15 +0,0 @@ -output "vpc_id" { - value = aws_vpc.this.id -} - -output "private_a_id" { - value = aws_subnet.private_a.id -} - -output "private_b_id" { - value = aws_subnet.private_b.id -} - -output "cidr_block" { - value = var.cidr_block -} diff --git a/terraform/modules/vpc/variables.tf b/terraform/modules/vpc/variables.tf deleted file mode 100644 index 74433e8f84..0000000000 --- a/terraform/modules/vpc/variables.tf +++ /dev/null @@ -1,23 +0,0 @@ -variable "cidr_block" { - type = string - default = "10.0.0.0/16" -} - -variable "private_cidr_a" { - type = string - default = "10.0.1.0/24" -} - -variable "private_cidr_b" { - type = string - default = "10.0.2.0/24" -} - -variable "availability_zone_a" { - type = string - default = "us-east-1a" -} -variable "availability_zone_b" { - type = string - default = "us-east-1b" -}