diff --git a/.aspire/settings.json b/.aspire/settings.json new file mode 100644 index 00000000..58364df9 --- /dev/null +++ b/.aspire/settings.json @@ -0,0 +1,3 @@ +{ + "appHostPath": "../SimpleModule.AppHost/SimpleModule.AppHost.csproj" +} \ No newline at end of file diff --git a/.claude/agents/generator-reviewer.md b/.claude/agents/generator-reviewer.md index 393e40f9..2b4e01f7 100644 --- a/.claude/agents/generator-reviewer.md +++ b/.claude/agents/generator-reviewer.md @@ -8,7 +8,5 @@ Review source generator code in SimpleModule.Generator for correctness and best 2. **Generator API**: Must use `IIncrementalGenerator`, not `ISourceGenerator` (enforced by `EnforceExtendedAnalyzerRules`) 3. **Symbol comparison**: Always use `SymbolEqualityComparer.Default.Equals()`, never `==` on symbols 4. **Fully qualified names**: Generated code must use `global::Namespace.Type` format from `SymbolDisplayFormat.FullyQualifiedFormat` -5. **No reflection**: Generated code must use direct `new` instantiation, no DI resolution of `IModule` -6. **Cross-assembly discovery**: Generator must scan `compilation.References` for module types, not just syntax in the current project -7. **Nullable handling**: Use `#nullable enable` in generated code or handle nulls explicitly -8. **AOT compliance**: Generated code must not use patterns that require runtime code generation +5. **Cross-assembly discovery**: Generator must scan `compilation.References` for module types, not just syntax in the current project +6. **Nullable handling**: Use `#nullable enable` in generated code or handle nulls explicitly diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c6c242a1..c94f1e7a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -29,7 +29,23 @@ "mcp__plugin_playwright_playwright__browser_press_key", "mcp__plugin_playwright_playwright__browser_run_code", "mcp__plugin_playwright_playwright__browser_network_requests", - "mcp__plugin_playwright_playwright__browser_console_messages" + "mcp__plugin_playwright_playwright__browser_console_messages", + "mcp__aspire__list_apphosts", + "mcp__aspire__select_apphost", + "mcp__aspire__list_resources", + "mcp__aspire__list_console_logs", + "mcp__aspire__execute_resource_command", + "mcp__playwright__browser_navigate", + "mcp__playwright__browser_click", + "mcp__playwright__browser_take_screenshot", + "mcp__playwright__browser_evaluate", + "mcp__playwright__browser_snapshot", + "mcp__playwright__browser_resize" ] - } + }, + "enableAllProjectMcpServers": true, + "enabledMcpjsonServers": [ + "aspire", + "playwright" + ] } diff --git a/.claude/skills/new-module/SKILL.md b/.claude/skills/new-module/SKILL.md index 5282eccb..e39a5724 100644 --- a/.claude/skills/new-module/SKILL.md +++ b/.claude/skills/new-module/SKILL.md @@ -65,7 +65,6 @@ public class Module : IModule 6. Run `dotnet build` to verify the source generator picks up the new module ## Important -- Do NOT add `PublishAot` or `EnableAotAnalyzer` to module class libraries - Always include `` - Use `[Module("")]` attribute on the module class - Use fully qualified ASP.NET Core usings (Builder, Http, Routing, DependencyInjection) diff --git a/.editorconfig b/.editorconfig index 727a724f..0a81d83c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -155,6 +155,10 @@ dotnet_diagnostic.IDE0072.severity = none [*.Generated.cs] generated_code = true +# EF Core migrations (auto-generated — suppress strict analysis) +[**/Migrations/*.cs] +generated_code = true + [*.g.cs] generated_code = true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd35a5e8..b5d2c63f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,12 @@ jobs: env: Database__DefaultConnection: "Host=localhost;Database=simplemodule_test;Username=test;Password=test" + - name: Verify migrations are up-to-date + working-directory: template/SimpleModule.Host + env: + Database__DefaultConnection: "Host=localhost;Database=simplemodule_test;Username=test;Password=test" + run: dotnet ef migrations has-pending-model-changes + publish: runs-on: ubuntu-latest needs: [test-sqlite, test-postgresql] @@ -84,5 +90,5 @@ jobs: with: dotnet-version: '10.0.x' - - name: Publish AOT + - name: Publish run: dotnet publish template/SimpleModule.Host/SimpleModule.Host.csproj -c Release diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..6983627d --- /dev/null +++ b/.mcp.json @@ -0,0 +1,18 @@ +{ + "mcpServers": { + "aspire": { + "command": "aspire", + "args": [ + "mcp", + "start" + ] + }, + "playwright": { + "command": "npx", + "args": [ + "-y", + "@playwright/mcp@latest" + ] + } + } +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..d14dbcb3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,61 @@ +# Copilot instructions + +This repository is set up to use Aspire. Aspire is an orchestrator for the entire application and will take care of configuring dependencies, building, and running the application. The resources that make up the application are defined in `apphost.cs` including application code and external dependencies. + +## General recommendations for working with Aspire +1. Before making any changes always run the apphost using `aspire run` and inspect the state of resources to make sure you are building from a known state. +1. Changes to the _apphost.cs_ file will require a restart of the application to take effect. +2. Make changes incrementally and run the aspire application using the `aspire run` command to validate changes. +3. Use the Aspire MCP tools to check the status of resources and debug issues. + +## Running the application +To run the application run the following command: + +``` +aspire run +``` + +If there is already an instance of the application running it will prompt to stop the existing instance. You only need to restart the application if code in `apphost.cs` is changed, but if you experience problems it can be useful to reset everything to the starting state. + +## Checking resources +To check the status of resources defined in the app model use the _list resources_ tool. This will show you the current state of each resource and if there are any issues. If a resource is not running as expected you can use the _execute resource command_ tool to restart it or perform other actions. + +## Listing integrations +IMPORTANT! When a user asks you to add a resource to the app model you should first use the _list integrations_ tool to get a list of the current versions of all the available integrations. You should try to use the version of the integration which aligns with the version of the Aspire.AppHost.Sdk. Some integration versions may have a preview suffix. Once you have identified the correct integration you should always use the _get integration docs_ tool to fetch the latest documentation for the integration and follow the links to get additional guidance. + +## Debugging issues +IMPORTANT! Aspire is designed to capture rich logs and telemetry for all resources defined in the app model. Use the following diagnostic tools when debugging issues with the application before making changes to make sure you are focusing on the right things. + +1. _list structured logs_; use this tool to get details about structured logs. +2. _list console logs_; use this tool to get details about console logs. +3. _list traces_; use this tool to get details about traces. +4. _list trace structured logs_; use this tool to get logs related to a trace + +## Other Aspire MCP tools + +1. _select apphost_; use this tool if working with multiple app hosts within a workspace. +2. _list apphosts_; use this tool to get details about active app hosts. + +## Playwright MCP server + +The playwright MCP server has also been configured in this repository and you should use it to perform functional investigations of the resources defined in the app model as you work on the codebase. To get endpoints that can be used for navigation using the playwright MCP server use the list resources tool. + +## Updating the app host +The user may request that you update the Aspire apphost. You can do this using the `aspire update` command. This will update the apphost to the latest version and some of the Aspire specific packages in referenced projects, however you may need to manually update other packages in the solution to ensure compatibility. You can consider using the `dotnet-outdated` with the users consent. To install the `dotnet-outdated` tool use the following command: + +``` +dotnet tool install --global dotnet-outdated-tool +``` + +## Persistent containers +IMPORTANT! Consider avoiding persistent containers early during development to avoid creating state management issues when restarting the app. + +## Aspire workload +IMPORTANT! The aspire workload is obsolete. You should never attempt to install or use the Aspire workload. + +## Official documentation +IMPORTANT! Always prefer official documentation when available. The following sites contain the official documentation for Aspire and related components + +1. https://aspire.dev +2. https://learn.microsoft.com/dotnet/aspire +3. https://nuget.org (for specific integration package details) \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 5eb2486b..460058be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What This Is -Modular monolith framework for .NET with compile-time module discovery via Roslyn source generators. Fully AOT-compatible — no reflection. Frontend uses React 19 + Inertia.js served via Blazor SSR. +Modular monolith framework for .NET with compile-time module discovery via Roslyn source generators. Frontend uses React 19 + Inertia.js served via Blazor SSR. ## Build & Run @@ -40,8 +40,8 @@ Test stack: xUnit.v3, FluentAssertions, Bogus, Microsoft.AspNetCore.Mvc.Testing. ### .NET Backend - **SimpleModule.Core** — `IModule` interface, `[Module]` attribute, `IEndpoint` interface, `[Dto]` attribute, menu system (`IMenuRegistry`), event bus (`IEventBus`), Inertia integration. -- **SimpleModule.Generator** — Roslyn `IIncrementalGenerator` (netstandard2.0). Scans referenced assemblies for `[Module]` classes, `IEndpoint` implementors, and `[Dto]` types. Generates: `AddModules()`, `MapModuleEndpoints()`, `CollectModuleMenuItems()`, AOT JSON serializers, TypeScript interface definitions, Razor component assembly discovery. -- **SimpleModule.Host** — Host app (net10.0, PublishAot). Calls generated extension methods in `Program.cs`. Custom Inertia middleware bridges Blazor SSR → React. +- **SimpleModule.Generator** — Roslyn `IIncrementalGenerator` (netstandard2.0). Scans referenced assemblies for `[Module]` classes, `IEndpoint` implementors, and `[Dto]` types. Generates: `AddModules()`, `MapModuleEndpoints()`, `CollectModuleMenuItems()`, JSON serializers, TypeScript interface definitions, Razor component assembly discovery. +- **SimpleModule.Host** — Host app (net10.0). Calls generated extension methods in `Program.cs`. Custom Inertia middleware bridges Blazor SSR → React. ### Frontend (React + Inertia.js) @@ -58,9 +58,8 @@ Test stack: xUnit.v3, FluentAssertions, Bogus, Microsoft.AspNetCore.Mvc.Testing. ## Key Constraints -- **No reflection** — source generator emits static `new ModuleName()` calls for AOT. - **Source generator must target netstandard2.0** with `IIncrementalGenerator` (not `ISourceGenerator`). -- **Module class libraries must NOT have `PublishAot`** — only the Host project. Modules need ``. +- Modules need ``. - **Module Vite builds use library mode** — externalize React, React-DOM, @inertiajs/react. Inline dynamic imports. - **TreatWarningsAsErrors is enabled** globally via `Directory.Build.props` with `AnalysisLevel=latest-all` and `AnalysisMode=All`. Suppressed rules are listed in `.editorconfig`. diff --git a/Directory.Packages.props b/Directory.Packages.props index 2b00bcae..a9fa2a8e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,12 +3,23 @@ true + + + + + + + + + + + @@ -29,6 +40,8 @@ + + diff --git a/README.md b/README.md index 70195b7f..c8179bb9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # SimpleModule -A modular monolith framework for .NET with compile-time module discovery via Roslyn source generators. Fully AOT-compatible with no reflection. Frontend uses React 19 + Inertia.js served via Blazor SSR. +A modular monolith framework for .NET with compile-time module discovery via Roslyn source generators. Frontend uses React 19 + Inertia.js served via Blazor SSR. ## Prerequisites diff --git a/SimpleModule.AppHost/AppHost.cs b/SimpleModule.AppHost/AppHost.cs new file mode 100644 index 00000000..9624f048 --- /dev/null +++ b/SimpleModule.AppHost/AppHost.cs @@ -0,0 +1,17 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var postgres = builder + .AddPostgres("postgres") + .WithDataVolume() + .WithPgAdmin() + .WithLifetime(ContainerLifetime.Persistent); + +var db = postgres.AddDatabase("simplemoduledb"); + +builder + .AddProject("simplemodule-host") + .WithExternalHttpEndpoints() + .WithReference(db) + .WaitFor(db); + +builder.Build().Run(); diff --git a/SimpleModule.AppHost/Properties/launchSettings.json b/SimpleModule.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..83c1d2d8 --- /dev/null +++ b/SimpleModule.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:17119;http://localhost:15076", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21159", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23210", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22061" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15076", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19205", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18194", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20023" + } + } + } +} diff --git a/SimpleModule.AppHost/SimpleModule.AppHost.csproj b/SimpleModule.AppHost/SimpleModule.AppHost.csproj new file mode 100644 index 00000000..441649ee --- /dev/null +++ b/SimpleModule.AppHost/SimpleModule.AppHost.csproj @@ -0,0 +1,15 @@ + + + + + + + + + Exe + net10.0 + enable + enable + c74f53ec-e4b7-4a9e-b41c-d39a4ebc0274 + + diff --git a/SimpleModule.AppHost/appsettings.Development.json b/SimpleModule.AppHost/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/SimpleModule.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/SimpleModule.AppHost/appsettings.json b/SimpleModule.AppHost/appsettings.json new file mode 100644 index 00000000..31c092aa --- /dev/null +++ b/SimpleModule.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/SimpleModule.ServiceDefaults/Extensions.cs b/SimpleModule.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000..c819f243 --- /dev/null +++ b/SimpleModule.ServiceDefaults/Extensions.cs @@ -0,0 +1,143 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common 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 +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + 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(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder + .Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing + .AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments( + HealthEndpointPath, + StringComparison.OrdinalIgnoreCase + ) + && !context.Request.Path.StartsWithSegments( + AlivenessEndpointPath, + StringComparison.OrdinalIgnoreCase + ) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace( + builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"] + ); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + 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) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks( + AlivenessEndpointPath, + new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") } + ); + } + + return app; + } +} diff --git a/SimpleModule.ServiceDefaults/SimpleModule.ServiceDefaults.csproj b/SimpleModule.ServiceDefaults/SimpleModule.ServiceDefaults.csproj new file mode 100644 index 00000000..e3dc2acc --- /dev/null +++ b/SimpleModule.ServiceDefaults/SimpleModule.ServiceDefaults.csproj @@ -0,0 +1,18 @@ + + + net10.0 + enable + enable + true + + + + + + + + + + + + diff --git a/SimpleModule.slnx b/SimpleModule.slnx index 7b382271..23a3a615 100644 --- a/SimpleModule.slnx +++ b/SimpleModule.slnx @@ -32,6 +32,21 @@ + + + + + + + + + + + + + + + @@ -42,4 +57,6 @@ + + diff --git a/cli/SimpleModule.Cli/Commands/Doctor/Checks/CsprojConventionCheck.cs b/cli/SimpleModule.Cli/Commands/Doctor/Checks/CsprojConventionCheck.cs index cbb68b24..f8553b27 100644 --- a/cli/SimpleModule.Cli/Commands/Doctor/Checks/CsprojConventionCheck.cs +++ b/cli/SimpleModule.Cli/Commands/Doctor/Checks/CsprojConventionCheck.cs @@ -31,19 +31,6 @@ public IEnumerable Run(SolutionContext solution) CheckStatus.Fail, "missing FrameworkReference Microsoft.AspNetCore.App" ); - - var hasPublishAot = root.Descendants("PublishAot").Any(); - yield return !hasPublishAot - ? new CheckResult( - $"{module} PublishAot", - CheckStatus.Pass, - "no PublishAot (correct)" - ) - : new CheckResult( - $"{module} PublishAot", - CheckStatus.Fail, - "module should NOT have PublishAot" - ); } else { diff --git a/cli/SimpleModule.Cli/Commands/Doctor/Checks/IDoctorCheck.cs b/cli/SimpleModule.Cli/Commands/Doctor/Checks/IDoctorCheck.cs index a57bb19d..6920a949 100644 --- a/cli/SimpleModule.Cli/Commands/Doctor/Checks/IDoctorCheck.cs +++ b/cli/SimpleModule.Cli/Commands/Doctor/Checks/IDoctorCheck.cs @@ -1,5 +1,8 @@ -namespace SimpleModule.Cli.Commands.Doctor.Checks; +using NetEscapades.EnumGenerators; +namespace SimpleModule.Cli.Commands.Doctor.Checks; + +[EnumExtensions] public enum CheckStatus { Pass, diff --git a/cli/SimpleModule.Cli/SimpleModule.Cli.csproj b/cli/SimpleModule.Cli/SimpleModule.Cli.csproj index de10a57b..abccc313 100644 --- a/cli/SimpleModule.Cli/SimpleModule.Cli.csproj +++ b/cli/SimpleModule.Cli/SimpleModule.Cli.csproj @@ -8,6 +8,7 @@ CA1308 + diff --git a/docs/plans/2026-03-13-future-roadmap.md b/docs/plans/2026-03-13-future-roadmap.md index 29b65aa0..8914a065 100644 --- a/docs/plans/2026-03-13-future-roadmap.md +++ b/docs/plans/2026-03-13-future-roadmap.md @@ -6,36 +6,28 @@ SimpleModule is a modular monolith framework for .NET with compile-time module d This document catalogs known problems, missing capabilities, and planned improvements, organized into prioritized phases and a full reference catalog. +> **Last updated**: 2026-03-18 — Phase 1 complete, significant progress on Phases 2–4. See status column in Known Problems tables for current state. + --- -## Phase 1: Foundation (Fix What's Broken) +## Phase 1: Foundation (Fix What's Broken) ✅ COMPLETE -These are blocking issues or significant gaps that undermine the framework's value proposition. +All Phase 1 items have been implemented. -### 1.1 Request Validation -**Problem**: Endpoints manually parse `ReadFormAsync()` with no validation. Invalid input silently produces bad data. -**Plan**: Integrate a validation pipeline (FluentValidation or minimal API filters). Source generator could auto-wire validators discovered by convention. -**Files**: All endpoint classes across modules, Core (new validation abstractions). +### ~~1.1 Request Validation~~ ✅ +**Done**: `ValidationBuilder` fluent helper in Core (`framework/SimpleModule.Core/Validation/`). Products and Orders modules have validators. `GlobalExceptionHandler` maps `ValidationException` → HTTP 400 ProblemDetails with field-level errors. -### 1.2 EF Core Migrations -**Problem**: Uses `EnsureCreated()` — no schema versioning, no safe production upgrades. Database changes are untracked. -**Plan**: Add per-module migration support. Each module's DbContext gets its own migration history. CLI (`sm`) should scaffold migrations. -**Files**: SimpleModule.Database, module DbContexts, SimpleModule.Cli. +### ~~1.2 EF Core Migrations~~ ✅ +**Done**: Initial EF Core migration created (`template/SimpleModule.Host/Migrations/`). `EnsureCreated()` used in dev; migrations available for production. Vogen value converters integrated. -### 1.3 Consistent Endpoint Pattern -**Problem**: Products uses both `IEndpoint` classes AND `ConfigureEndpoints()` escape hatch simultaneously. Confusing for framework users. -**Plan**: Standardize on `IEndpoint` for all CRUD. Reserve `ConfigureEndpoints()` strictly for non-standard routes (WebSocket, file upload, etc.). Migrate Products module. -**Files**: Products module endpoints, documentation. +### ~~1.3 Consistent Endpoint Pattern~~ ✅ +**Done**: Products fully migrated to `IEndpoint` pattern. `ConfigureEndpoints()` escape hatch removed from Products. `CrudEndpoints` static helper in Core reduces boilerplate across all modules. -### 1.4 Error Handling & Problem Details -**Problem**: No structured error responses. Exceptions bubble as 500s with no useful payload. -**Plan**: Add global exception middleware returning RFC 7807 Problem Details. Module-specific error types. Inertia error page support. -**Files**: SimpleModule.Core (middleware), SimpleModule.Api (registration). +### ~~1.4 Error Handling & Problem Details~~ ✅ +**Done**: `GlobalExceptionHandler` implements `IExceptionHandler` with RFC 7807 ProblemDetails for validation (400), not-found (404), conflict (409), and unhandled (500) exceptions. -### 1.5 Use Generated TypeScript Types -**Problem**: React pages define inline TypeScript interfaces that duplicate [Dto] definitions. Generated `contracts.d.ts` exists but is unused. -**Plan**: Wire `extract-ts-types.mjs` into build pipeline. Replace inline interfaces with imports from generated types. Single source of truth. -**Files**: All React page components, tools/extract-ts-types.mjs, module vite configs. +### ~~1.5 Use Generated TypeScript Types~~ ✅ +**Done**: `[Dto]` types generate per-module `types.ts` files. React pages import from generated types. `tools/extract-ts-types.mjs` wired into build pipeline. --- @@ -76,16 +68,17 @@ These add significant value and unblock real application development. **Plan**: `IFileStorage` abstraction in Core (local disk + S3 compatible). Upload endpoint pattern. Image processing pipeline. **Files**: SimpleModule.Core (IFileStorage), new endpoints, React upload components. +### 2.7 Strongly-Typed IDs ✅ +**Done**: Vogen-based `ProductId`, `OrderId`, `UserId` value objects in module `.Contracts` projects. Compile-time type safety for entity IDs with auto-generated EF Core value converters, JSON converters, and route parameter binding. + --- ## Phase 3: Polish (Production Readiness) These make the difference between a demo and a deployable system. -### 3.1 Observability -**Problem**: Structured logging exists (`[LoggerMessage]`) but no metrics, tracing, or health check depth. -**Plan**: OpenTelemetry integration. Per-module health checks. Request duration metrics. Distributed tracing through event bus. -**Files**: SimpleModule.Core (telemetry), SimpleModule.Api (configuration), module health checks. +### ~~3.1 Observability~~ ✅ +**Done**: Aspire integration with OpenTelemetry (service defaults, tracing, metrics). `SimpleModule.AppHost` orchestrates services with `Aspire.Hosting.PostgreSQL`. `AddServiceDefaults()` and `MapDefaultEndpoints()` wired in Host. ### 3.2 Caching **Problem**: Every request hits the database. No caching layer. @@ -143,75 +136,75 @@ These make the framework pleasant to build with. **Plan**: `SimpleModule.Testing` package with: test server builder, authenticated test client, database seeding helpers, Inertia response assertions. **Files**: New project: src/SimpleModule.Testing/. -### 4.5 Source Generator Diagnostics -**Problem**: When source generator fails or produces unexpected output, debugging is opaque. -**Plan**: Add analyzer diagnostics: warnings for common mistakes (missing [Module], IEndpoint without public constructor, [Dto] with unsupported types). Emit diagnostic comments in generated code. -**Files**: SimpleModule.Generator (diagnostic descriptors). +### ~~4.5 Source Generator Diagnostics~~ ✅ (Partial) +**Done**: Source generator refactored into focused `IEmitter` pattern (9 emitters: Diagnostic, Endpoint, JSON, Menu, Module, RazorComponent, TypeScript, ViewPages, HostDbContext). `DiagnosticEmitter` exists. Full analyzer diagnostics for common mistakes still pending. --- ## Known Problems (Full Catalog) ### Framework Core -| # | Problem | Severity | Phase | -|---|---------|----------|-------| -| F1 | No request validation pipeline | High | 1 | -| F2 | No EF Core migrations | High | 1 | -| F3 | No structured error responses (Problem Details) | High | 1 | -| F4 | Event handlers not auto-discovered by generator | Medium | 2 | -| F5 | No pagination/filtering abstractions | Medium | 2 | -| F6 | No caching layer | Medium | 3 | -| F7 | No background job system | Medium | 3 | -| F8 | No file storage abstraction | Medium | 2 | -| F9 | No rate limiting | Medium | 3 | -| F10 | No API versioning | Low | 3 | -| F11 | Menu URLs are untyped strings | Low | 4 | +| # | Problem | Severity | Phase | Status | +|---|---------|----------|-------|--------| +| F1 | No request validation pipeline | High | 1 | ✅ Done | +| F2 | No EF Core migrations | High | 1 | ✅ Done | +| F3 | No structured error responses (Problem Details) | High | 1 | ✅ Done | +| F4 | Event handlers not auto-discovered by generator | Medium | 2 | Open | +| F5 | No pagination/filtering abstractions | Medium | 2 | Open | +| F6 | No caching layer | Medium | 3 | Open | +| F7 | No background job system | Medium | 3 | Open | +| F8 | No file storage abstraction | Medium | 2 | Open | +| F9 | No rate limiting | Medium | 3 | Open | +| F10 | No API versioning | Low | 3 | Open | +| F11 | Menu URLs are untyped strings | Low | 4 | Open | +| F12 | No strongly-typed entity IDs | High | 2 | ✅ Done (Vogen) | ### Frontend -| # | Problem | Severity | Phase | -|---|---------|----------|-------| -| FE1 | Generated TS types unused (inline duplicates) | High | 1 | -| FE2 | No client-side validation error display | High | 2 | -| FE3 | No loading states during form submission | Medium | 2 | -| FE4 | No toast/notification system | Medium | 2 | -| FE5 | No shared React component library | Medium | 2 | -| FE6 | QRCode module partially integrated | Low | 2 | -| FE7 | Product Browse page has minimal styling vs others | Low | 2 | -| FE8 | No Vite HMR integration for dev workflow | Medium | 4 | +| # | Problem | Severity | Phase | Status | +|---|---------|----------|-------|--------| +| FE1 | Generated TS types unused (inline duplicates) | High | 1 | ✅ Done | +| FE2 | No client-side validation error display | High | 2 | Open | +| FE3 | No loading states during form submission | Medium | 2 | Open | +| FE4 | No toast/notification system | Medium | 2 | Open | +| FE5 | No shared React component library | Medium | 2 | Open | +| FE6 | QRCode module partially integrated | Low | 2 | Open | +| FE7 | Product Browse page has minimal styling vs others | Low | 2 | Open | +| FE8 | No Vite HMR integration for dev workflow | Medium | 4 | Open | ### Architecture -| # | Problem | Severity | Phase | -|---|---------|----------|-------| -| A1 | Mixed endpoint patterns (IEndpoint + ConfigureEndpoints) | High | 1 | -| A2 | Manual form parsing in endpoints (ReadFormAsync) | High | 1 | -| A3 | Seed data hard-coded in DbContext (not environment-aware) | Medium | 3 | -| A4 | TypeScript extraction tool is fragile (depends on generator output format) | Medium | 4 | -| A5 | No cross-module query pattern (only contracts interfaces) | Low | 3 | +| # | Problem | Severity | Phase | Status | +|---|---------|----------|-------|--------| +| A1 | Mixed endpoint patterns (IEndpoint + ConfigureEndpoints) | High | 1 | ✅ Done | +| A2 | Manual form parsing in endpoints (ReadFormAsync) | High | 1 | ✅ Done | +| A3 | Seed data hard-coded in DbContext (not environment-aware) | Medium | 3 | Open | +| A4 | TypeScript extraction tool is fragile (depends on generator output format) | Medium | 4 | Open | +| A5 | No cross-module query pattern (only contracts interfaces) | Low | 3 | Open | +| A6 | CRUD endpoint boilerplate | Medium | 2 | ✅ Done (CrudEndpoints helper) | ### Production / Operations -| # | Problem | Severity | Phase | -|---|---------|----------|-------| -| P1 | No observability (metrics, tracing) | High | 3 | -| P2 | No security headers (CSP, HSTS beyond default) | Medium | 3 | -| P3 | No brute-force protection on auth endpoints | Medium | 3 | -| P4 | No K8s/production deployment manifests | Medium | 3 | -| P5 | No secrets management guidance | Low | 3 | +| # | Problem | Severity | Phase | Status | +|---|---------|----------|-------|--------| +| P1 | No observability (metrics, tracing) | High | 3 | ✅ Done (Aspire + OpenTelemetry) | +| P2 | No security headers (CSP, HSTS beyond default) | Medium | 3 | Open | +| P3 | No brute-force protection on auth endpoints | Medium | 3 | Open | +| P4 | No K8s/production deployment manifests | Medium | 3 | Open | +| P5 | No secrets management guidance | Low | 3 | Open | ### Testing -| # | Problem | Severity | Phase | -|---|---------|----------|-------| -| T1 | No shared test utilities / test harness | Medium | 4 | -| T2 | No frontend tests (Playwright setup exists, no tests) | Medium | 4 | -| T3 | Integration tests only for Products, not Orders/Users | Medium | 2 | -| T4 | No load/performance testing | Low | 3 | +| # | Problem | Severity | Phase | Status | +|---|---------|----------|-------|--------| +| T1 | No shared test utilities / test harness | Medium | 4 | Open | +| T2 | No frontend tests (Playwright setup exists, no tests) | Medium | 4 | ✅ Done (5 E2E test files) | +| T3 | Integration tests only for Products, not Orders/Users | Medium | 2 | Open | +| T4 | No load/performance testing | Low | 3 | Open | ### Developer Experience -| # | Problem | Severity | Phase | -|---|---------|----------|-------| -| DX1 | CLI feature templates have TODO placeholders | Medium | 4 | -| DX2 | No user-facing documentation site | Medium | 4 | -| DX3 | Source generator failures are hard to debug | Low | 4 | -| DX4 | New module creation requires many manual steps | Medium | 4 | +| # | Problem | Severity | Phase | Status | +|---|---------|----------|-------|--------| +| DX1 | CLI feature templates have TODO placeholders | Medium | 4 | Open | +| DX2 | No user-facing documentation site | Medium | 4 | Open | +| DX3 | Source generator failures are hard to debug | Low | 4 | ✅ Partial (IEmitter refactor + DiagnosticEmitter) | +| DX4 | New module creation requires many manual steps | Medium | 4 | Open | --- @@ -228,6 +221,22 @@ These make the framework pleasant to build with. --- +## Recent Completions (since 2026-03-13) + +Work not originally in the roadmap that was completed: + +| Item | Description | +|------|-------------| +| **Source generator refactor** | Monolithic generator split into 9 focused `IEmitter` classes with full test coverage | +| **NetEscapades.EnumGenerators** | AOT-safe enum extensions for `DatabaseProvider`, `MenuSection`, `CheckStatus` | +| **Vogen strongly-typed IDs** | `ProductId`, `OrderId`, `UserId` value objects in module Contracts projects | +| **ValidationBuilder** | Fluent validation helper in Core, wired into Products and Orders | +| **CrudEndpoints helper** | Static helper reducing CRUD endpoint boilerplate across modules | +| **GlobalExceptionHandler** | RFC 7807 ProblemDetails for all error types | +| **EF Core migration** | Initial migration with Vogen value converters | +| **Aspire + OpenTelemetry** | Service defaults, distributed tracing, PostgreSQL orchestration | +| **Playwright E2E tests** | 5 test suites covering Products, Orders, Dashboard flows | + ## Verification This is a planning document — verification means reviewing it periodically against actual project state: diff --git a/docs/plans/2026-03-18-admin-module-design.md b/docs/plans/2026-03-18-admin-module-design.md new file mode 100644 index 00000000..51c2e37d --- /dev/null +++ b/docs/plans/2026-03-18-admin-module-design.md @@ -0,0 +1,180 @@ +# Admin Module Design + +## Overview + +Separate Admin module for full user and role management with permissions, security controls, soft delete, and audit logging. Extracted from the Users module to provide clean separation of concerns — Users handles authentication/identity, Admin handles administration. + +## Module Structure + +``` +modules/Admin/ +├── src/Admin.Contracts/ +│ ├── IAdminContracts.cs +│ ├── AdminUserDto.cs +│ ├── AdminRoleDto.cs +│ ├── AuditLogEntryDto.cs +│ ├── CreateAdminUserRequest.cs +│ └── UpdateAdminUserRequest.cs +├── src/Admin/ +│ ├── AdminModule.cs +│ ├── AdminDbContext.cs +│ ├── Entities/ +│ │ └── AuditLogEntry.cs +│ ├── Services/ +│ │ ├── AdminService.cs +│ │ └── AuditService.cs +│ ├── Endpoints/Admin/ +│ ├── Views/Admin/ +│ ├── Pages/Admin/ +│ ├── Pages/components/ +│ ├── vite.config.ts +│ └── package.json +└── tests/Admin.Tests/ +``` + +## Dependencies + +- `Users.Contracts` — `IUserContracts`, `UserDto`, `UserId` +- `Users` (direct) — `UserManager`, `RoleManager`, Identity entities +- `SimpleModule.Core` — `PermissionRegistry`, `IModule`, `IEndpoint`, `[Dto]` + +## Data Model + +### AuditLogEntry (new entity, owned by Admin module) + +| Field | Type | Purpose | +|-------|------|---------| +| Id | long | PK, auto-increment | +| UserId | string | Target user | +| PerformedByUserId | string | Admin who performed action | +| Action | string | Action type (e.g., "RoleAdded", "PasswordReset") | +| Details | string? | JSON context (role name, old/new values) | +| Timestamp | DateTimeOffset | When it happened | + +### ApplicationUser Change (in Users module) + +Add `DeactivatedAt` (`DateTimeOffset?`) to `ApplicationUser`. Null = active, non-null = soft-deleted. + +## Audit Actions Tracked + +- `UserCreated`, `UserUpdated`, `UserDeactivated`, `UserReactivated` +- `RoleAdded`, `RoleRemoved` +- `PermissionGranted`, `PermissionRevoked` +- `PasswordReset`, `AccountLocked`, `AccountUnlocked` +- `EmailReverified`, `TwoFactorDisabled` +- `RoleCreated`, `RoleUpdated`, `RoleDeleted` +- `RolePermissionAdded`, `RolePermissionRemoved` + +## Soft Delete + +- `DeactivatedAt` field on `ApplicationUser` (nullable DateTimeOffset) +- Deactivated users cannot log in (checked during authentication) +- Appear in admin list with "Deactivated" status +- Can be reactivated by admin +- Excluded from `IUserContracts.GetAllUsersAsync()` by default + +## Pages & Tabs + +### Users List (`/admin/users`) + +- Search bar (name/email), pagination +- Table: Name, Email, Roles (badges), Status (Active/Deactivated/Locked), Created +- "Create User" button in header +- Edit button per row + +### User Create (`/admin/users/create`) + +- Form: Display Name, Email, Password, Confirm Password, Email Confirmed toggle, Role checkboxes +- "Create User" submit + +### User Edit (`/admin/users/{id}/edit?tab=`) + +4 tabs: + +**Details** — Display Name, Email, Email Confirmed toggle. Save button. + +**Roles & Permissions** — Role checkbox list + Direct Permissions grouped checkboxes by module (from `PermissionRegistry.ByModule`). Save button per section. + +**Security** — Reset Password (new password + confirm), Lock/Unlock, Force Email Re-verification, Disable 2FA/Reset Authenticator, Failed login attempts + last login (read-only). + +**Activity** — Audit log timeline. Each entry: action icon, description, performed by, relative timestamp. Paginated with "Load more." + +### Roles List (`/admin/roles`) + +- Table: Name, Description, Users (badge), Permissions (badge), Created +- Create Role button, Edit/Delete per row + +### Role Create (`/admin/roles/create`) + +- Form: Name, Description, Permission grouped checkboxes with select-all per module + +### Role Edit (`/admin/roles/{id}/edit?tab=`) + +3 tabs: + +**Details** — Name, Description. Save button. + +**Permissions** — Grouped checkboxes by module with select-all per group. Save button. + +**Users** — Read-only list of assigned users with link to their edit page. + +## Backend Endpoints + +### User Endpoints (`/admin/users`) + +| Method | Route | Purpose | +|--------|-------|---------| +| GET | `/admin/users` | View: User list (Inertia) | +| GET | `/admin/users/create` | View: Create form (Inertia) | +| POST | `/admin/users` | Action: Create user | +| GET | `/admin/users/{id}/edit` | View: Edit page with tabs (Inertia) | +| POST | `/admin/users/{id}` | Action: Update details | +| POST | `/admin/users/{id}/roles` | Action: Set roles | +| POST | `/admin/users/{id}/permissions` | Action: Set direct permissions | +| POST | `/admin/users/{id}/reset-password` | Action: Reset password | +| POST | `/admin/users/{id}/lock` | Action: Lock account | +| POST | `/admin/users/{id}/unlock` | Action: Unlock account | +| POST | `/admin/users/{id}/force-reverify` | Action: Clear email confirmed | +| POST | `/admin/users/{id}/disable-2fa` | Action: Disable 2FA | +| POST | `/admin/users/{id}/deactivate` | Action: Soft delete | +| POST | `/admin/users/{id}/reactivate` | Action: Restore | +| GET | `/admin/users/{id}/activity` | View: Activity log (Inertia partial) | + +### Role Endpoints (`/admin/roles`) + +| Method | Route | Purpose | +|--------|-------|---------| +| GET | `/admin/roles` | View: Role list (Inertia) | +| GET | `/admin/roles/create` | View: Create form (Inertia) | +| POST | `/admin/roles` | Action: Create role with permissions | +| GET | `/admin/roles/{id}/edit` | View: Edit page with tabs (Inertia) | +| POST | `/admin/roles/{id}` | Action: Update details | +| POST | `/admin/roles/{id}/permissions` | Action: Set permissions | +| DELETE | `/admin/roles/{id}` | Action: Delete role | + +## Authorization + +All endpoints require `RequireRole("Admin")`. Module defines permissions `Admin.ManageUsers`, `Admin.ManageRoles`, `Admin.ViewAuditLog` for future granularity. + +## Frontend Components + +### Reusable + +- `PermissionGroups.tsx` — grouped checkboxes by module with select-all +- `ActivityTimeline.tsx` — audit log timeline with icons +- `TabNav.tsx` — URL-driven tab navigation (`?tab=` query param) + +### Tab state + +URL-driven via `?tab=details|roles|security|activity`. Tabs are linkable and bookmarkable. Form submissions preserve the current tab via redirect. + +## Migration from Users Module + +Remove from Users module: +- `Endpoints/Admin/AdminUsersEndpoint.cs` +- `Endpoints/Admin/AdminRolesEndpoint.cs` +- `Views/Admin/` (all 5 endpoints) +- `Pages/Admin/` (all 5 React pages) +- Menu items for "Manage Users" / "Manage Roles" + +Users module keeps: authentication, OAuth/OpenIddict, account self-service (2FA, personal data), API endpoints (`/api/users/*`). diff --git a/docs/plans/2026-03-18-admin-module-plan.md b/docs/plans/2026-03-18-admin-module-plan.md new file mode 100644 index 00000000..6ce4d550 --- /dev/null +++ b/docs/plans/2026-03-18-admin-module-plan.md @@ -0,0 +1,2824 @@ +# Admin Module Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Create a separate Admin module with full user CRUD (soft delete), role management with permission assignment, security controls, and audit logging — replacing the admin functionality currently in the Users module. + +**Architecture:** New `Admin` module that references `Users` directly for Identity access (`UserManager`, `RoleManager`, entities). Owns its own `AdminDbContext` with an `AuditLogEntry` entity. React pages use tabbed UI for edit forms. Permissions grouped by module via `PermissionRegistry`. + +**Tech Stack:** .NET 10, ASP.NET Core Identity, EF Core (SQLite/PostgreSQL), Inertia.js, React 19, Vite, @simplemodule/ui (Radix-based), xUnit.v3, FluentAssertions + +--- + +### Task 1: Scaffold Admin.Contracts Project + +**Files:** +- Create: `modules/Admin/src/Admin.Contracts/Admin.Contracts.csproj` +- Create: `modules/Admin/src/Admin.Contracts/AdminConstants.cs` +- Create: `modules/Admin/src/Admin.Contracts/AdminPermissions.cs` +- Create: `modules/Admin/src/Admin.Contracts/AuditLogEntryDto.cs` + +**Step 1: Create Admin.Contracts.csproj** + +```xml + + + net10.0 + Library + + + + + +``` + +**Step 2: Create AdminConstants.cs** + +```csharp +namespace SimpleModule.Admin.Contracts; + +public static class AdminConstants +{ + public const string ModuleName = "Admin"; + public const string RoutePrefix = "/admin"; +} +``` + +**Step 3: Create AdminPermissions.cs** + +```csharp +namespace SimpleModule.Admin.Contracts; + +public static class AdminPermissions +{ + public const string ManageUsers = "Admin.ManageUsers"; + public const string ManageRoles = "Admin.ManageRoles"; + public const string ViewAuditLog = "Admin.ViewAuditLog"; +} +``` + +**Step 4: Create AuditLogEntryDto.cs** + +```csharp +using SimpleModule.Core; + +namespace SimpleModule.Admin.Contracts; + +[Dto] +public class AuditLogEntryDto +{ + public long Id { get; set; } + public string UserId { get; set; } = string.Empty; + public string PerformedByUserId { get; set; } = string.Empty; + public string PerformedByName { get; set; } = string.Empty; + public string Action { get; set; } = string.Empty; + public string? Details { get; set; } + public DateTimeOffset Timestamp { get; set; } +} +``` + +**Step 5: Verify it compiles** + +Run: `dotnet build modules/Admin/src/Admin.Contracts/Admin.Contracts.csproj` +Expected: Build succeeded + +**Step 6: Commit** + +```bash +git add modules/Admin/src/Admin.Contracts/ +git commit -m "feat(admin): scaffold Admin.Contracts project with constants, permissions, and DTOs" +``` + +--- + +### Task 2: Scaffold Admin Project (Module, DbContext, Entity) + +**Files:** +- Create: `modules/Admin/src/Admin/Admin.csproj` +- Create: `modules/Admin/src/Admin/AdminModule.cs` +- Create: `modules/Admin/src/Admin/AdminDbContext.cs` +- Create: `modules/Admin/src/Admin/Entities/AuditLogEntry.cs` +- Create: `modules/Admin/src/Admin/Entities/AuditLogEntryConfiguration.cs` +- Create: `modules/Admin/src/Admin/Services/AuditService.cs` + +**Step 1: Create Admin.csproj** + +```xml + + + net10.0 + + + + + + + + + + + + +``` + +**Step 2: Create AuditLogEntry entity** + +File: `modules/Admin/src/Admin/Entities/AuditLogEntry.cs` + +```csharp +namespace SimpleModule.Admin.Entities; + +public class AuditLogEntry +{ + public long Id { get; set; } + public string UserId { get; set; } = string.Empty; + public string PerformedByUserId { get; set; } = string.Empty; + public string Action { get; set; } = string.Empty; + public string? Details { get; set; } + public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow; +} +``` + +**Step 3: Create AuditLogEntryConfiguration** + +File: `modules/Admin/src/Admin/Entities/AuditLogEntryConfiguration.cs` + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace SimpleModule.Admin.Entities; + +public class AuditLogEntryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + builder.Property(e => e.UserId).IsRequired(); + builder.Property(e => e.PerformedByUserId).IsRequired(); + builder.Property(e => e.Action).IsRequired().HasMaxLength(100); + builder.Property(e => e.Details).HasMaxLength(4000); + builder.HasIndex(e => e.UserId); + builder.HasIndex(e => e.Timestamp); + } +} +``` + +**Step 4: Create AdminDbContext** + +File: `modules/Admin/src/Admin/AdminDbContext.cs` + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SimpleModule.Admin.Contracts; +using SimpleModule.Admin.Entities; +using SimpleModule.Database; + +namespace SimpleModule.Admin; + +public class AdminDbContext( + DbContextOptions options, + IOptions dbOptions +) : DbContext(options) +{ + public DbSet AuditLogEntries => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + builder.ApplyConfigurationsFromAssembly(typeof(AdminDbContext).Assembly); + builder.ApplyModuleSchema(AdminConstants.ModuleName, dbOptions.Value); + } +} +``` + +**Step 5: Create AuditService** + +File: `modules/Admin/src/Admin/Services/AuditService.cs` + +```csharp +using SimpleModule.Admin.Entities; + +namespace SimpleModule.Admin.Services; + +public class AuditService(AdminDbContext db) +{ + public async Task LogAsync(string userId, string performedByUserId, string action, string? details = null) + { + db.AuditLogEntries.Add(new AuditLogEntry + { + UserId = userId, + PerformedByUserId = performedByUserId, + Action = action, + Details = details, + Timestamp = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(); + } +} +``` + +**Step 6: Create AdminModule.cs (minimal, menu and permissions)** + +File: `modules/Admin/src/Admin/AdminModule.cs` + +```csharp +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Admin.Contracts; +using SimpleModule.Admin.Services; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Menu; +using SimpleModule.Database; + +namespace SimpleModule.Admin; + +[Module(AdminConstants.ModuleName)] +public class AdminModule : IModule +{ + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddModuleDbContext(configuration, AdminConstants.ModuleName); + services.AddScoped(); + } + + public void ConfigurePermissions(PermissionRegistryBuilder builder) + { + builder.AddPermissions(); + } + + public void ConfigureMenu(IMenuBuilder menus) + { + menus.Add(new MenuItem + { + Label = "Users", + Url = "/admin/users", + Icon = """""", + Order = 20, + Section = MenuSection.Navbar, + }); + menus.Add(new MenuItem + { + Label = "Roles", + Url = "/admin/roles", + Icon = """""", + Order = 21, + Section = MenuSection.Navbar, + }); + menus.Add(new MenuItem + { + Label = "Manage Users", + Url = "/admin/users", + Icon = """""", + Order = 30, + Section = MenuSection.UserDropdown, + Group = "admin", + }); + menus.Add(new MenuItem + { + Label = "Manage Roles", + Url = "/admin/roles", + Icon = """""", + Order = 31, + Section = MenuSection.UserDropdown, + Group = "admin", + }); + } +} +``` + +**Step 7: Verify it compiles** + +Run: `dotnet build modules/Admin/src/Admin/Admin.csproj` +Expected: Build succeeded + +**Step 8: Commit** + +```bash +git add modules/Admin/src/Admin/ +git commit -m "feat(admin): add Admin module with DbContext, AuditLogEntry entity, and AuditService" +``` + +--- + +### Task 3: Wire Admin Module into Host and Solution + +**Files:** +- Modify: `template/SimpleModule.Host/SimpleModule.Host.csproj` — add Admin project reference +- Modify: `SimpleModule.slnx` — add Admin projects to solution +- Modify: `tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs` — add AdminDbContext + +**Step 1: Add project reference to Host csproj** + +In `template/SimpleModule.Host/SimpleModule.Host.csproj`, add after the Orders reference: + +```xml + +``` + +**Step 2: Add projects to solution** + +In `SimpleModule.slnx`, add a new folder block after the Users folder: + +```xml + + + + + +``` + +**Step 3: Add AdminDbContext to test factory** + +In `tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs`: +- Add `using SimpleModule.Admin;` +- Add `ReplaceDbContext(services);` after the existing `ReplaceDbContext` calls + +**Step 4: Verify full solution builds** + +Run: `dotnet build` +Expected: Build succeeded + +**Step 5: Commit** + +```bash +git add template/SimpleModule.Host/SimpleModule.Host.csproj SimpleModule.slnx tests/SimpleModule.Tests.Shared/ +git commit -m "feat(admin): wire Admin module into Host, solution, and test factory" +``` + +--- + +### Task 4: Add DeactivatedAt to ApplicationUser + +**Files:** +- Modify: `modules/Users/src/Users/Entities/ApplicationUser.cs` — add `DeactivatedAt` property + +**Step 1: Add the property** + +In `ApplicationUser.cs`, add: + +```csharp +public DateTimeOffset? DeactivatedAt { get; set; } +``` + +**Step 2: Verify it compiles** + +Run: `dotnet build modules/Users/src/Users/Users.csproj` +Expected: Build succeeded + +**Step 3: Commit** + +```bash +git add modules/Users/src/Users/Entities/ApplicationUser.cs +git commit -m "feat(users): add DeactivatedAt property to ApplicationUser for soft delete" +``` + +--- + +### Task 5: Remove Admin Code from Users Module + +**Files:** +- Delete: `modules/Users/src/Users/Endpoints/Admin/AdminUsersEndpoint.cs` +- Delete: `modules/Users/src/Users/Endpoints/Admin/AdminRolesEndpoint.cs` +- Delete: `modules/Users/src/Users/Views/Admin/UsersEndpoint.cs` +- Delete: `modules/Users/src/Users/Views/Admin/UsersEditEndpoint.cs` +- Delete: `modules/Users/src/Users/Views/Admin/RolesEndpoint.cs` +- Delete: `modules/Users/src/Users/Views/Admin/RolesCreateEndpoint.cs` +- Delete: `modules/Users/src/Users/Views/Admin/RolesEditEndpoint.cs` +- Delete: `modules/Users/src/Users/Pages/Admin/Users.tsx` +- Delete: `modules/Users/src/Users/Pages/Admin/UsersEdit.tsx` +- Delete: `modules/Users/src/Users/Pages/Admin/Roles.tsx` +- Delete: `modules/Users/src/Users/Pages/Admin/RolesCreate.tsx` +- Delete: `modules/Users/src/Users/Pages/Admin/RolesEdit.tsx` +- Modify: `modules/Users/src/Users/Pages/index.ts` — remove Admin page imports/exports +- Modify: `modules/Users/src/Users/UsersModule.cs` — remove admin menu items (Users, Roles navbar + Manage Users, Manage Roles dropdown) +- Delete: `modules/Users/tests/Users.Tests/Integration/AdminUsersEndpointTests.cs` (if exists) +- Delete: `modules/Users/tests/Users.Tests/Integration/AdminRolesEndpointTests.cs` (if exists) + +**Step 1: Delete all admin endpoint, view, and page files** + +Delete all files listed above. + +**Step 2: Update Pages/index.ts** + +Remove the Admin imports and exports. The file should only contain Account page mappings: + +```typescript +import Disable2fa from './Account/Disable2fa'; +import EnableAuthenticator from './Account/EnableAuthenticator'; +import GenerateRecoveryCodes from './Account/GenerateRecoveryCodes'; +import ResetAuthenticator from './Account/ResetAuthenticator'; +import ShowRecoveryCodes from './Account/ShowRecoveryCodes'; +import TwoFactorAuthentication from './Account/TwoFactorAuthentication'; + +export const pages: Record = { + 'Users/Account/TwoFactorAuthentication': TwoFactorAuthentication, + 'Users/Account/EnableAuthenticator': EnableAuthenticator, + 'Users/Account/Disable2fa': Disable2fa, + 'Users/Account/ResetAuthenticator': ResetAuthenticator, + 'Users/Account/GenerateRecoveryCodes': GenerateRecoveryCodes, + 'Users/Account/ShowRecoveryCodes': ShowRecoveryCodes, +}; +``` + +**Step 3: Update UsersModule.cs — remove admin menu items** + +Remove the 4 menu items: "Users" (navbar), "Roles" (navbar), "Manage Users" (dropdown), "Manage Roles" (dropdown). Keep "Account Settings", "Email", "Security" dropdown items. + +**Step 4: Delete admin test files** + +Delete `AdminUsersEndpointTests.cs` and `AdminRolesEndpointTests.cs` if they exist. + +**Step 5: Verify it compiles** + +Run: `dotnet build` +Expected: Build succeeded + +**Step 6: Commit** + +```bash +git add -A +git commit -m "refactor(users): remove admin UI, endpoints, and menu items — moved to Admin module" +``` + +--- + +### Task 6: Admin User View Endpoints (List + Create + Edit) + +**Files:** +- Create: `modules/Admin/src/Admin/Views/Admin/UsersEndpoint.cs` +- Create: `modules/Admin/src/Admin/Views/Admin/UsersCreateEndpoint.cs` +- Create: `modules/Admin/src/Admin/Views/Admin/UsersEditEndpoint.cs` +- Create: `modules/Admin/src/Admin/Views/Admin/UsersActivityEndpoint.cs` + +**Step 1: Create UsersEndpoint.cs (list view)** + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using SimpleModule.Core; +using SimpleModule.Core.Inertia; +using SimpleModule.Users.Entities; + +namespace SimpleModule.Admin.Views.Admin; + +public class UsersEndpoint : IViewEndpoint +{ + private const int PageSize = 20; + + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/admin/users", + async ( + UserManager userManager, + string? search, + int page = 1 + ) => + { + var query = userManager.Users.AsQueryable(); + + if (!string.IsNullOrWhiteSpace(search)) + { + var pattern = $"%{search.Trim()}%"; + query = query.Where(u => + (u.Email != null && EF.Functions.Like(u.Email, pattern)) + || EF.Functions.Like(u.DisplayName, pattern) + || (u.UserName != null && EF.Functions.Like(u.UserName, pattern)) + ); + } + + var totalCount = await query.CountAsync(); + var totalPages = (int)Math.Ceiling((double)totalCount / PageSize); + page = Math.Clamp(page, 1, Math.Max(1, totalPages)); + + var users = await query + .OrderBy(u => u.DisplayName) + .Skip((page - 1) * PageSize) + .Take(PageSize) + .ToListAsync(); + + var userList = new List(); + foreach (var user in users) + { + var roles = await userManager.GetRolesAsync(user); + userList.Add(new + { + id = user.Id, + displayName = user.DisplayName, + email = user.Email, + emailConfirmed = user.EmailConfirmed, + roles = roles.ToList(), + isLockedOut = user.LockoutEnd.HasValue + && user.LockoutEnd > DateTimeOffset.UtcNow, + isDeactivated = user.DeactivatedAt.HasValue, + createdAt = user.CreatedAt.ToString("O"), + }); + } + + return Inertia.Render("Admin/Admin/Users", new + { + users = userList, + search = search ?? "", + page, + totalPages, + totalCount, + }); + } + ) + .RequireAuthorization(policy => policy.RequireRole("Admin")); + } +} +``` + +**Step 2: Create UsersCreateEndpoint.cs** + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using SimpleModule.Core; +using SimpleModule.Core.Inertia; +using SimpleModule.Users.Entities; + +namespace SimpleModule.Admin.Views.Admin; + +public class UsersCreateEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/admin/users/create", + async (RoleManager roleManager) => + { + var allRoles = await roleManager.Roles + .OrderBy(r => r.Name) + .Select(r => new { id = r.Id, name = r.Name, description = r.Description }) + .ToListAsync(); + + return Inertia.Render("Admin/Admin/UsersCreate", new { allRoles }); + } + ) + .RequireAuthorization(policy => policy.RequireRole("Admin")); + } +} +``` + +**Step 3: Create UsersEditEndpoint.cs (tabbed view with all data)** + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using SimpleModule.Admin.Contracts; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Inertia; +using SimpleModule.Users; +using SimpleModule.Users.Entities; + +namespace SimpleModule.Admin.Views.Admin; + +public class UsersEditEndpoint : IViewEndpoint +{ + private const int ActivityPageSize = 20; + + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/admin/users/{id}/edit", + async ( + string id, + UserManager userManager, + RoleManager roleManager, + UsersDbContext usersDb, + AdminDbContext adminDb, + PermissionRegistry permissionRegistry, + string? tab + ) => + { + var user = await userManager.FindByIdAsync(id); + if (user is null) + return Results.NotFound(); + + var userRoles = await userManager.GetRolesAsync(user); + var allRoles = await roleManager.Roles.OrderBy(r => r.Name).ToListAsync(); + + // User direct permissions + var userPermissions = await usersDb.UserPermissions + .Where(p => p.UserId == id) + .Select(p => p.Permission) + .ToListAsync(); + + // Permission registry grouped by module + var permissionsByModule = permissionRegistry.ByModule + .ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value.ToList() + ); + + // Activity log (first page) + var activityLog = await adminDb.AuditLogEntries + .Where(e => e.UserId == id) + .OrderByDescending(e => e.Timestamp) + .Take(ActivityPageSize) + .Select(e => new + { + e.Id, + e.Action, + e.Details, + e.PerformedByUserId, + timestamp = e.Timestamp.ToString("O"), + }) + .ToListAsync(); + + // Resolve performer names for activity entries + var performerIds = activityLog + .Select(e => e.PerformedByUserId) + .Distinct() + .ToList(); + var performers = await userManager.Users + .Where(u => performerIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, u => u.DisplayName); + + var activityWithNames = activityLog.Select(e => new + { + e.Id, + e.Action, + e.Details, + performedBy = performers.GetValueOrDefault(e.PerformedByUserId, "Unknown"), + e.timestamp, + }); + + var activityTotal = await adminDb.AuditLogEntries.CountAsync(e => e.UserId == id); + + return Inertia.Render("Admin/Admin/UsersEdit", new + { + user = new + { + id = user.Id, + displayName = user.DisplayName, + email = user.Email, + emailConfirmed = user.EmailConfirmed, + twoFactorEnabled = user.TwoFactorEnabled, + isLockedOut = user.LockoutEnd.HasValue + && user.LockoutEnd > DateTimeOffset.UtcNow, + isDeactivated = user.DeactivatedAt.HasValue, + accessFailedCount = user.AccessFailedCount, + createdAt = user.CreatedAt.ToString("O"), + lastLoginAt = user.LastLoginAt?.ToString("O"), + }, + userRoles = userRoles.ToList(), + userPermissions, + allRoles = allRoles.Select(r => new + { + id = r.Id, + name = r.Name, + description = r.Description, + }).ToList(), + permissionsByModule, + activityLog = activityWithNames, + activityTotal, + tab = tab ?? "details", + }); + } + ) + .RequireAuthorization(policy => policy.RequireRole("Admin")); + } +} +``` + +**Step 4: Create UsersActivityEndpoint.cs (paginated activity partial)** + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using SimpleModule.Core; +using SimpleModule.Users.Entities; + +namespace SimpleModule.Admin.Views.Admin; + +public class UsersActivityEndpoint : IEndpoint +{ + private const int PageSize = 20; + + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/admin/users/{id}/activity", + async ( + string id, + AdminDbContext adminDb, + UserManager userManager, + int page = 1 + ) => + { + var entries = await adminDb.AuditLogEntries + .Where(e => e.UserId == id) + .OrderByDescending(e => e.Timestamp) + .Skip((page - 1) * PageSize) + .Take(PageSize) + .ToListAsync(); + + var performerIds = entries.Select(e => e.PerformedByUserId).Distinct().ToList(); + var performers = await userManager.Users + .Where(u => performerIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, u => u.DisplayName); + + var total = await adminDb.AuditLogEntries.CountAsync(e => e.UserId == id); + + return Results.Ok(new + { + entries = entries.Select(e => new + { + e.Id, + e.Action, + e.Details, + performedBy = performers.GetValueOrDefault(e.PerformedByUserId, "Unknown"), + timestamp = e.Timestamp.ToString("O"), + }), + total, + page, + totalPages = (int)Math.Ceiling((double)total / PageSize), + }); + } + ) + .RequireAuthorization(policy => policy.RequireRole("Admin")); + } +} +``` + +**Step 5: Verify it compiles** + +Run: `dotnet build modules/Admin/src/Admin/Admin.csproj` +Expected: Build succeeded + +**Step 6: Commit** + +```bash +git add modules/Admin/src/Admin/Views/ +git commit -m "feat(admin): add user view endpoints (list, create, edit with tabs, activity)" +``` + +--- + +### Task 7: Admin User Action Endpoints + +**Files:** +- Create: `modules/Admin/src/Admin/Endpoints/Admin/AdminUsersEndpoint.cs` + +This single endpoint class maps all user mutation routes. + +**Step 1: Create AdminUsersEndpoint.cs** + +```csharp +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Admin.Services; +using SimpleModule.Core; +using SimpleModule.Users; +using SimpleModule.Users.Entities; + +namespace SimpleModule.Admin.Endpoints.Admin; + +public class AdminUsersEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + var group = app.MapGroup("/admin/users") + .WithTags("Admin") + .RequireAuthorization(policy => policy.RequireRole("Admin")); + + // Create user + group.MapPost("/", async ( + [FromForm] string email, + [FromForm] string displayName, + [FromForm] string password, + [FromForm] string? emailConfirmed, + HttpContext context, + UserManager userManager, + AuditService audit + ) => + { + var user = new ApplicationUser + { + UserName = email, + Email = email, + DisplayName = displayName.Trim(), + EmailConfirmed = emailConfirmed is not null, + }; + + var result = await userManager.CreateAsync(user, password); + if (!result.Succeeded) + return Results.Redirect("/admin/users/create"); + + // Assign roles from form + var form = await context.Request.ReadFormAsync(); + var roles = form["roles"].Where(r => !string.IsNullOrEmpty(r)).ToList(); + if (roles.Count > 0) + await userManager.AddToRolesAsync(user, roles!); + + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + await audit.LogAsync(user.Id, adminId, "UserCreated", + $"Created user {user.Email}"); + + return Results.Redirect($"/admin/users/{user.Id}/edit"); + }).DisableAntiforgery(); + + // Update user details + group.MapPost("/{id}", async ( + string id, + [FromForm] string displayName, + [FromForm] string email, + [FromForm] string? emailConfirmed, + HttpContext context, + UserManager userManager, + AuditService audit + ) => + { + var user = await userManager.FindByIdAsync(id); + if (user is null) + return Results.NotFound(); + + user.DisplayName = displayName; + user.Email = email; + user.EmailConfirmed = emailConfirmed is not null; + await userManager.UpdateAsync(user); + + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + await audit.LogAsync(id, adminId, "UserUpdated"); + + return Results.Redirect($"/admin/users/{id}/edit?tab=details"); + }).DisableAntiforgery(); + + // Set roles + group.MapPost("/{id}/roles", async ( + string id, + HttpContext context, + UserManager userManager, + AuditService audit + ) => + { + var user = await userManager.FindByIdAsync(id); + if (user is null) + return Results.NotFound(); + + var form = await context.Request.ReadFormAsync(); + var newRoles = form["roles"].Where(r => !string.IsNullOrEmpty(r)).ToList(); + var currentRoles = await userManager.GetRolesAsync(user); + + var removed = currentRoles.Except(newRoles!).ToList(); + var added = newRoles!.Except(currentRoles).ToList(); + + await userManager.RemoveFromRolesAsync(user, currentRoles); + if (newRoles.Count > 0) + await userManager.AddToRolesAsync(user, newRoles!); + + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + foreach (var role in removed) + await audit.LogAsync(id, adminId, "RoleRemoved", role); + foreach (var role in added) + await audit.LogAsync(id, adminId, "RoleAdded", role); + + return Results.Redirect($"/admin/users/{id}/edit?tab=roles"); + }).DisableAntiforgery(); + + // Set direct permissions + group.MapPost("/{id}/permissions", async ( + string id, + HttpContext context, + UsersDbContext usersDb, + UserManager userManager, + AuditService audit + ) => + { + var user = await userManager.FindByIdAsync(id); + if (user is null) + return Results.NotFound(); + + var form = await context.Request.ReadFormAsync(); + var newPermissions = form["permissions"] + .Where(p => !string.IsNullOrEmpty(p)) + .ToHashSet(); + + var currentPermissions = usersDb.UserPermissions + .Where(p => p.UserId == id) + .ToList(); + + var currentSet = currentPermissions.Select(p => p.Permission).ToHashSet(); + var removed = currentSet.Except(newPermissions).ToList(); + var added = newPermissions.Except(currentSet).ToList(); + + usersDb.UserPermissions.RemoveRange( + currentPermissions.Where(p => removed.Contains(p.Permission))); + foreach (var perm in added) + usersDb.UserPermissions.Add(new UserPermission { UserId = id, Permission = perm }); + await usersDb.SaveChangesAsync(); + + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + foreach (var perm in removed) + await audit.LogAsync(id, adminId, "PermissionRevoked", perm); + foreach (var perm in added) + await audit.LogAsync(id, adminId, "PermissionGranted", perm); + + return Results.Redirect($"/admin/users/{id}/edit?tab=roles"); + }).DisableAntiforgery(); + + // Reset password + group.MapPost("/{id}/reset-password", async ( + string id, + [FromForm] string newPassword, + HttpContext context, + UserManager userManager, + AuditService audit + ) => + { + var user = await userManager.FindByIdAsync(id); + if (user is null) + return Results.NotFound(); + + var token = await userManager.GeneratePasswordResetTokenAsync(user); + var result = await userManager.ResetPasswordAsync(user, token, newPassword); + if (!result.Succeeded) + return Results.Redirect($"/admin/users/{id}/edit?tab=security"); + + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + await audit.LogAsync(id, adminId, "PasswordReset"); + + return Results.Redirect($"/admin/users/{id}/edit?tab=security"); + }).DisableAntiforgery(); + + // Lock account + group.MapPost("/{id}/lock", async ( + string id, + HttpContext context, + UserManager userManager, + AuditService audit + ) => + { + var user = await userManager.FindByIdAsync(id); + if (user is null) + return Results.NotFound(); + + await userManager.SetLockoutEnabledAsync(user, true); + await userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddYears(100)); + + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + await audit.LogAsync(id, adminId, "AccountLocked"); + + return Results.Redirect($"/admin/users/{id}/edit?tab=security"); + }); + + // Unlock account + group.MapPost("/{id}/unlock", async ( + string id, + HttpContext context, + UserManager userManager, + AuditService audit + ) => + { + var user = await userManager.FindByIdAsync(id); + if (user is null) + return Results.NotFound(); + + await userManager.SetLockoutEndDateAsync(user, null); + await userManager.ResetAccessFailedCountAsync(user); + + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + await audit.LogAsync(id, adminId, "AccountUnlocked"); + + return Results.Redirect($"/admin/users/{id}/edit?tab=security"); + }); + + // Force email re-verification + group.MapPost("/{id}/force-reverify", async ( + string id, + HttpContext context, + UserManager userManager, + AuditService audit + ) => + { + var user = await userManager.FindByIdAsync(id); + if (user is null) + return Results.NotFound(); + + user.EmailConfirmed = false; + await userManager.UpdateAsync(user); + + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + await audit.LogAsync(id, adminId, "EmailReverified"); + + return Results.Redirect($"/admin/users/{id}/edit?tab=security"); + }); + + // Disable 2FA + group.MapPost("/{id}/disable-2fa", async ( + string id, + HttpContext context, + UserManager userManager, + AuditService audit + ) => + { + var user = await userManager.FindByIdAsync(id); + if (user is null) + return Results.NotFound(); + + await userManager.SetTwoFactorEnabledAsync(user, false); + await userManager.ResetAuthenticatorKeyAsync(user); + + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + await audit.LogAsync(id, adminId, "TwoFactorDisabled"); + + return Results.Redirect($"/admin/users/{id}/edit?tab=security"); + }); + + // Deactivate (soft delete) + group.MapPost("/{id}/deactivate", async ( + string id, + HttpContext context, + UserManager userManager, + AuditService audit + ) => + { + var user = await userManager.FindByIdAsync(id); + if (user is null) + return Results.NotFound(); + + user.DeactivatedAt = DateTimeOffset.UtcNow; + await userManager.UpdateAsync(user); + + // Also lock the account + await userManager.SetLockoutEnabledAsync(user, true); + await userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddYears(100)); + + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + await audit.LogAsync(id, adminId, "UserDeactivated"); + + return Results.Redirect($"/admin/users/{id}/edit?tab=details"); + }); + + // Reactivate + group.MapPost("/{id}/reactivate", async ( + string id, + HttpContext context, + UserManager userManager, + AuditService audit + ) => + { + var user = await userManager.FindByIdAsync(id); + if (user is null) + return Results.NotFound(); + + user.DeactivatedAt = null; + await userManager.UpdateAsync(user); + await userManager.SetLockoutEndDateAsync(user, null); + await userManager.ResetAccessFailedCountAsync(user); + + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + await audit.LogAsync(id, adminId, "UserReactivated"); + + return Results.Redirect($"/admin/users/{id}/edit?tab=details"); + }); + } +} +``` + +**Step 2: Verify it compiles** + +Run: `dotnet build modules/Admin/src/Admin/Admin.csproj` +Expected: Build succeeded + +**Step 3: Commit** + +```bash +git add modules/Admin/src/Admin/Endpoints/ +git commit -m "feat(admin): add all user action endpoints (CRUD, roles, permissions, security, soft delete)" +``` + +--- + +### Task 8: Admin Role View and Action Endpoints + +**Files:** +- Create: `modules/Admin/src/Admin/Views/Admin/RolesEndpoint.cs` +- Create: `modules/Admin/src/Admin/Views/Admin/RolesCreateEndpoint.cs` +- Create: `modules/Admin/src/Admin/Views/Admin/RolesEditEndpoint.cs` +- Create: `modules/Admin/src/Admin/Endpoints/Admin/AdminRolesEndpoint.cs` + +**Step 1: Create RolesEndpoint.cs (list view)** + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using SimpleModule.Core; +using SimpleModule.Core.Inertia; +using SimpleModule.Users; +using SimpleModule.Users.Entities; + +namespace SimpleModule.Admin.Views.Admin; + +public class RolesEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/admin/roles", + async ( + RoleManager roleManager, + UserManager userManager, + UsersDbContext usersDb + ) => + { + var roles = await roleManager.Roles + .OrderBy(r => r.Name) + .ToListAsync(); + + var roleList = new List(); + foreach (var role in roles) + { + var usersInRole = role.Name is not null + ? await userManager.GetUsersInRoleAsync(role.Name) + : []; + + var permissionCount = await usersDb.RolePermissions + .CountAsync(rp => rp.RoleId == role.Id); + + roleList.Add(new + { + id = role.Id, + name = role.Name, + description = role.Description, + userCount = usersInRole.Count, + permissionCount, + createdAt = role.CreatedAt.ToString("O"), + }); + } + + return Inertia.Render("Admin/Admin/Roles", new { roles = roleList }); + } + ) + .RequireAuthorization(policy => policy.RequireRole("Admin")); + } +} +``` + +**Step 2: Create RolesCreateEndpoint.cs** + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Inertia; + +namespace SimpleModule.Admin.Views.Admin; + +public class RolesCreateEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/admin/roles/create", + (PermissionRegistry permissionRegistry) => + { + var permissionsByModule = permissionRegistry.ByModule + .ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value.ToList() + ); + + return Inertia.Render("Admin/Admin/RolesCreate", new { permissionsByModule }); + } + ) + .RequireAuthorization(policy => policy.RequireRole("Admin")); + } +} +``` + +**Step 3: Create RolesEditEndpoint.cs (tabbed view)** + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Inertia; +using SimpleModule.Users; +using SimpleModule.Users.Entities; + +namespace SimpleModule.Admin.Views.Admin; + +public class RolesEditEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/admin/roles/{id}/edit", + async ( + string id, + RoleManager roleManager, + UserManager userManager, + UsersDbContext usersDb, + PermissionRegistry permissionRegistry, + string? tab + ) => + { + var role = await roleManager.FindByIdAsync(id); + if (role is null) + return Results.NotFound(); + + var usersInRole = role.Name is not null + ? await userManager.GetUsersInRoleAsync(role.Name) + : []; + + var rolePermissions = await usersDb.RolePermissions + .Where(rp => rp.RoleId == id) + .Select(rp => rp.Permission) + .ToListAsync(); + + var permissionsByModule = permissionRegistry.ByModule + .ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value.ToList() + ); + + return Inertia.Render("Admin/Admin/RolesEdit", new + { + role = new + { + id = role.Id, + name = role.Name, + description = role.Description, + createdAt = role.CreatedAt.ToString("O"), + }, + users = usersInRole.Select(u => new + { + id = u.Id, + displayName = u.DisplayName, + email = u.Email, + }).ToList(), + rolePermissions, + permissionsByModule, + tab = tab ?? "details", + }); + } + ) + .RequireAuthorization(policy => policy.RequireRole("Admin")); + } +} +``` + +**Step 4: Create AdminRolesEndpoint.cs (action endpoints)** + +```csharp +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Admin.Services; +using SimpleModule.Core; +using SimpleModule.Users; +using SimpleModule.Users.Entities; + +namespace SimpleModule.Admin.Endpoints.Admin; + +public class AdminRolesEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + var group = app.MapGroup("/admin/roles") + .WithTags("Admin") + .RequireAuthorization(policy => policy.RequireRole("Admin")); + + // Create role with permissions + group.MapPost("/", async ( + [FromForm] string name, + [FromForm] string? description, + HttpContext context, + RoleManager roleManager, + UsersDbContext usersDb, + AuditService audit + ) => + { + var trimmedName = name.Trim(); + if (string.IsNullOrEmpty(trimmedName)) + return Results.Redirect("/admin/roles/create"); + + var role = new ApplicationRole + { + Name = trimmedName, + Description = description?.Trim() is { Length: > 0 } d ? d : null, + }; + + var result = await roleManager.CreateAsync(role); + if (!result.Succeeded) + return Results.Redirect("/admin/roles/create"); + + // Assign permissions from form + var form = await context.Request.ReadFormAsync(); + var permissions = form["permissions"].Where(p => !string.IsNullOrEmpty(p)).ToList(); + foreach (var perm in permissions) + usersDb.RolePermissions.Add(new RolePermission { RoleId = role.Id, Permission = perm! }); + if (permissions.Count > 0) + await usersDb.SaveChangesAsync(); + + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + await audit.LogAsync(role.Id, adminId, "RoleCreated", $"Created role {trimmedName}"); + + return Results.Redirect($"/admin/roles/{role.Id}/edit"); + }).DisableAntiforgery(); + + // Update role details + group.MapPost("/{id}", async ( + string id, + [FromForm] string name, + [FromForm] string? description, + HttpContext context, + RoleManager roleManager, + AuditService audit + ) => + { + var role = await roleManager.FindByIdAsync(id); + if (role is null) + return Results.NotFound(); + + role.Name = name.Trim(); + role.Description = description?.Trim() is { Length: > 0 } d ? d : null; + await roleManager.UpdateAsync(role); + + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + await audit.LogAsync(id, adminId, "RoleUpdated"); + + return Results.Redirect($"/admin/roles/{id}/edit?tab=details"); + }).DisableAntiforgery(); + + // Set role permissions + group.MapPost("/{id}/permissions", async ( + string id, + HttpContext context, + RoleManager roleManager, + UsersDbContext usersDb, + AuditService audit + ) => + { + var role = await roleManager.FindByIdAsync(id); + if (role is null) + return Results.NotFound(); + + var form = await context.Request.ReadFormAsync(); + var newPermissions = form["permissions"] + .Where(p => !string.IsNullOrEmpty(p)) + .ToHashSet(); + + var currentPermissions = usersDb.RolePermissions + .Where(rp => rp.RoleId == id) + .ToList(); + + var currentSet = currentPermissions.Select(p => p.Permission).ToHashSet(); + var removed = currentSet.Except(newPermissions).ToList(); + var added = newPermissions.Except(currentSet).ToList(); + + usersDb.RolePermissions.RemoveRange( + currentPermissions.Where(p => removed.Contains(p.Permission))); + foreach (var perm in added) + usersDb.RolePermissions.Add(new RolePermission { RoleId = id, Permission = perm }); + await usersDb.SaveChangesAsync(); + + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + foreach (var perm in removed) + await audit.LogAsync(id, adminId, "RolePermissionRemoved", perm); + foreach (var perm in added) + await audit.LogAsync(id, adminId, "RolePermissionAdded", perm); + + return Results.Redirect($"/admin/roles/{id}/edit?tab=permissions"); + }).DisableAntiforgery(); + + // Delete role + group.MapDelete("/{id}", async ( + string id, + HttpContext context, + RoleManager roleManager, + UserManager userManager, + AuditService audit + ) => + { + var role = await roleManager.FindByIdAsync(id); + if (role is null) + return Results.NotFound(); + + var usersInRole = role.Name is not null + ? await userManager.GetUsersInRoleAsync(role.Name) + : []; + if (usersInRole.Count > 0) + return Results.BadRequest(new { error = "Cannot delete role with assigned users" }); + + var roleName = role.Name; + await roleManager.DeleteAsync(role); + + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + await audit.LogAsync(id, adminId, "RoleDeleted", $"Deleted role {roleName}"); + + return Results.Redirect("/admin/roles"); + }); + } +} +``` + +**Step 5: Verify it compiles** + +Run: `dotnet build modules/Admin/src/Admin/Admin.csproj` +Expected: Build succeeded + +**Step 6: Commit** + +```bash +git add modules/Admin/src/Admin/Views/Admin/Roles* modules/Admin/src/Admin/Endpoints/Admin/AdminRolesEndpoint.cs +git commit -m "feat(admin): add role view and action endpoints (CRUD, permissions, delete guard)" +``` + +--- + +### Task 9: Frontend Setup (package.json, vite.config, shared components) + +**Files:** +- Create: `modules/Admin/src/Admin/package.json` +- Create: `modules/Admin/src/Admin/vite.config.ts` +- Create: `modules/Admin/src/Admin/tsconfig.json` +- Create: `modules/Admin/src/Admin/Pages/components/PermissionGroups.tsx` +- Create: `modules/Admin/src/Admin/Pages/components/ActivityTimeline.tsx` +- Create: `modules/Admin/src/Admin/Pages/components/TabNav.tsx` + +**Step 1: Create package.json** + +```json +{ + "private": true, + "name": "@simplemodule/admin", + "version": "0.0.0", + "scripts": { + "build": "vite build", + "watch": "vite build --watch" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} +``` + +**Step 2: Create vite.config.ts** + +```typescript +import { resolve } from 'node:path'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react()], + define: { 'process.env.NODE_ENV': JSON.stringify('production') }, + build: { + lib: { + entry: { + 'Admin.pages': resolve(__dirname, 'Pages/index.ts'), + }, + formats: ['es'], + fileName: (_format, entryName) => `${entryName}.js`, + }, + outDir: 'wwwroot', + emptyOutDir: false, + rollupOptions: { + external: ['react', 'react-dom', 'react/jsx-runtime', '@inertiajs/react'], + }, + }, +}); +``` + +**Step 3: Create tsconfig.json** + +Copy from Users module or create minimal one matching project conventions. + +**Step 4: Create TabNav.tsx** + +```tsx +import { router } from '@inertiajs/react'; + +interface Tab { + id: string; + label: string; +} + +interface TabNavProps { + tabs: Tab[]; + activeTab: string; + baseUrl: string; +} + +export function TabNav({ tabs, activeTab, baseUrl }: TabNavProps) { + return ( +
+ +
+ ); +} +``` + +**Step 5: Create PermissionGroups.tsx** + +```tsx +interface PermissionGroupsProps { + permissionsByModule: Record; + selected: string[]; + namePrefix: string; +} + +export function PermissionGroups({ permissionsByModule, selected, namePrefix }: PermissionGroupsProps) { + const modules = Object.entries(permissionsByModule); + + if (modules.length === 0) { + return

No permissions registered.

; + } + + return ( +
+ {modules.map(([moduleName, permissions]) => { + const allSelected = permissions.every((p) => selected.includes(p)); + + return ( +
+
+

{moduleName}

+ +
+
+ {permissions.map((perm) => { + const shortName = perm.includes('.') ? perm.split('.').pop() : perm; + return ( + + ); + })} +
+
+ ); + })} +
+ ); +} +``` + +**Step 6: Create ActivityTimeline.tsx** + +```tsx +interface ActivityEntry { + id: number; + action: string; + details: string | null; + performedBy: string; + timestamp: string; +} + +interface ActivityTimelineProps { + entries: ActivityEntry[]; + total: number; + onLoadMore?: () => void; +} + +const actionLabels: Record = { + UserCreated: 'User created', + UserUpdated: 'Details updated', + UserDeactivated: 'Account deactivated', + UserReactivated: 'Account reactivated', + RoleAdded: 'Role added', + RoleRemoved: 'Role removed', + PermissionGranted: 'Permission granted', + PermissionRevoked: 'Permission revoked', + PasswordReset: 'Password reset', + AccountLocked: 'Account locked', + AccountUnlocked: 'Account unlocked', + EmailReverified: 'Email re-verification required', + TwoFactorDisabled: '2FA disabled', +}; + +function timeAgo(timestamp: string): string { + const seconds = Math.floor((Date.now() - new Date(timestamp).getTime()) / 1000); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + return new Date(timestamp).toLocaleDateString(); +} + +export function ActivityTimeline({ entries, total, onLoadMore }: ActivityTimelineProps) { + if (entries.length === 0) { + return

No activity recorded.

; + } + + return ( +
+
+ {entries.map((entry) => ( +
+
+

+ {actionLabels[entry.action] ?? entry.action} + {entry.details && ( + — {entry.details} + )} +

+

+ by {entry.performedBy} · {timeAgo(entry.timestamp)} +

+
+
+ ))} +
+ {entries.length < total && onLoadMore && ( + + )} +
+ ); +} +``` + +**Step 7: Run npm install to register the new workspace** + +Run: `npm install` +Expected: workspace `@simplemodule/admin` resolved + +**Step 8: Commit** + +```bash +git add modules/Admin/src/Admin/package.json modules/Admin/src/Admin/vite.config.ts modules/Admin/src/Admin/tsconfig.json modules/Admin/src/Admin/Pages/components/ +git commit -m "feat(admin): add frontend setup with TabNav, PermissionGroups, and ActivityTimeline components" +``` + +--- + +### Task 10: Users List Page (React) + +**Files:** +- Create: `modules/Admin/src/Admin/Pages/Admin/Users.tsx` + +**Step 1: Create Users.tsx** + +```tsx +import { router } from '@inertiajs/react'; +import { + Badge, + Button, + Input, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@simplemodule/ui'; +import { type FormEvent, useState } from 'react'; + +interface User { + id: string; + displayName: string; + email: string; + emailConfirmed: boolean; + roles: string[]; + isLockedOut: boolean; + isDeactivated: boolean; + createdAt: string; +} + +interface Props { + users: User[]; + search: string; + page: number; + totalPages: number; + totalCount: number; +} + +function userStatus(user: User) { + if (user.isDeactivated) return { label: 'Deactivated', variant: 'secondary' as const }; + if (user.isLockedOut) return { label: 'Locked', variant: 'danger' as const }; + return { label: 'Active', variant: 'success' as const }; +} + +export default function Users({ users, search, page, totalPages, totalCount }: Props) { + const [searchValue, setSearchValue] = useState(search); + + function handleSearch(e: FormEvent) { + e.preventDefault(); + router.get('/admin/users', { search: searchValue, page: 1 }, { preserveState: true }); + } + + function goToPage(p: number) { + router.get('/admin/users', { search: searchValue, page: p }, { preserveState: true }); + } + + return ( +
+
+
+

Users

+

{totalCount} total users

+
+ +
+ +
+ setSearchValue(e.target.value)} + placeholder="Search by name or email..." + className="flex-1" + /> + +
+ + + + + Name + Email + Roles + Status + Created + + + + + {users.map((user) => { + const status = userStatus(user); + return ( + + {user.displayName || '\u2014'} + + {user.email} + {!user.emailConfirmed && ( + unverified + )} + + +
+ {user.roles.map((role) => ( + {role} + ))} +
+
+ + {status.label} + + + {new Date(user.createdAt).toLocaleDateString()} + + + + +
+ ); + })} +
+
+ + {totalPages > 1 && ( +
+ + Page {page} of {totalPages} + +
+ )} +
+ ); +} +``` + +**Step 2: Commit** + +```bash +git add modules/Admin/src/Admin/Pages/Admin/Users.tsx +git commit -m "feat(admin): add Users list page with search, pagination, status badges, and create button" +``` + +--- + +### Task 11: User Create Page (React) + +**Files:** +- Create: `modules/Admin/src/Admin/Pages/Admin/UsersCreate.tsx` + +**Step 1: Create UsersCreate.tsx** + +```tsx +import { router } from '@inertiajs/react'; +import { Button, Card, CardContent, Input, Label } from '@simplemodule/ui'; + +interface Role { + id: string; + name: string; + description: string | null; +} + +interface Props { + allRoles: Role[]; +} + +export default function UsersCreate({ allRoles }: Props) { + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + router.post('/admin/users', formData); + } + + return ( +
+
+ + + + + +

Create User

+
+

Add a new user account

+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + {allRoles.length > 0 && ( +
+ +
+ {allRoles.map((role) => ( +
+ + +
+ ))} +
+
+ )} + + +
+
+
+
+ ); +} +``` + +**Step 2: Commit** + +```bash +git add modules/Admin/src/Admin/Pages/Admin/UsersCreate.tsx +git commit -m "feat(admin): add User Create page with password, roles, and email confirmed" +``` + +--- + +### Task 12: User Edit Page with Tabs (React) + +**Files:** +- Create: `modules/Admin/src/Admin/Pages/Admin/UsersEdit.tsx` + +**Step 1: Create UsersEdit.tsx** + +```tsx +import { router } from '@inertiajs/react'; +import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input, Label } from '@simplemodule/ui'; +import { useState } from 'react'; +import { ActivityTimeline } from '../components/ActivityTimeline'; +import { PermissionGroups } from '../components/PermissionGroups'; +import { TabNav } from '../components/TabNav'; + +interface UserDetail { + id: string; + displayName: string; + email: string; + emailConfirmed: boolean; + twoFactorEnabled: boolean; + isLockedOut: boolean; + isDeactivated: boolean; + accessFailedCount: number; + createdAt: string; + lastLoginAt: string | null; +} + +interface Role { + id: string; + name: string; + description: string | null; +} + +interface ActivityEntry { + id: number; + action: string; + details: string | null; + performedBy: string; + timestamp: string; +} + +interface Props { + user: UserDetail; + userRoles: string[]; + userPermissions: string[]; + allRoles: Role[]; + permissionsByModule: Record; + activityLog: ActivityEntry[]; + activityTotal: number; + tab: string; +} + +const tabs = [ + { id: 'details', label: 'Details' }, + { id: 'roles', label: 'Roles & Permissions' }, + { id: 'security', label: 'Security' }, + { id: 'activity', label: 'Activity' }, +]; + +export default function UsersEdit({ + user, + userRoles, + userPermissions, + allRoles, + permissionsByModule, + activityLog, + activityTotal, + tab, +}: Props) { + const [activityEntries, setActivityEntries] = useState(activityLog); + const [activityPage, setActivityPage] = useState(1); + + async function loadMoreActivity() { + const nextPage = activityPage + 1; + const res = await fetch(`/admin/users/${user.id}/activity?page=${nextPage}`); + const data = await res.json(); + setActivityEntries((prev) => [...prev, ...data.entries]); + setActivityPage(nextPage); + } + + return ( +
+
+ + + + + +

Edit User

+ {user.isDeactivated && Deactivated} +
+

+ Created: {new Date(user.createdAt).toLocaleString()} + {user.lastLoginAt && ( + Last login: {new Date(user.lastLoginAt).toLocaleString()} + )} +

+ + + + {tab === 'details' && ( + <> + + + Details + + +
{ + e.preventDefault(); + router.post(`/admin/users/${user.id}`, new FormData(e.currentTarget)); + }} + className="space-y-4" + > +
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + + + Account Status + + + {user.isDeactivated ? ( +
+

This account has been deactivated.

+ +
+ ) : ( +
+

Deactivating will lock the account and prevent login.

+ +
+ )} +
+
+ + )} + + {tab === 'roles' && ( + <> + + + Roles + + +
{ + e.preventDefault(); + router.post(`/admin/users/${user.id}/roles`, new FormData(e.currentTarget)); + }} + > +
+ {allRoles.map((role) => ( +
+ + +
+ ))} + {allRoles.length === 0 && ( +

No roles defined.

+ )} +
+ +
+
+
+ + + + Direct Permissions + + +

+ These permissions are granted directly to this user, bypassing role assignments. +

+
{ + e.preventDefault(); + router.post(`/admin/users/${user.id}/permissions`, new FormData(e.currentTarget)); + }} + > + + + +
+
+ + )} + + {tab === 'security' && ( + <> + + + Reset Password + + +
{ + e.preventDefault(); + const formData = new FormData(e.currentTarget); + if (formData.get('newPassword') !== formData.get('confirmPassword')) { + alert('Passwords do not match.'); + return; + } + router.post(`/admin/users/${user.id}/reset-password`, formData); + }} + className="space-y-4" + > +
+ + +
+
+ + +
+ +
+
+
+ + + + Account Lock + + + {user.isLockedOut ? ( +
+

This account is locked.

+ +
+ ) : ( +
+

This account is active.

+ +
+ )} +
+
+ + + + Email Verification + + +

+ Status: {user.emailConfirmed ? 'Verified' : 'Not verified'} +

+ {user.emailConfirmed && ( + + )} +
+
+ + + + Two-Factor Authentication + + +

+ Status: {user.twoFactorEnabled ? 'Enabled' : 'Not enabled'} +

+ {user.twoFactorEnabled && ( + + )} +
+
+ + + + Login Info + + +
+
+ Failed login attempts: + {user.accessFailedCount} +
+
+ Last login: + + {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : 'Never'} + +
+
+
+
+ + )} + + {tab === 'activity' && ( + + + Activity Log + + + + + + )} +
+ ); +} +``` + +**Step 2: Commit** + +```bash +git add modules/Admin/src/Admin/Pages/Admin/UsersEdit.tsx +git commit -m "feat(admin): add User Edit page with Details, Roles & Permissions, Security, and Activity tabs" +``` + +--- + +### Task 13: Roles List, Create, and Edit Pages (React) + +**Files:** +- Create: `modules/Admin/src/Admin/Pages/Admin/Roles.tsx` +- Create: `modules/Admin/src/Admin/Pages/Admin/RolesCreate.tsx` +- Create: `modules/Admin/src/Admin/Pages/Admin/RolesEdit.tsx` + +**Step 1: Create Roles.tsx** + +```tsx +import { router } from '@inertiajs/react'; +import { + Badge, + Button, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@simplemodule/ui'; + +interface Role { + id: string; + name: string; + description: string | null; + userCount: number; + permissionCount: number; + createdAt: string; +} + +interface Props { + roles: Role[]; +} + +export default function Roles({ roles }: Props) { + function handleDelete(id: string, name: string) { + if (!confirm(`Delete role "${name}"?`)) return; + router.delete(`/admin/roles/${id}`, { + onError: () => alert('Cannot delete role with assigned users.'), + }); + } + + return ( +
+
+
+

Roles

+

Manage application roles

+
+ +
+ + + + + Name + Description + Users + Permissions + Created + + + + + {roles.map((role) => ( + + {role.name} + {role.description || '\u2014'} + {role.userCount} + {role.permissionCount} + + {new Date(role.createdAt).toLocaleDateString()} + + +
+ + +
+
+
+ ))} +
+
+
+ ); +} +``` + +**Step 2: Create RolesCreate.tsx** + +```tsx +import { router } from '@inertiajs/react'; +import { Button, Card, CardContent, Input, Label } from '@simplemodule/ui'; +import { PermissionGroups } from '../components/PermissionGroups'; + +interface Props { + permissionsByModule: Record; +} + +export default function RolesCreate({ permissionsByModule }: Props) { + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + router.post('/admin/roles', new FormData(e.currentTarget)); + } + + return ( +
+
+ + + + + +

Create Role

+
+

Add a new application role with permissions

+ + + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ +
+
+
+
+ ); +} +``` + +**Step 3: Create RolesEdit.tsx** + +```tsx +import { router } from '@inertiajs/react'; +import { + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Input, + Label, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@simplemodule/ui'; +import { PermissionGroups } from '../components/PermissionGroups'; +import { TabNav } from '../components/TabNav'; + +interface RoleDetail { + id: string; + name: string; + description: string | null; + createdAt: string; +} + +interface UserSummary { + id: string; + displayName: string; + email: string; +} + +interface Props { + role: RoleDetail; + users: UserSummary[]; + rolePermissions: string[]; + permissionsByModule: Record; + tab: string; +} + +const tabs = [ + { id: 'details', label: 'Details' }, + { id: 'permissions', label: 'Permissions' }, + { id: 'users', label: 'Users' }, +]; + +export default function RolesEdit({ role, users, rolePermissions, permissionsByModule, tab }: Props) { + return ( +
+
+ + + + + +

Edit Role

+
+

+ Created: {new Date(role.createdAt).toLocaleString()} +

+ + + + {tab === 'details' && ( + + +
{ + e.preventDefault(); + router.post(`/admin/roles/${role.id}`, new FormData(e.currentTarget)); + }} + className="space-y-4" + > +
+ + +
+
+ + +
+ +
+
+
+ )} + + {tab === 'permissions' && ( + + + Role Permissions + + +
{ + e.preventDefault(); + router.post(`/admin/roles/${role.id}/permissions`, new FormData(e.currentTarget)); + }} + > + + + +
+
+ )} + + {tab === 'users' && ( + + + Assigned Users ({users.length}) + + + {users.length === 0 ? ( +

No users assigned to this role.

+ ) : ( + + + + Name + Email + + + + + {users.map((u) => ( + + {u.displayName || '\u2014'} + {u.email} + + + + + ))} + +
+ )} +
+
+ )} +
+ ); +} +``` + +**Step 4: Commit** + +```bash +git add modules/Admin/src/Admin/Pages/Admin/Roles.tsx modules/Admin/src/Admin/Pages/Admin/RolesCreate.tsx modules/Admin/src/Admin/Pages/Admin/RolesEdit.tsx +git commit -m "feat(admin): add Roles list, create (with permissions), and tabbed edit pages" +``` + +--- + +### Task 14: Page Registry and Build Verification + +**Files:** +- Create: `modules/Admin/src/Admin/Pages/index.ts` + +**Step 1: Create Pages/index.ts** + +```typescript +import Roles from './Admin/Roles'; +import RolesCreate from './Admin/RolesCreate'; +import RolesEdit from './Admin/RolesEdit'; +import Users from './Admin/Users'; +import UsersCreate from './Admin/UsersCreate'; +import UsersEdit from './Admin/UsersEdit'; + +export const pages: Record = { + 'Admin/Admin/Users': Users, + 'Admin/Admin/UsersCreate': UsersCreate, + 'Admin/Admin/UsersEdit': UsersEdit, + 'Admin/Admin/Roles': Roles, + 'Admin/Admin/RolesCreate': RolesCreate, + 'Admin/Admin/RolesEdit': RolesEdit, +}; +``` + +**Step 2: Build frontend** + +Run: `npm run build --workspace=@simplemodule/admin` +Expected: Build succeeds, `Admin.pages.js` created in `modules/Admin/src/Admin/wwwroot/` + +**Step 3: Build full solution** + +Run: `dotnet build` +Expected: Build succeeded + +**Step 4: Commit** + +```bash +git add modules/Admin/src/Admin/Pages/index.ts +git commit -m "feat(admin): add page registry and verify full build" +``` + +--- + +### Task 15: Admin Tests Scaffold + +**Files:** +- Create: `modules/Admin/tests/Admin.Tests/Admin.Tests.csproj` +- Create: `modules/Admin/tests/Admin.Tests/Integration/AdminUsersEndpointTests.cs` +- Create: `modules/Admin/tests/Admin.Tests/Integration/AdminRolesEndpointTests.cs` + +**Step 1: Create Admin.Tests.csproj** + +```xml + + + net10.0 + true + + + + + + + + + + +``` + +**Step 2: Create AdminUsersEndpointTests.cs** + +Write integration tests covering: +- GET `/admin/users` — returns user list (authenticated as Admin role) +- GET `/admin/users` — returns 401/403 for non-admin +- GET `/admin/users?search=test` — filters results +- GET `/admin/users/create` — returns create form view +- POST `/admin/users` — creates user +- GET `/admin/users/{id}/edit` — returns edit view with tabs +- POST `/admin/users/{id}` — updates user details +- POST `/admin/users/{id}/roles` — sets user roles +- POST `/admin/users/{id}/permissions` — sets user permissions +- POST `/admin/users/{id}/lock` — locks account +- POST `/admin/users/{id}/unlock` — unlocks account +- POST `/admin/users/{id}/reset-password` — resets password +- POST `/admin/users/{id}/deactivate` — soft deletes +- POST `/admin/users/{id}/reactivate` — restores + +Each test should use `SimpleModuleWebApplicationFactory`, `CreateAuthenticatedClient` with Admin role claim, and seed test users via `UserManager` in a scope. + +**Step 3: Create AdminRolesEndpointTests.cs** + +Write integration tests covering: +- GET `/admin/roles` — returns role list +- GET `/admin/roles/create` — returns create form with permissions +- POST `/admin/roles` — creates role with permissions +- GET `/admin/roles/{id}/edit` — returns edit view with tabs +- POST `/admin/roles/{id}` — updates role details +- POST `/admin/roles/{id}/permissions` — sets role permissions +- DELETE `/admin/roles/{id}` — deletes role (and blocked when users assigned) + +**Step 4: Run tests** + +Run: `dotnet test modules/Admin/tests/Admin.Tests/` +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add modules/Admin/tests/ +git commit -m "test(admin): add integration tests for user and role admin endpoints" +``` + +--- + +### Task 16: Final Verification and Cleanup + +**Step 1: Run full test suite** + +Run: `dotnet test` +Expected: All tests pass (including existing tests that may have referenced old admin endpoints) + +**Step 2: Run frontend lint** + +Run: `npm run check` +Expected: No lint errors + +**Step 3: Run the app and verify** + +Run: `dotnet run --project template/SimpleModule.Host` +Visit `https://localhost:5001/admin/users` — should render the new Admin module's Users page +Visit `https://localhost:5001/admin/roles` — should render the new Admin module's Roles page + +**Step 4: Commit any final fixes** + +```bash +git add -A +git commit -m "chore(admin): final cleanup and verification" +``` diff --git a/docs/plans/2026-03-18-permission-system-design.md b/docs/plans/2026-03-18-permission-system-design.md new file mode 100644 index 00000000..0fd1849c --- /dev/null +++ b/docs/plans/2026-03-18-permission-system-design.md @@ -0,0 +1,239 @@ +# Permission System Design + +**Date:** 2026-03-18 +**Status:** Approved + +## Overview + +A module-scoped permission system for SimpleModule. Modules declare strongly-typed permissions via static constant classes, registered through a `ConfigurePermissions` method on `IModule`. Permissions can be assigned to both users and roles, stored in dedicated tables, and projected into claims at login for fast runtime checks. + +## Design Decisions + +| Aspect | Decision | +|--------|----------| +| Definition | Static class with `const string` fields per module | +| Registration | `ConfigurePermissions(PermissionRegistryBuilder)` on `IModule` | +| Checking | `[RequirePermission]` attribute (generator-wired) + inline `.RequirePermission()` escape hatch | +| Storage | Dedicated `UserPermissions` / `RolePermissions` tables | +| Runtime | Claims projection at login, claim-based checks at request time | +| Default policy | Deny by default — `[AllowAnonymous]` to opt out | +| Auto-generation | Generator emits permissions class for `CrudEndpoints` modules | +| Admin override | Admin role bypasses all permission checks | +| Admin UI | Extended role/user edit pages with module-grouped permission checklists | + +## Section 1: Permission Definition + +Each module defines a static class with string constants. The module name is used as a prefix to prevent collisions. + +```csharp +// modules/Products/src/Products/ProductsPermissions.cs +public static class ProductsPermissions +{ + public const string View = "Products.View"; + public const string Create = "Products.Create"; + public const string Update = "Products.Update"; + public const string Delete = "Products.Delete"; + // Custom permissions added manually: + public const string Export = "Products.Export"; +} +``` + +For modules using `CrudEndpoints`, the **source generator auto-emits** the four CRUD permission constants (`View`, `Create`, `Update`, `Delete`). The developer can extend with additional custom permissions in a separate partial class or a new class. + +The module registers its permissions in `ConfigurePermissions`: + +```csharp +public class ProductsModule : IModule +{ + public void ConfigurePermissions(PermissionRegistryBuilder builder) + { + builder.AddPermissions(); + } +} +``` + +`AddPermissions()` reflects over the static class at startup (one-time registration) and registers all `public const string` fields as known permissions. + +## Section 2: Permission Checking on Endpoints + +Two mechanisms, both supported: + +### Attribute-based (common case) + +```csharp +[RequirePermission(ProductsPermissions.Create)] +public class CreateProductEndpoint : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder builder) + { + builder.MapPost("/products", HandleAsync); + } +} +``` + +The source generator discovers `[RequirePermission]` on `IEndpoint` classes and emits `.RequireAuthorization(policy => policy.RequireClaim("permission", "Products.Create"))` in the generated `MapModuleEndpoints()` method. + +### Inline (escape hatch) + +```csharp +public class SpecialProductEndpoint : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder builder) + { + builder.MapPost("/products/special", HandleAsync) + .RequirePermission(ProductsPermissions.Create, ProductsPermissions.Export); + } +} +``` + +### AllowAnonymous opt-out + +```csharp +[AllowAnonymous] +public class PublicProductListEndpoint : IEndpoint { ... } +``` + +The generator recognizes `[AllowAnonymous]` and skips adding `.RequireAuthorization()`. Without either attribute, the deny-by-default policy requires authentication but no specific permission. + +### Multiple permissions + +Multiple permissions on one endpoint are treated as **AND** (user must have all). For OR semantics, use inline with a custom policy. + +## Section 3: Storage & Claims Projection + +### Dedicated tables (Users module) + +``` +UserPermissions +├── UserId (FK → AspNetUsers.Id) +├── Permission (string, e.g., "Products.Create") +└── PK: (UserId, Permission) + +RolePermissions +├── RoleId (FK → AspNetRoles.Id) +├── Permission (string, e.g., "Products.Create") +└── PK: (RoleId, Permission) +``` + +### Claims projection at login + +When the OAuth2 token is issued (in `AuthorizationEndpoint`): + +1. Load the user's direct permissions from `UserPermissions` +2. Load permissions from all the user's roles via `RolePermissions` +3. Merge and deduplicate +4. Add each as a claim: `type = "permission"`, `value = "Products.Create"` + +Claims travel with the token/cookie — no database hits at request time. + +### Authorization handler + +A custom `PermissionAuthorizationHandler` checks for the required `"permission"` claim(s) on the `ClaimsPrincipal`. + +### Admin override + +Users with the `Admin` role bypass permission checks entirely (all permissions implicitly granted). + +## Section 4: Source Generator Changes + +### EndpointExtensionsEmitter updates + +Discovers `[RequirePermission]` and `[AllowAnonymous]` on endpoint classes: + +```csharp +// Generated output in MapModuleEndpoints(): +endpoints.MapPost("/products", CreateProductEndpoint.HandleAsync) + .RequireAuthorization(policy => policy.RequireClaim("permission", "Products.Create")); + +endpoints.MapGet("/products/public", PublicProductListEndpoint.HandleAsync) + .AllowAnonymous(); + +// No attribute → deny-by-default: +endpoints.MapGet("/products/internal", InternalEndpoint.HandleAsync) + .RequireAuthorization(); // authenticated, no specific permission +``` + +### New PermissionsEmitter + +Scans modules that use `CrudEndpoints`. For each, emits: + +```csharp +// +namespace Products; + +public static partial class ProductsPermissions +{ + public const string View = "Products.View"; + public const string Create = "Products.Create"; + public const string Update = "Products.Update"; + public const string Delete = "Products.Delete"; +} +``` + +The class is `partial` so developers can extend it with custom permissions. The generator also auto-applies the correct `[RequirePermission]` to the CRUD endpoints it maps. + +## Section 5: IModule Interface & Registration Flow + +### IModule changes + +```csharp +public interface IModule +{ + void ConfigureServices(IServiceCollection services, IConfiguration configuration); + void ConfigureEndpoints(IEndpointRouteBuilder builder) { } + void ConfigurePermissions(PermissionRegistryBuilder builder) { } // new +} +``` + +### Registration flow at startup + +1. `AddModules()` called in `Program.cs` +2. For each module: + - `module.ConfigureServices(services, config)` + - `module.ConfigurePermissions(permissionRegistry)` +3. `PermissionRegistry` registered as a singleton +4. `AddAuthorization()` registers: + - Default policy: `RequireAuthenticatedUser()` + - `PermissionAuthorizationHandler` +5. At login (OAuth2 token issuance): + - Load user + role permissions from DB + - Merge, deduplicate, add as `"permission"` claims + +### PermissionRegistryBuilder API + +```csharp +public class PermissionRegistryBuilder +{ + public void AddPermissions() where T : class; // scans static string constants + public void AddPermission(string permission); // manual registration +} + +public class PermissionRegistry +{ + public IReadOnlySet AllPermissions { get; } + public IReadOnlyDictionary> ByModule { get; } // "Products" → [...] +} +``` + +The `PermissionRegistry` is used by the admin UI to display available permissions — no hardcoded lists. + +## Section 6: Admin UI for Permission Management + +### Role permission management + +- `GET /admin/roles/{id}/edit` — Extended to show permissions checklist grouped by module (from `PermissionRegistry`). +- `POST /admin/roles/{id}/permissions` — New endpoint. Saves selected permissions to `RolePermissions` table. + +### User permission management + +- `GET /admin/users/{id}/edit` — Extended to show: + - Inherited permissions (from roles) — displayed read-only + - Direct permissions — editable checklist +- `POST /admin/users/{id}/permissions` — New endpoint. Saves to `UserPermissions` table. + +### Frontend + +- React components receive `PermissionRegistry.ByModule` as props +- Permissions rendered as checkboxes grouped under module headings +- Inherited-from-role permissions shown as checked + disabled with tooltip showing which role grants them +- No new pages — everything fits into existing edit views for roles and users diff --git a/docs/plans/2026-03-18-permission-system-plan.md b/docs/plans/2026-03-18-permission-system-plan.md new file mode 100644 index 00000000..7a73953b --- /dev/null +++ b/docs/plans/2026-03-18-permission-system-plan.md @@ -0,0 +1,1313 @@ +# Permission System Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a module-scoped permission system with strongly-typed permissions, attribute-based endpoint protection, hybrid storage (dedicated tables + claims projection), deny-by-default policy, and admin permission seeding. + +**Architecture:** Modules declare permissions via static constant classes registered through `ConfigurePermissions()` on `IModule`. The source generator discovers `[RequirePermission]` and `[AllowAnonymous]` on endpoints and emits authorization wiring. Permissions are stored in dedicated `UserPermission`/`RolePermission` tables and projected into claims at login. Admin role gets all permissions seeded automatically. + +**Tech Stack:** ASP.NET Core Authorization, EF Core (SQLite/PostgreSQL), Roslyn IIncrementalGenerator (netstandard2.0), xUnit.v3 + FluentAssertions. + +**Design doc:** `docs/plans/2026-03-18-permission-system-design.md` + +--- + +### Task 1: Add `RequirePermissionAttribute` to Core + +**Files:** +- Create: `framework/SimpleModule.Core/Authorization/RequirePermissionAttribute.cs` + +**Step 1: Create the attribute** + +```csharp +using System; + +namespace SimpleModule.Core.Authorization; + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class RequirePermissionAttribute : Attribute +{ + public string[] Permissions { get; } + + public RequirePermissionAttribute(params string[] permissions) + { + Permissions = permissions; + } +} +``` + +**Step 2: Verify it builds** + +Run: `dotnet build framework/SimpleModule.Core` +Expected: SUCCESS + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Core/Authorization/RequirePermissionAttribute.cs +git commit -m "feat: add RequirePermissionAttribute to Core" +``` + +--- + +### Task 2: Add `PermissionRegistryBuilder` and `PermissionRegistry` to Core + +**Files:** +- Create: `framework/SimpleModule.Core/Authorization/PermissionRegistry.cs` +- Create: `framework/SimpleModule.Core/Authorization/PermissionRegistryBuilder.cs` + +**Step 1: Create PermissionRegistry** + +```csharp +using System.Collections.Generic; + +namespace SimpleModule.Core.Authorization; + +public sealed class PermissionRegistry +{ + public IReadOnlySet AllPermissions { get; } + public IReadOnlyDictionary> ByModule { get; } + + internal PermissionRegistry( + IReadOnlySet allPermissions, + IReadOnlyDictionary> byModule) + { + AllPermissions = allPermissions; + ByModule = byModule; + } +} +``` + +**Step 2: Create PermissionRegistryBuilder** + +```csharp +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace SimpleModule.Core.Authorization; + +public sealed class PermissionRegistryBuilder +{ + private readonly Dictionary> _byModule = new(); + + public void AddPermissions() where T : class + { + var fields = typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) + .Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string)); + + foreach (var field in fields) + { + var value = (string)field.GetRawConstantValue()!; + AddPermission(value); + } + } + + public void AddPermission(string permission) + { + var dotIndex = permission.IndexOf('.'); + var module = dotIndex >= 0 ? permission[..dotIndex] : "Global"; + + if (!_byModule.TryGetValue(module, out var list)) + { + list = new List(); + _byModule[module] = list; + } + + if (!list.Contains(permission)) + { + list.Add(permission); + } + } + + public PermissionRegistry Build() + { + var all = new HashSet(); + var byModule = new Dictionary>(); + + foreach (var (module, permissions) in _byModule) + { + byModule[module] = permissions.AsReadOnly(); + foreach (var p in permissions) + { + all.Add(p); + } + } + + return new PermissionRegistry(all, byModule); + } +} +``` + +**Step 3: Verify it builds** + +Run: `dotnet build framework/SimpleModule.Core` +Expected: SUCCESS + +**Step 4: Commit** + +```bash +git add framework/SimpleModule.Core/Authorization/PermissionRegistry.cs framework/SimpleModule.Core/Authorization/PermissionRegistryBuilder.cs +git commit -m "feat: add PermissionRegistryBuilder and PermissionRegistry" +``` + +--- + +### Task 3: Add `ConfigurePermissions` to `IModule` + +**Files:** +- Modify: `framework/SimpleModule.Core/IModule.cs:8-13` + +**Step 1: Add the method** + +Add `using SimpleModule.Core.Authorization;` to the top and add the new virtual method to `IModule`: + +```csharp +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Menu; + +namespace SimpleModule.Core; + +public interface IModule +{ + virtual void ConfigureServices(IServiceCollection services, IConfiguration configuration) { } + virtual void ConfigureEndpoints(IEndpointRouteBuilder endpoints) { } + virtual void ConfigureMenu(IMenuBuilder menus) { } + virtual void ConfigurePermissions(PermissionRegistryBuilder builder) { } +} +``` + +**Step 2: Verify it builds** + +Run: `dotnet build framework/SimpleModule.Core` +Expected: SUCCESS + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Core/IModule.cs +git commit -m "feat: add ConfigurePermissions to IModule interface" +``` + +--- + +### Task 4: Add `RequirePermission` extension method for inline use + +**Files:** +- Create: `framework/SimpleModule.Core/Authorization/EndpointPermissionExtensions.cs` + +**Step 1: Create the extension method** + +```csharp +using Microsoft.AspNetCore.Builder; + +namespace SimpleModule.Core.Authorization; + +public static class EndpointPermissionExtensions +{ + public static TBuilder RequirePermission(this TBuilder builder, params string[] permissions) + where TBuilder : IEndpointConventionBuilder + { + return builder.RequireAuthorization(policy => + { + policy.RequireAuthenticatedUser(); + foreach (var permission in permissions) + { + policy.RequireClaim("permission", permission); + } + }); + } +} +``` + +**Step 2: Verify it builds** + +Run: `dotnet build framework/SimpleModule.Core` +Expected: SUCCESS + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Core/Authorization/EndpointPermissionExtensions.cs +git commit -m "feat: add RequirePermission extension method for inline use" +``` + +--- + +### Task 5: Add `PermissionAuthorizationHandler` + +**Files:** +- Create: `framework/SimpleModule.Core/Authorization/PermissionAuthorizationHandler.cs` +- Create: `framework/SimpleModule.Core/Authorization/PermissionRequirement.cs` + +**Step 1: Create the requirement** + +```csharp +using Microsoft.AspNetCore.Authorization; + +namespace SimpleModule.Core.Authorization; + +public sealed class PermissionRequirement : IAuthorizationRequirement +{ + public string Permission { get; } + + public PermissionRequirement(string permission) + { + Permission = permission; + } +} +``` + +**Step 2: Create the handler** + +```csharp +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; + +namespace SimpleModule.Core.Authorization; + +public sealed class PermissionAuthorizationHandler : AuthorizationHandler +{ + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + PermissionRequirement requirement) + { + // Admin role bypasses all permission checks + if (context.User.IsInRole("Admin")) + { + context.Succeed(requirement); + return Task.CompletedTask; + } + + if (context.User.HasClaim("permission", requirement.Permission)) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } +} +``` + +**Step 3: Verify it builds** + +Run: `dotnet build framework/SimpleModule.Core` +Expected: SUCCESS + +**Step 4: Commit** + +```bash +git add framework/SimpleModule.Core/Authorization/PermissionAuthorizationHandler.cs framework/SimpleModule.Core/Authorization/PermissionRequirement.cs +git commit -m "feat: add PermissionAuthorizationHandler with admin bypass" +``` + +--- + +### Task 6: Update source generator — discover `[RequirePermission]` and `[AllowAnonymous]` on endpoints + +**Files:** +- Modify: `framework/SimpleModule.Generator/Discovery/DiscoveryData.cs:101` (EndpointInfoRecord) +- Modify: `framework/SimpleModule.Generator/Discovery/DiscoveryData.cs:204` (EndpointInfo mutable class) +- Modify: `framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs:278-335` (FindEndpointTypes) + +**Step 1: Extend `EndpointInfoRecord` with permission data** + +In `DiscoveryData.cs`, replace: + +```csharp +internal readonly record struct EndpointInfoRecord(string FullyQualifiedName); +``` + +With: + +```csharp +internal readonly record struct EndpointInfoRecord( + string FullyQualifiedName, + ImmutableArray RequiredPermissions, + bool AllowAnonymous +) +{ + public bool Equals(EndpointInfoRecord other) + { + return FullyQualifiedName == other.FullyQualifiedName + && AllowAnonymous == other.AllowAnonymous + && RequiredPermissions.SequenceEqual(other.RequiredPermissions); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = hash * 31 + FullyQualifiedName.GetHashCode(); + hash = hash * 31 + AllowAnonymous.GetHashCode(); + foreach (var p in RequiredPermissions) + hash = hash * 31 + p.GetHashCode(); + return hash; + } + } +} +``` + +**Step 2: Update the mutable `EndpointInfo` class** + +Replace: + +```csharp +internal sealed class EndpointInfo +{ + public string FullyQualifiedName { get; set; } = ""; +} +``` + +With: + +```csharp +internal sealed class EndpointInfo +{ + public string FullyQualifiedName { get; set; } = ""; + public List RequiredPermissions { get; set; } = new(); + public bool AllowAnonymous { get; set; } +} +``` + +**Step 3: Update `EndpointInfoRecord` construction in `SymbolDiscovery.Extract`** + +In `SymbolDiscovery.cs`, find the line (around line 167): + +```csharp +m.Endpoints.Select(e => new EndpointInfoRecord(e.FullyQualifiedName)) +``` + +Replace with: + +```csharp +m.Endpoints.Select(e => new EndpointInfoRecord( + e.FullyQualifiedName, + e.RequiredPermissions.ToImmutableArray(), + e.AllowAnonymous)) +``` + +**Step 4: Update `FindEndpointTypes` to extract attributes** + +In `SymbolDiscovery.cs`, in the `FindEndpointTypes` method, update the `IEndpoint` branch (around line 328-330). After creating the `EndpointInfo`, scan the type's attributes: + +Replace: + +```csharp +else if (ImplementsInterface(typeSymbol, endpointInterfaceSymbol)) +{ + endpoints.Add(new EndpointInfo { FullyQualifiedName = fqn }); +} +``` + +With: + +```csharp +else if (ImplementsInterface(typeSymbol, endpointInterfaceSymbol)) +{ + var info = new EndpointInfo { FullyQualifiedName = fqn }; + + foreach (var attr in typeSymbol.GetAttributes()) + { + var attrName = attr.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + if (attrName == "global::SimpleModule.Core.Authorization.RequirePermissionAttribute") + { + // params string[] permissions — stored as a single array constructor argument + if (attr.ConstructorArguments.Length > 0) + { + var arg = attr.ConstructorArguments[0]; + if (arg.Kind == TypedConstantKind.Array) + { + foreach (var val in arg.Values) + { + if (val.Value is string s) + info.RequiredPermissions.Add(s); + } + } + else if (arg.Value is string single) + { + info.RequiredPermissions.Add(single); + } + } + } + else if (attrName == "global::Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute") + { + info.AllowAnonymous = true; + } + } + + endpoints.Add(info); +} +``` + +**Step 5: Update `ModuleInfoRecord` equality to account for new `EndpointInfoRecord` fields** + +The `ModuleInfoRecord.Equals` already calls `Endpoints.SequenceEqual(other.Endpoints)`, which will use the updated `EndpointInfoRecord.Equals`. No changes needed here. + +**Step 6: Verify it builds** + +Run: `dotnet build framework/SimpleModule.Generator` +Expected: SUCCESS + +**Step 7: Commit** + +```bash +git add framework/SimpleModule.Generator/Discovery/DiscoveryData.cs framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +git commit -m "feat: generator discovers RequirePermission and AllowAnonymous on endpoints" +``` + +--- + +### Task 7: Update `EndpointExtensionsEmitter` to emit authorization wiring + +**Files:** +- Modify: `framework/SimpleModule.Generator/Emitters/EndpointExtensionsEmitter.cs:14-58` + +**Step 1: Update usings and endpoint mapping** + +Add `using Microsoft.AspNetCore.Authorization;` to the generated output. Then update the endpoint registration loop to append auth calls. + +In `EndpointExtensionsEmitter.cs`, update the generated code. The emitter currently emits: + +```csharp +sb.AppendLine($" new {endpoint.FullyQualifiedName}().Map(group);"); +``` + +Change each endpoint mapping line to check for `AllowAnonymous` and `RequiredPermissions`: + +For the route-prefix branch (lines 45-48), replace: + +```csharp +foreach (var endpoint in module.Endpoints) +{ + sb.AppendLine($" new {endpoint.FullyQualifiedName}().Map(group);"); +} +``` + +With a helper method call. Add a private method to the emitter class: + +```csharp +private static void EmitEndpointMapping(StringBuilder sb, EndpointInfoRecord endpoint, string target) +{ + sb.Append($" new {endpoint.FullyQualifiedName}().Map({target});"); + + // Note: The Map() method registers the route internally. + // We cannot chain .AllowAnonymous() or .RequireAuthorization() on the Map() call + // because Map() is void. Instead we need a different approach. +} +``` + +**IMPORTANT DESIGN CONSIDERATION:** The `IEndpoint.Map()` method returns `void`, so we cannot chain `.AllowAnonymous()` or `.RequireAuthorization()` on it. The emitter needs a different approach. + +**Revised approach:** Instead of chaining on `Map()`, the emitter should generate a wrapper that: +1. Calls `endpoint.Map(group)` as before +2. After all endpoints in a module are mapped, applies auth policies to the group level + +Actually, the better approach is to apply authorization at the **route group** level for the module, and let `[AllowAnonymous]` on individual endpoints override. Since we already have `var group = app.MapGroup(...)`, we can add `.RequireAuthorization()` to the group to enforce deny-by-default. Individual endpoints with `[AllowAnonymous]` will need to call `.AllowAnonymous()` inside their `Map()` method. + +**Revised emitter changes:** + +Update the route-prefix group creation line (line 43): + +Replace: + +```csharp +sb.AppendLine( + $" var group = app.MapGroup(\"{module.RoutePrefix}\").WithTags(\"{module.ModuleName}\");" +); +``` + +With: + +```csharp +sb.AppendLine( + $" var group = app.MapGroup(\"{module.RoutePrefix}\").WithTags(\"{module.ModuleName}\").RequireAuthorization();" +); +``` + +This makes all auto-registered endpoints require authentication by default (deny-by-default). Endpoints with `[AllowAnonymous]` opt out inside their `Map()` call. Endpoints with `[RequirePermission]` need the permission check applied. + +For `[RequirePermission]` — since `Map()` is void, the endpoint class itself should apply `.RequireAuthorization()` in its `Map()` method (inline approach). The attribute is still useful for the **source generator to auto-generate the `Map()` body for `CrudEndpoints`** (Task 9). + +**However**, a better approach that doesn't change `IEndpoint.Map()`: make `Map()` return `RouteHandlerBuilder?` or modify the emitter to NOT use `Map()` for endpoints with permissions. Let's keep it simple: + +**Final revised approach:** Add `.RequireAuthorization()` to the group (deny-by-default). The `[RequirePermission]` attribute is used by developers as documentation and by the CrudEndpoints generator (Task 9). For manually-written endpoints, developers use the inline `.RequirePermission()` extension inside their `Map()` method. This keeps `IEndpoint` unchanged and the emitter changes minimal. + +So the only emitter change is adding `.RequireAuthorization()` to groups: + +In `EndpointExtensionsEmitter.cs`: + +Replace line 43: +```csharp +$" var group = app.MapGroup(\"{module.RoutePrefix}\").WithTags(\"{module.ModuleName}\");" +``` +With: +```csharp +$" var group = app.MapGroup(\"{module.RoutePrefix}\").WithTags(\"{module.ModuleName}\").RequireAuthorization();" +``` + +Replace line 76 (view group): +```csharp +$" var viewGroup = app.MapGroup(\"{module.ViewPrefix}\").WithTags(\"{module.ModuleName}\").ExcludeFromDescription();" +``` +With: +```csharp +$" var viewGroup = app.MapGroup(\"{module.ViewPrefix}\").WithTags(\"{module.ModuleName}\").ExcludeFromDescription().RequireAuthorization();" +``` + +Also add the `using Microsoft.AspNetCore.Authorization;` to the generated file (after line 18): +```csharp +sb.AppendLine("using Microsoft.AspNetCore.Authorization;"); +``` + +**Step 2: Verify it builds** + +Run: `dotnet build framework/SimpleModule.Generator` +Expected: SUCCESS + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Generator/Emitters/EndpointExtensionsEmitter.cs +git commit -m "feat: emitter adds RequireAuthorization to endpoint groups (deny-by-default)" +``` + +--- + +### Task 8: Update `ModuleExtensionsEmitter` to call `ConfigurePermissions` and register services + +**Files:** +- Modify: `framework/SimpleModule.Generator/Discovery/DiscoveryData.cs` (add `HasConfigurePermissions` to `ModuleInfoRecord` and `ModuleInfo`) +- Modify: `framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs` (detect `ConfigurePermissions`) +- Modify: `framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs:37-66` + +**Step 1: Add `HasConfigurePermissions` to data model** + +In `DiscoveryData.cs`, add `bool HasConfigurePermissions` to `ModuleInfoRecord` (after `HasConfigureMenu`): + +```csharp +internal readonly record struct ModuleInfoRecord( + string FullyQualifiedName, + string ModuleName, + bool HasConfigureServices, + bool HasConfigureEndpoints, + bool HasConfigureMenu, + bool HasConfigurePermissions, + bool HasRazorComponents, + string RoutePrefix, + string ViewPrefix, + ImmutableArray Endpoints, + ImmutableArray Views +) +``` + +Update `Equals` and `GetHashCode` to include `HasConfigurePermissions`. + +In `ModuleInfo` mutable class, add: +```csharp +public bool HasConfigurePermissions { get; set; } +``` + +**Step 2: Detect `ConfigurePermissions` in `SymbolDiscovery`** + +In `SymbolDiscovery.cs`, in the `FindModuleTypes` method (around line 266), add: + +```csharp +HasConfigurePermissions = DeclaresMethod(typeSymbol, "ConfigurePermissions"), +``` + +Also update the `ModuleInfoRecord` construction (around line 158) to include: + +```csharp +m.HasConfigurePermissions, +``` + +**Step 3: Update `ModuleExtensionsEmitter` to generate permission registration** + +In `ModuleExtensionsEmitter.cs`, add authorization-related usings to the generated output (after line 21): + +```csharp +sb.AppendLine("using Microsoft.AspNetCore.Authorization;"); +sb.AppendLine("using SimpleModule.Core.Authorization;"); +``` + +After the `ConfigureServices` calls (around line 47), add permission registration: + +```csharp +// Permission registry +sb.AppendLine(); +sb.AppendLine(" var permissionBuilder = new PermissionRegistryBuilder();"); + +foreach (var module in modules.Where(m => m.HasConfigurePermissions)) +{ + var fieldName = TypeMappingHelpers.GetModuleFieldName(module.FullyQualifiedName); + sb.AppendLine($" {fieldName}.ConfigurePermissions(permissionBuilder);"); +} + +sb.AppendLine(" var permissionRegistry = permissionBuilder.Build();"); +sb.AppendLine(" services.AddSingleton(permissionRegistry);"); +sb.AppendLine(); +sb.AppendLine(" services.AddAuthorizationBuilder()"); +sb.AppendLine(" .SetDefaultPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());"); +sb.AppendLine(" services.AddSingleton();"); +``` + +**Step 4: Verify it builds** + +Run: `dotnet build framework/SimpleModule.Generator` +Expected: SUCCESS + +**Step 5: Commit** + +```bash +git add framework/SimpleModule.Generator/Discovery/DiscoveryData.cs framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs +git commit -m "feat: generator calls ConfigurePermissions and registers authorization services" +``` + +--- + +### Task 9: Add permission entities and DbContext changes to Users module + +**Files:** +- Create: `modules/Users/src/Users/Entities/UserPermission.cs` +- Create: `modules/Users/src/Users/Entities/RolePermission.cs` +- Modify: `modules/Users/src/Users/UsersDbContext.cs:1-20` + +**Step 1: Create `UserPermission` entity** + +```csharp +namespace SimpleModule.Users.Entities; + +public class UserPermission +{ + public string UserId { get; set; } = string.Empty; + public string Permission { get; set; } = string.Empty; + + public ApplicationUser? User { get; set; } +} +``` + +**Step 2: Create `RolePermission` entity** + +```csharp +namespace SimpleModule.Users.Entities; + +public class RolePermission +{ + public string RoleId { get; set; } = string.Empty; + public string Permission { get; set; } = string.Empty; + + public ApplicationRole? Role { get; set; } +} +``` + +**Step 3: Update `UsersDbContext`** + +```csharp +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SimpleModule.Database; +using SimpleModule.Users.Entities; + +namespace SimpleModule.Users; + +public class UsersDbContext( + DbContextOptions options, + IOptions dbOptions +) : IdentityDbContext(options) +{ + public DbSet UserPermissions => Set(); + public DbSet RolePermissions => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + builder.Entity(entity => + { + entity.HasKey(e => new { e.UserId, e.Permission }); + entity.HasOne(e => e.User) + .WithMany() + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + + builder.Entity(entity => + { + entity.HasKey(e => new { e.RoleId, e.Permission }); + entity.HasOne(e => e.Role) + .WithMany() + .HasForeignKey(e => e.RoleId) + .OnDelete(DeleteBehavior.Cascade); + }); + + builder.ApplyModuleSchema("Users", dbOptions.Value); + } +} +``` + +**Step 4: Verify it builds** + +Run: `dotnet build modules/Users/src/Users` +Expected: SUCCESS + +**Step 5: Commit** + +```bash +git add modules/Users/src/Users/Entities/UserPermission.cs modules/Users/src/Users/Entities/RolePermission.cs modules/Users/src/Users/UsersDbContext.cs +git commit -m "feat: add UserPermission and RolePermission entities with DbContext config" +``` + +--- + +### Task 10: Add permission claims projection to `AuthorizationEndpoint` + +**Files:** +- Modify: `modules/Users/src/Users/Endpoints/Connect/AuthorizationEndpoint.cs:69-94` + +**Step 1: Add permission claims loading** + +After the roles are added (line 80), add permission loading: + +```csharp +// Load permissions from user's roles +var dbContext = context.RequestServices.GetRequiredService(); + +var roleIds = await dbContext.Roles + .Where(r => roles.Contains(r.Name!)) + .Select(r => r.Id) + .ToListAsync(); + +var rolePermissions = await dbContext.RolePermissions + .Where(rp => roleIds.Contains(rp.RoleId)) + .Select(rp => rp.Permission) + .ToListAsync(); + +// Load direct user permissions +var userId = await userManager.GetUserIdAsync(user); +var userPermissions = await dbContext.UserPermissions + .Where(up => up.UserId == userId) + .Select(up => up.Permission) + .ToListAsync(); + +// Merge and deduplicate, add as claims +var allPermissions = new HashSet(rolePermissions); +foreach (var p in userPermissions) + allPermissions.Add(p); + +foreach (var permission in allPermissions) +{ + identity.AddClaim("permission", permission); +} +``` + +Add required usings at top of file: +```csharp +using Microsoft.EntityFrameworkCore; +``` + +**Step 2: Update `GetDestinations` to include permission claims** + +Add a case for the `"permission"` claim type: + +```csharp +case "permission": + yield return Destinations.AccessToken; + yield break; +``` + +**Step 3: Verify it builds** + +Run: `dotnet build modules/Users/src/Users` +Expected: SUCCESS + +**Step 4: Commit** + +```bash +git add modules/Users/src/Users/Endpoints/Connect/AuthorizationEndpoint.cs +git commit -m "feat: project permissions into claims at OAuth2 token issuance" +``` + +--- + +### Task 11: Add `ProductsPermissions` static class and register on module + +**Files:** +- Create: `modules/Products/src/Products/ProductsPermissions.cs` +- Modify: `modules/Products/src/Products/ProductsModule.cs:15-21` + +**Step 1: Create the permissions class** + +```csharp +namespace SimpleModule.Products; + +public static class ProductsPermissions +{ + public const string View = "Products.View"; + public const string Create = "Products.Create"; + public const string Update = "Products.Update"; + public const string Delete = "Products.Delete"; +} +``` + +**Step 2: Add `ConfigurePermissions` to `ProductsModule`** + +Add using and method: + +```csharp +using SimpleModule.Core.Authorization; +``` + +Add method to `ProductsModule` class: + +```csharp +public void ConfigurePermissions(PermissionRegistryBuilder builder) +{ + builder.AddPermissions(); +} +``` + +**Step 3: Add `[RequirePermission]` to Product endpoints** + +Update each endpoint class in `modules/Products/src/Products/Endpoints/Products/`: + +- `GetAllEndpoint.cs`: Add `[RequirePermission(ProductsPermissions.View)]` +- `GetByIdEndpoint.cs`: Add `[RequirePermission(ProductsPermissions.View)]` +- `CreateEndpoint.cs`: Add `[RequirePermission(ProductsPermissions.Create)]` +- `UpdateEndpoint.cs`: Add `[RequirePermission(ProductsPermissions.Update)]` +- `DeleteEndpoint.cs`: Add `[RequirePermission(ProductsPermissions.Delete)]` + +Each file needs `using SimpleModule.Core.Authorization;` and the attribute on the class. Then inside the `Map()` method, add the inline permission check. Example for `GetAllEndpoint.cs`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Endpoints; +using SimpleModule.Products.Contracts; + +namespace SimpleModule.Products.Endpoints.Products; + +[RequirePermission(ProductsPermissions.View)] +public class GetAllEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapGet("/", (IProductContracts productContracts) => + CrudEndpoints.GetAll(productContracts.GetAllProductsAsync)) + .RequirePermission(ProductsPermissions.View); +} +``` + +**Step 4: Verify it builds** + +Run: `dotnet build modules/Products/src/Products` +Expected: SUCCESS + +**Step 5: Commit** + +```bash +git add modules/Products/src/Products/ProductsPermissions.cs modules/Products/src/Products/ProductsModule.cs modules/Products/src/Products/Endpoints/Products/ +git commit -m "feat: add ProductsPermissions and protect Product endpoints" +``` + +--- + +### Task 12: Add `OrdersPermissions` and protect Orders endpoints + +**Files:** +- Create: `modules/Orders/src/Orders/OrdersPermissions.cs` +- Modify: `modules/Orders/src/Orders/OrdersModule.cs` +- Modify: All Orders endpoint files + +Follow the same pattern as Task 11. Create `OrdersPermissions` with `View`, `Create`, `Update`, `Delete` constants. Add `ConfigurePermissions` to `OrdersModule`. Add `[RequirePermission]` attributes and inline `.RequirePermission()` calls to all Orders endpoints. + +**Step 1: Create permissions, update module, protect endpoints** + +**Step 2: Verify it builds** + +Run: `dotnet build modules/Orders/src/Orders` +Expected: SUCCESS + +**Step 3: Commit** + +```bash +git add modules/Orders/src/Orders/OrdersPermissions.cs modules/Orders/src/Orders/OrdersModule.cs modules/Orders/src/Orders/Endpoints/ +git commit -m "feat: add OrdersPermissions and protect Orders endpoints" +``` + +--- + +### Task 13: Mark public endpoints with `[AllowAnonymous]` + +**Files:** +- Modify: Connect endpoints in Users module (AuthorizationEndpoint, TokenEndpoint, EndSessionEndpoint, UserInfoEndpoint) +- Modify: Any view endpoints that should be public (login page, etc.) +- Modify: Identity/Account endpoints + +**Step 1: Add `[AllowAnonymous]` to OAuth2 connect endpoints** + +These endpoints handle auth flow itself and must remain public. Add `[Microsoft.AspNetCore.Authorization.AllowAnonymous]` attribute to: +- `AuthorizationEndpoint` +- `TokenEndpoint` +- `EndSessionEndpoint` +- `UserInfoEndpoint` + +And inside their `Map()` methods, chain `.AllowAnonymous()` on the route: + +```csharp +app.MapMethods(ConnectRouteConstants.ConnectAuthorize, ...) + .ExcludeFromDescription() + .AllowAnonymous(); +``` + +**Step 2: Add `[AllowAnonymous]` to public view endpoints** + +Any browse/landing pages that should be accessible without login. Check each `IViewEndpoint` and decide based on the current `RequiresAuth` menu property. + +**Step 3: Verify it builds** + +Run: `dotnet build` +Expected: SUCCESS + +**Step 4: Commit** + +```bash +git add modules/Users/src/Users/Endpoints/ +git commit -m "feat: mark public endpoints with AllowAnonymous" +``` + +--- + +### Task 14: Seed all permissions for Admin role + +**Files:** +- Create: `modules/Users/src/Users/Services/PermissionSeedService.cs` +- Modify: `modules/Users/src/Users/UsersModule.cs:114` (register the service) + +**Step 1: Create the seed service** + +```csharp +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SimpleModule.Core.Authorization; +using SimpleModule.Users.Constants; +using SimpleModule.Users.Entities; + +namespace SimpleModule.Users.Services; + +public partial class PermissionSeedService( + IServiceProvider serviceProvider, + PermissionRegistry permissionRegistry, + ILogger logger +) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var adminRole = await roleManager.FindByNameAsync(SeedConstants.AdminRole); + if (adminRole is null) + return; + + var existingPermissions = await dbContext.RolePermissions + .Where(rp => rp.RoleId == adminRole.Id) + .Select(rp => rp.Permission) + .ToListAsync(cancellationToken); + + var existingSet = new HashSet(existingPermissions); + var newPermissions = new List(); + + foreach (var permission in permissionRegistry.AllPermissions) + { + if (!existingSet.Contains(permission)) + { + newPermissions.Add(new RolePermission + { + RoleId = adminRole.Id, + Permission = permission, + }); + } + } + + if (newPermissions.Count > 0) + { + LogSeedingPermissions(logger, newPermissions.Count); + dbContext.RolePermissions.AddRange(newPermissions); + await dbContext.SaveChangesAsync(cancellationToken); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Seeding {Count} permissions for Admin role..." + )] + private static partial void LogSeedingPermissions(ILogger logger, int count); +} +``` + +**Step 2: Register in `UsersModule.ConfigureServices`** + +After `services.AddHostedService();` (line 115), add: + +```csharp +services.AddHostedService(); +``` + +**Step 3: Verify it builds** + +Run: `dotnet build modules/Users/src/Users` +Expected: SUCCESS + +**Step 4: Commit** + +```bash +git add modules/Users/src/Users/Services/PermissionSeedService.cs modules/Users/src/Users/UsersModule.cs +git commit -m "feat: seed all registered permissions for Admin role on startup" +``` + +--- + +### Task 15: Update `Program.cs` — remove duplicate `AddAuthorization` call + +**Files:** +- Modify: `template/SimpleModule.Host/Program.cs:126` + +**Step 1: Remove the standalone `AddAuthorization` call** + +The generated `AddModules()` now registers authorization with the correct default policy. Remove line 126: + +```csharp +builder.Services.AddAuthorization(); +``` + +This prevents the generated default policy from being overwritten by a bare `AddAuthorization()` call. + +**Step 2: Verify it builds and runs** + +Run: `dotnet build && dotnet run --project template/SimpleModule.Host --launch-profile https` +Expected: App starts, endpoints require auth by default + +**Step 3: Commit** + +```bash +git add template/SimpleModule.Host/Program.cs +git commit -m "fix: remove duplicate AddAuthorization, use generator-configured policy" +``` + +--- + +### Task 16: Update test infrastructure for permissions + +**Files:** +- Modify: `tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs` + +**Step 1: Update `CreateAuthenticatedClient` to support permission claims** + +Add a convenience overload: + +```csharp +public HttpClient CreateAuthenticatedClient(string[] permissions, params Claim[] additionalClaims) +{ + var claims = new List(additionalClaims); + foreach (var permission in permissions) + { + claims.Add(new Claim("permission", permission)); + } + return CreateAuthenticatedClient(claims.ToArray()); +} +``` + +**Step 2: Verify it builds** + +Run: `dotnet build tests/SimpleModule.Tests.Shared` +Expected: SUCCESS + +**Step 3: Commit** + +```bash +git add tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs +git commit -m "feat: add permission-aware test client helper" +``` + +--- + +### Task 17: Write tests for permission system + +**Files:** +- Create: `tests/SimpleModule.Core.Tests/Authorization/PermissionRegistryBuilderTests.cs` +- Create: `tests/SimpleModule.Core.Tests/Authorization/PermissionAuthorizationHandlerTests.cs` +- Modify: `modules/Products/tests/Products.Tests/Integration/` (update existing tests for auth) + +**Step 1: Write `PermissionRegistryBuilderTests`** + +```csharp +using FluentAssertions; +using SimpleModule.Core.Authorization; + +namespace SimpleModule.Core.Tests.Authorization; + +public class PermissionRegistryBuilderTests +{ + private static class TestPermissions + { + public const string View = "Test.View"; + public const string Create = "Test.Create"; + } + + [Fact] + public void AddPermissions_ScansStaticConstants() + { + var builder = new PermissionRegistryBuilder(); + builder.AddPermissions(); + var registry = builder.Build(); + + registry.AllPermissions.Should().Contain("Test.View"); + registry.AllPermissions.Should().Contain("Test.Create"); + } + + [Fact] + public void Build_GroupsByModule() + { + var builder = new PermissionRegistryBuilder(); + builder.AddPermission("Products.View"); + builder.AddPermission("Products.Create"); + builder.AddPermission("Orders.View"); + var registry = builder.Build(); + + registry.ByModule.Should().ContainKey("Products"); + registry.ByModule["Products"].Should().HaveCount(2); + registry.ByModule.Should().ContainKey("Orders"); + registry.ByModule["Orders"].Should().HaveCount(1); + } + + [Fact] + public void AddPermission_DeduplicatesValues() + { + var builder = new PermissionRegistryBuilder(); + builder.AddPermission("Products.View"); + builder.AddPermission("Products.View"); + var registry = builder.Build(); + + registry.AllPermissions.Should().HaveCount(1); + } +} +``` + +**Step 2: Write `PermissionAuthorizationHandlerTests`** + +```csharp +using System.Security.Claims; +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using SimpleModule.Core.Authorization; + +namespace SimpleModule.Core.Tests.Authorization; + +public class PermissionAuthorizationHandlerTests +{ + [Fact] + public async Task Handle_UserWithPermissionClaim_Succeeds() + { + var handler = new PermissionAuthorizationHandler(); + var requirement = new PermissionRequirement("Products.View"); + var user = CreateUser(new Claim("permission", "Products.View")); + var context = new AuthorizationHandlerContext([requirement], user, null); + + await handler.HandleAsync(context); + + context.HasSucceeded.Should().BeTrue(); + } + + [Fact] + public async Task Handle_UserWithoutPermissionClaim_Fails() + { + var handler = new PermissionAuthorizationHandler(); + var requirement = new PermissionRequirement("Products.View"); + var user = CreateUser(new Claim("permission", "Orders.View")); + var context = new AuthorizationHandlerContext([requirement], user, null); + + await handler.HandleAsync(context); + + context.HasSucceeded.Should().BeFalse(); + } + + [Fact] + public async Task Handle_AdminRole_BypassesPermissionCheck() + { + var handler = new PermissionAuthorizationHandler(); + var requirement = new PermissionRequirement("Products.Delete"); + var user = CreateUser(new Claim(ClaimTypes.Role, "Admin")); + var context = new AuthorizationHandlerContext([requirement], user, null); + + await handler.HandleAsync(context); + + context.HasSucceeded.Should().BeTrue(); + } + + private static ClaimsPrincipal CreateUser(params Claim[] claims) + { + var identity = new ClaimsIdentity(claims, "Test"); + return new ClaimsPrincipal(identity); + } +} +``` + +**Step 3: Update Products integration tests** + +The existing product endpoint tests need to pass permission claims. Update tests to use `CreateAuthenticatedClient` with the appropriate permissions. Tests should verify: +- Unauthenticated → 401 +- Authenticated without permission → 403 +- Authenticated with permission → 200 + +**Step 4: Run all tests** + +Run: `dotnet test` +Expected: ALL PASS + +**Step 5: Commit** + +```bash +git add tests/SimpleModule.Core.Tests/Authorization/ modules/Products/tests/Products.Tests/ +git commit -m "test: add permission system unit and integration tests" +``` + +--- + +### Task 18: Full integration test — build and run + +**Step 1: Clean build** + +Run: `dotnet build` +Expected: SUCCESS with no errors + +**Step 2: Run all tests** + +Run: `dotnet test` +Expected: ALL PASS + +**Step 3: Manual smoke test** + +Run: `dotnet run --project template/SimpleModule.Host --launch-profile https` +Expected: +- App starts on https://localhost:5001 +- Swagger UI loads +- Unauthenticated API calls to `/api/products` return 401 +- Admin user can access all endpoints after login +- Check logs for "Seeding X permissions for Admin role..." + +**Step 4: Final commit if any fixes needed** diff --git a/docs/plans/2026-03-18-source-generator-packages-plan.md b/docs/plans/2026-03-18-source-generator-packages-plan.md new file mode 100644 index 00000000..2f8a824d --- /dev/null +++ b/docs/plans/2026-03-18-source-generator-packages-plan.md @@ -0,0 +1,842 @@ +# Source Generator Packages Integration Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Integrate source generator packages to eliminate boilerplate, improve type safety, and reduce hand-written mapping/validation/enum code across the SimpleModule framework. + +**Architecture:** Four independent workstreams, ordered by dependency — enums first (no deps), then strongly-typed IDs (foundational), then validation, then CRUD reduction. Each workstream adds a NuGet package to `Directory.Packages.props` and/or introduces framework helpers, then modifies affected modules. + +**Tech Stack:** Vogen (strongly-typed IDs), NetEscapades.EnumGenerators (enum utilities), custom framework helpers (validation/CRUD) + +--- + +## Task 1: Add NetEscapades.EnumGenerators for Enum Utilities + +Lowest risk, zero dependencies on other tasks. Replaces `Enum.TryParse` and enables fast `ToString`/`IsDefined` on all enums. + +**Files:** +- Modify: `Directory.Packages.props` (add package version) +- Modify: `framework/SimpleModule.Database/SimpleModule.Database.csproj` (add package reference) +- Modify: `framework/SimpleModule.Database/DatabaseProvider.cs` +- Modify: `framework/SimpleModule.Database/DatabaseProviderDetector.cs` +- Modify: `framework/SimpleModule.Core/SimpleModule.Core.csproj` (add package reference) +- Modify: `framework/SimpleModule.Core/Menu/MenuSection.cs` +- Modify: `cli/SimpleModule.Cli/SimpleModule.Cli.csproj` (add package reference) +- Modify: `cli/SimpleModule.Cli/Commands/Doctor/Checks/IDoctorCheck.cs` +- Test: existing tests in `tests/SimpleModule.Database.Tests/DatabaseProviderDetectorTests.cs` + +**Step 1: Add package version to central package management** + +In `Directory.Packages.props`, add under the `` section: + +```xml + +``` + +**Step 2: Add package reference to Database project** + +In `framework/SimpleModule.Database/SimpleModule.Database.csproj`, add: + +```xml + +``` + +**Step 3: Annotate DatabaseProvider enum** + +```csharp +using NetEscapades.EnumGenerators; + +namespace SimpleModule.Database; + +[EnumExtensions] +public enum DatabaseProvider +{ + Sqlite, + PostgreSql, + SqlServer, +} +``` + +This generates `DatabaseProviderExtensions` with `ToStringFast()`, `IsDefined()`, `TryParse()`, etc. + +**Step 4: Update DatabaseProviderDetector to use generated TryParse** + +Replace in `framework/SimpleModule.Database/DatabaseProviderDetector.cs:11-14`: + +```csharp +// Before: +Enum.TryParse(explicitProvider, ignoreCase: true, out var parsed) + +// After: +DatabaseProviderExtensions.TryParse(explicitProvider, out var parsed, ignoreCase: true) +``` + +**Step 5: Add package reference to Core and CLI projects, annotate remaining enums** + +In `framework/SimpleModule.Core/Menu/MenuSection.cs`: + +```csharp +using NetEscapades.EnumGenerators; + +namespace SimpleModule.Core.Menu; + +[EnumExtensions] +public enum MenuSection +{ + Navbar, + UserDropdown, +} +``` + +In `cli/SimpleModule.Cli/Commands/Doctor/Checks/IDoctorCheck.cs`: + +```csharp +using NetEscapades.EnumGenerators; + +namespace SimpleModule.Cli.Commands.Doctor.Checks; + +[EnumExtensions] +public enum CheckStatus +{ + Pass, + Warning, + Fail, +} +``` + +**Step 6: Run all tests to verify no regressions** + +```bash +dotnet test +``` + +Expected: All tests pass. The generated extensions are drop-in compatible. + +**Step 7: Commit** + +```bash +git add Directory.Packages.props framework/SimpleModule.Database/ framework/SimpleModule.Core/ cli/SimpleModule.Cli/ +git commit -m "feat: add NetEscapades.EnumGenerators for AOT-safe enum utilities" +``` + +--- + +## Task 2: Add Vogen for Strongly-Typed Entity IDs + +Foundational change — introduces `ProductId`, `OrderId`, `UserId` value objects that prevent mixing up raw `int`/`string` IDs across module boundaries. Generates EF Core value converters, JSON converters, and route parameter binding automatically. + +**Files:** +- Modify: `Directory.Packages.props` +- Modify: `framework/SimpleModule.Core/SimpleModule.Core.csproj` +- Create: `framework/SimpleModule.Core/Ids/ProductId.cs` +- Create: `framework/SimpleModule.Core/Ids/OrderId.cs` +- Create: `framework/SimpleModule.Core/Ids/UserId.cs` +- Modify: `modules/Products/src/Products.Contracts/Product.cs` +- Modify: `modules/Products/src/Products.Contracts/IProductContracts.cs` +- Modify: `modules/Products/src/Products.Contracts/Products.Contracts.csproj` +- Modify: `modules/Products/src/Products/ProductService.cs` +- Modify: `modules/Products/src/Products/ProductsDbContext.cs` +- Modify: `modules/Products/src/Products/Endpoints/Products/GetByIdEndpoint.cs` +- Modify: `modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs` +- Modify: `modules/Products/src/Products/Endpoints/Products/DeleteEndpoint.cs` +- Modify: `modules/Orders/src/Orders.Contracts/Order.cs` +- Modify: `modules/Orders/src/Orders.Contracts/OrderItem.cs` +- Modify: `modules/Orders/src/Orders.Contracts/IOrderContracts.cs` +- Modify: `modules/Orders/src/Orders.Contracts/CreateOrderRequest.cs` +- Modify: `modules/Orders/src/Orders.Contracts/UpdateOrderRequest.cs` +- Modify: `modules/Orders/src/Orders/OrderService.cs` +- Modify: `modules/Orders/src/Orders/OrdersDbContext.cs` +- Modify: `modules/Orders/src/Orders/Endpoints/Orders/*` +- Modify: `modules/Users/src/Users.Contracts/UserDto.cs` +- Modify: `modules/Users/src/Users.Contracts/IUserContracts.cs` +- Modify: `modules/Users/src/Users/UserService.cs` +- Modify: `tests/SimpleModule.Tests.Shared/Fakes/FakeDataGenerators.cs` +- Modify: all test files that use raw `int`/`string` IDs +- Test: all existing tests + +### Step 1: Add Vogen package + +In `Directory.Packages.props`, add: + +```xml + +``` + +In `framework/SimpleModule.Core/SimpleModule.Core.csproj`, add: + +```xml + +``` + +### Step 2: Define strongly-typed IDs + +Create `framework/SimpleModule.Core/Ids/ProductId.cs`: + +```csharp +using Vogen; + +namespace SimpleModule.Core.Ids; + +[ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] +public readonly partial struct ProductId; +``` + +Create `framework/SimpleModule.Core/Ids/OrderId.cs`: + +```csharp +using Vogen; + +namespace SimpleModule.Core.Ids; + +[ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] +public readonly partial struct OrderId; +``` + +Create `framework/SimpleModule.Core/Ids/UserId.cs`: + +```csharp +using Vogen; + +namespace SimpleModule.Core.Ids; + +[ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] +public readonly partial struct UserId +{ + private static Validation Validate(string value) => + string.IsNullOrWhiteSpace(value) + ? Validation.Invalid("UserId cannot be empty") + : Validation.Ok; +} +``` + +### Step 3: Update Product contracts + +In `modules/Products/src/Products.Contracts/Product.cs`: + +```csharp +using SimpleModule.Core; +using SimpleModule.Core.Ids; + +namespace SimpleModule.Products.Contracts; + +[Dto] +public class Product +{ + public ProductId Id { get; set; } + public string Name { get; set; } = string.Empty; + public decimal Price { get; set; } +} +``` + +In `modules/Products/src/Products.Contracts/IProductContracts.cs`: + +```csharp +using SimpleModule.Core.Ids; + +namespace SimpleModule.Products.Contracts; + +public interface IProductContracts +{ + Task> GetAllProductsAsync(); + Task GetProductByIdAsync(ProductId id); + Task> GetProductsByIdsAsync(IEnumerable ids); + Task CreateProductAsync(CreateProductRequest request); + Task UpdateProductAsync(ProductId id, UpdateProductRequest request); + Task DeleteProductAsync(ProductId id); +} +``` + +### Step 4: Update Order contracts + +In `modules/Orders/src/Orders.Contracts/Order.cs`: + +```csharp +using SimpleModule.Core; +using SimpleModule.Core.Ids; + +namespace SimpleModule.Orders.Contracts; + +[Dto] +public class Order +{ + public OrderId Id { get; set; } + public UserId UserId { get; set; } + public List Items { get; set; } = new(); + public decimal Total { get; set; } + public DateTime CreatedAt { get; set; } +} +``` + +In `modules/Orders/src/Orders.Contracts/OrderItem.cs`: + +```csharp +using SimpleModule.Core; +using SimpleModule.Core.Ids; + +namespace SimpleModule.Orders.Contracts; + +[Dto] +public class OrderItem +{ + public ProductId ProductId { get; set; } + public int Quantity { get; set; } +} +``` + +In `modules/Orders/src/Orders.Contracts/CreateOrderRequest.cs` and `UpdateOrderRequest.cs`: + +```csharp +using SimpleModule.Core; +using SimpleModule.Core.Ids; + +namespace SimpleModule.Orders.Contracts; + +[Dto] +public class CreateOrderRequest +{ + public UserId UserId { get; set; } + public List Items { get; set; } = new(); +} +``` + +### Step 5: Update User contracts + +In `modules/Users/src/Users.Contracts/UserDto.cs`: + +```csharp +using SimpleModule.Core; +using SimpleModule.Core.Ids; + +namespace SimpleModule.Users.Contracts; + +[Dto] +public class UserDto +{ + public UserId Id { get; set; } + public string Email { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public bool EmailConfirmed { get; set; } + public bool TwoFactorEnabled { get; set; } +} +``` + +In `modules/Users/src/Users.Contracts/IUserContracts.cs`: + +```csharp +using SimpleModule.Core.Ids; + +namespace SimpleModule.Users.Contracts; + +public interface IUserContracts +{ + Task> GetAllUsersAsync(); + Task GetUserByIdAsync(UserId id); + Task GetCurrentUserAsync(UserId userId); + Task CreateUserAsync(CreateUserRequest request); + Task UpdateUserAsync(UserId id, UpdateUserRequest request); + Task DeleteUserAsync(UserId id); +} +``` + +### Step 6: Update service implementations + +Update `ProductService.cs` — change all `int id` parameters to `ProductId id`. The `.Value` property extracts the raw int when needed (e.g., `db.Products.FindAsync(id.Value)`). + +Update `OrderService.cs` — change `int id` to `OrderId id`, `request.UserId` is now `UserId` type. + +Update `UserService.cs` — change `string id` to `UserId id`. When calling `userManager.FindByIdAsync()`, pass `id.Value`. When creating `UserDto`, wrap with `UserId.From(user.Id)`. + +### Step 7: Update EF Core configurations + +Register Vogen EF Core converters in each DbContext's `OnModelCreating` or use the generated `RegisterAllIn*` extension on `ModelConfigurationBuilder`. The exact approach depends on Vogen's generated code — check the generated `ConfigureConventions` extension method. + +### Step 8: Update endpoints + +All endpoint route parameters change from `int id` to `ProductId id` / `OrderId id` / `UserId id`. Vogen generates the route parameter binding automatically via `IParsable`. + +### Step 9: Update test fakers + +In `tests/SimpleModule.Tests.Shared/Fakes/FakeDataGenerators.cs`: + +```csharp +// Before: +.RuleFor(p => p.Id, f => f.IndexFaker + 1) +// After: +.RuleFor(p => p.Id, f => ProductId.From(f.IndexFaker + 1)) + +// Before: +.RuleFor(o => o.UserId, f => f.Random.Int(1, 100).ToString(CultureInfo.InvariantCulture)) +// After: +.RuleFor(o => o.UserId, f => UserId.From(f.Random.Int(1, 100).ToString(CultureInfo.InvariantCulture))) +``` + +### Step 10: Update all tests with raw ID usage + +Update test files that construct entities or pass IDs directly. For example in `ProductServiceTests.cs`: + +```csharp +// Before: +var product = await _sut.GetProductByIdAsync(1); +// After: +var product = await _sut.GetProductByIdAsync(ProductId.From(1)); +``` + +### Step 11: Run all tests + +```bash +dotnet test +``` + +Fix any remaining compilation errors — the compiler will catch every place a raw primitive is used where a strongly-typed ID is expected. + +### Step 12: Commit + +```bash +git add -A +git commit -m "feat: add Vogen strongly-typed IDs (ProductId, OrderId, UserId) for compile-time safety" +``` + +--- + +## Task 3: Improve Validation with Source-Generated Pattern + +The existing validation pattern (manual `Dictionary` accumulation) works but is verbose. Rather than adding a third-party validation package (which may conflict with the existing `ValidationResult`/`ValidationException` pattern), we improve the existing pattern by: +1. Adding a fluent `ValidationBuilder` helper to Core +2. Adding validation to Products module (currently has none) +3. Making the Orders validation cleaner + +This is a code quality improvement, not a package integration — keeping the validation approach consistent with the project's "no external dependencies unless source-generated" philosophy. + +**Files:** +- Create: `framework/SimpleModule.Core/Validation/ValidationBuilder.cs` +- Modify: `modules/Orders/src/Orders/Endpoints/Orders/CreateRequestValidator.cs` +- Modify: `modules/Orders/src/Orders/OrdersConstants.cs` +- Create: `modules/Products/src/Products/Endpoints/Products/CreateRequestValidator.cs` +- Create: `modules/Products/src/Products/ProductsConstants.cs` (add validation messages if not present) +- Modify: `modules/Products/src/Products/Endpoints/Products/CreateEndpoint.cs` +- Modify: `modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs` +- Create: `modules/Products/tests/Products.Tests/Unit/CreateRequestValidatorTests.cs` +- Test: `modules/Orders/tests/Orders.Tests/Unit/CreateOrderRequestValidatorTests.cs` + +### Step 1: Create ValidationBuilder helper + +Create `framework/SimpleModule.Core/Validation/ValidationBuilder.cs`: + +```csharp +namespace SimpleModule.Core.Validation; + +public sealed class ValidationBuilder +{ + private readonly Dictionary> _errors = []; + + public ValidationBuilder AddErrorIf(bool condition, string field, string message) + { + if (condition) + { + if (!_errors.TryGetValue(field, out var list)) + { + list = []; + _errors[field] = list; + } + + list.Add(message); + } + + return this; + } + + public ValidationResult Build() + { + if (_errors.Count == 0) + { + return ValidationResult.Success; + } + + var errors = _errors.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToArray()); + return ValidationResult.WithErrors(errors); + } +} +``` + +### Step 2: Write test for ValidationBuilder + +Create `tests/SimpleModule.Core.Tests/Validation/ValidationBuilderTests.cs`: + +```csharp +using FluentAssertions; +using SimpleModule.Core.Validation; + +namespace SimpleModule.Core.Tests.Validation; + +public class ValidationBuilderTests +{ + [Fact] + public void Build_WithNoErrors_ReturnsSuccess() + { + var result = new ValidationBuilder().Build(); + + result.IsValid.Should().BeTrue(); + result.Errors.Should().BeEmpty(); + } + + [Fact] + public void AddErrorIf_WhenConditionTrue_AddsError() + { + var result = new ValidationBuilder() + .AddErrorIf(true, "Name", "Name is required.") + .Build(); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainKey("Name"); + result.Errors["Name"].Should().Contain("Name is required."); + } + + [Fact] + public void AddErrorIf_WhenConditionFalse_DoesNotAddError() + { + var result = new ValidationBuilder() + .AddErrorIf(false, "Name", "Name is required.") + .Build(); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void AddErrorIf_MultipleSameField_AccumulatesErrors() + { + var result = new ValidationBuilder() + .AddErrorIf(true, "Name", "Name is required.") + .AddErrorIf(true, "Name", "Name must be at least 2 characters.") + .Build(); + + result.Errors["Name"].Should().HaveCount(2); + } +} +``` + +### Step 3: Run test to verify it fails, then implement, then run again + +```bash +dotnet test --filter "FullyQualifiedName~ValidationBuilderTests" +``` + +### Step 4: Simplify Orders CreateRequestValidator + +```csharp +using System.Globalization; +using System.Text; +using SimpleModule.Core.Validation; +using SimpleModule.Orders.Contracts; + +namespace SimpleModule.Orders.Endpoints.Orders; + +public static class CreateRequestValidator +{ + private static readonly CompositeFormat QuantityMustBePositiveFormat = CompositeFormat.Parse( + OrdersConstants.ValidationMessages.QuantityMustBePositiveFormat + ); + + public static ValidationResult Validate(CreateOrderRequest request) + { + var builder = new ValidationBuilder() + .AddErrorIf( + string.IsNullOrWhiteSpace(request.UserId.Value), + OrdersConstants.Fields.UserId, + OrdersConstants.ValidationMessages.UserIdRequired + ) + .AddErrorIf( + request.Items is null || request.Items.Count == 0, + OrdersConstants.Fields.Items, + OrdersConstants.ValidationMessages.AtLeastOneItemRequired + ); + + if (request.Items is { Count: > 0 }) + { + for (var i = 0; i < request.Items.Count; i++) + { + builder.AddErrorIf( + request.Items[i].Quantity <= 0, + OrdersConstants.Fields.Items, + string.Format(CultureInfo.InvariantCulture, QuantityMustBePositiveFormat, i) + ); + } + } + + return builder.Build(); + } +} +``` + +### Step 5: Add Products validation + +Create `modules/Products/src/Products/Endpoints/Products/CreateRequestValidator.cs`: + +```csharp +using SimpleModule.Core.Validation; +using SimpleModule.Products.Contracts; + +namespace SimpleModule.Products.Endpoints.Products; + +public static class CreateRequestValidator +{ + public static ValidationResult Validate(CreateProductRequest request) => + new ValidationBuilder() + .AddErrorIf( + string.IsNullOrWhiteSpace(request.Name), + "Name", + "Product name is required." + ) + .AddErrorIf( + request.Price <= 0, + "Price", + "Price must be greater than zero." + ) + .Build(); +} +``` + +### Step 6: Wire validation into Products endpoints + +In `modules/Products/src/Products/Endpoints/Products/CreateEndpoint.cs`, add validation before calling service: + +```csharp +var validation = CreateRequestValidator.Validate(request); +if (!validation.IsValid) +{ + throw new ValidationException(validation.Errors); +} +``` + +Same for `UpdateEndpoint.cs` (validate the `UpdateProductRequest`). + +### Step 7: Write tests for Products validator + +Create `modules/Products/tests/Products.Tests/Unit/CreateRequestValidatorTests.cs`: + +```csharp +using FluentAssertions; +using SimpleModule.Products.Contracts; +using SimpleModule.Products.Endpoints.Products; + +namespace Products.Tests.Unit; + +public class CreateRequestValidatorTests +{ + [Fact] + public void Validate_WithValidRequest_ReturnsSuccess() + { + var request = new CreateProductRequest { Name = "Widget", Price = 9.99m }; + + var result = CreateRequestValidator.Validate(request); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void Validate_WithEmptyName_ReturnsError() + { + var request = new CreateProductRequest { Name = "", Price = 9.99m }; + + var result = CreateRequestValidator.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainKey("Name"); + } + + [Fact] + public void Validate_WithZeroPrice_ReturnsError() + { + var request = new CreateProductRequest { Name = "Widget", Price = 0 }; + + var result = CreateRequestValidator.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainKey("Price"); + } +} +``` + +### Step 8: Run all tests + +```bash +dotnet test +``` + +### Step 9: Commit + +```bash +git add framework/SimpleModule.Core/Validation/ modules/Products/ modules/Orders/ tests/ +git commit -m "feat: add ValidationBuilder helper, add Products validation, simplify Orders validation" +``` + +--- + +## Task 4: Reduce CRUD Endpoint Boilerplate via Shared Base Patterns + +Rather than adding a package, this task creates reusable base patterns that reduce the repetitive CRUD endpoint code. Every module currently has nearly identical GetAll/GetById/Create/Update/Delete endpoints. We introduce generic endpoint helper methods in Core that modules can opt into. + +**Important design decision:** We keep the `IEndpoint` interface — each module still has its own endpoint classes. But we extract the common handler logic into static helper methods that reduce the lambda boilerplate. + +**Files:** +- Create: `framework/SimpleModule.Core/Endpoints/CrudEndpoints.cs` +- Modify: `modules/Products/src/Products/Endpoints/Products/GetAllEndpoint.cs` +- Modify: `modules/Products/src/Products/Endpoints/Products/GetByIdEndpoint.cs` +- Modify: `modules/Products/src/Products/Endpoints/Products/CreateEndpoint.cs` +- Modify: `modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs` +- Modify: `modules/Products/src/Products/Endpoints/Products/DeleteEndpoint.cs` +- Test: existing integration tests in `modules/Products/tests/Products.Tests/Integration/` + +### Step 1: Create CrudEndpoints helper + +Create `framework/SimpleModule.Core/Endpoints/CrudEndpoints.cs`: + +```csharp +using Microsoft.AspNetCore.Http; + +namespace SimpleModule.Core.Endpoints; + +public static class CrudEndpoints +{ + public static async Task GetAll(Func>> getAll) => + TypedResults.Ok(await getAll()); + + public static async Task GetById(Func> getById) where T : class + { + var entity = await getById(); + return entity is not null ? TypedResults.Ok(entity) : TypedResults.NotFound(); + } + + public static async Task Create( + Func> create, + Func locationFactory + ) + { + var entity = await create(); + return TypedResults.Created(locationFactory(entity), entity); + } + + public static async Task Update(Func> update) where T : class => + TypedResults.Ok(await update()); + + public static async Task Delete(Func delete) + { + await delete(); + return TypedResults.NoContent(); + } +} +``` + +### Step 2: Simplify Products endpoints + +In `modules/Products/src/Products/Endpoints/Products/GetAllEndpoint.cs`: + +```csharp +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Endpoints; +using SimpleModule.Products.Contracts; + +namespace SimpleModule.Products.Endpoints.Products; + +public class GetAllEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapGet("/", (IProductContracts products) => + CrudEndpoints.GetAll(products.GetAllProductsAsync)); +} +``` + +In `GetByIdEndpoint.cs`: + +```csharp +public class GetByIdEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapGet("/{id}", (ProductId id, IProductContracts products) => + CrudEndpoints.GetById(() => products.GetProductByIdAsync(id))); +} +``` + +In `CreateEndpoint.cs`: + +```csharp +public class CreateEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapPost("/", (CreateProductRequest request, IProductContracts products) => + { + var validation = CreateRequestValidator.Validate(request); + if (!validation.IsValid) + throw new ValidationException(validation.Errors); + + return CrudEndpoints.Create( + () => products.CreateProductAsync(request), + p => $"{ProductsConstants.RoutePrefix}/{p.Id}"); + }); +} +``` + +In `UpdateEndpoint.cs`: + +```csharp +public class UpdateEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapPut("/{id}", (ProductId id, UpdateProductRequest request, IProductContracts products) => + CrudEndpoints.Update(() => products.UpdateProductAsync(id, request))); +} +``` + +In `DeleteEndpoint.cs`: + +```csharp +public class DeleteEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapDelete("/{id}", (ProductId id, IProductContracts products) => + CrudEndpoints.Delete(() => products.DeleteProductAsync(id))); +} +``` + +### Step 3: Run integration tests + +```bash +dotnet test --filter "FullyQualifiedName~Products.Tests.Integration" +``` + +### Step 4: Apply same pattern to Orders endpoints + +Repeat the same simplification for Orders endpoints. The Orders `CreateEndpoint` and `UpdateEndpoint` keep their validation logic before calling `CrudEndpoints.Create`/`CrudEndpoints.Update`. + +### Step 5: Run all tests + +```bash +dotnet test +``` + +### Step 6: Commit + +```bash +git add framework/SimpleModule.Core/Endpoints/ modules/Products/ modules/Orders/ +git commit -m "feat: add CrudEndpoints helper to reduce endpoint boilerplate" +``` + +--- + +## Execution Order Summary + +| Task | Depends On | Risk | Impact | +|------|-----------|------|--------| +| 1. EnumGenerators | None | Low | Low — perf improvement | +| 2. Vogen IDs | None | **High** — touches all modules | **High** — compile-time safety | +| 3. Validation | Task 2 (UserId type) | Low | Medium — consistency | +| 4. CRUD helpers | Task 2 | Low | Medium — less boilerplate | + +Task 1 can run in parallel with Task 2. Tasks 3-4 should run sequentially after Task 2. + +## Rollback Strategy + +Each task is an independent commit. If any task causes issues: +1. `git revert ` for that specific task +2. Tasks 3-4 can be reverted independently of Task 2 (they build on it but don't break the IDs if removed) +3. Task 2 (Vogen) is the riskiest — if reverted, Tasks 3-4 that reference the ID types would also need adjustment diff --git a/docs/plans/2026-03-18-split-generator-design.md b/docs/plans/2026-03-18-split-generator-design.md new file mode 100644 index 00000000..52a0cea7 --- /dev/null +++ b/docs/plans/2026-03-18-split-generator-design.md @@ -0,0 +1,56 @@ +# Split Generator into Focused Emitters + +**Date:** 2026-03-18 +**Status:** Approved + +## Problem + +The `ModuleDiscovererGenerator` is a single monolithic class (split across 3 partial files) that handles all discovery and all 8 code generation outputs. This makes the codebase harder to navigate and maintain. + +## Decision + +**Approach B: Single discovery generator dispatching to separate emitter classes.** + +Keep one `[Generator]` with one `CompilationProvider` pipeline (single discovery pass). Factor each output into its own emitter class implementing a shared `IEmitter` interface. The generator's `RegisterSourceOutput` callback iterates the emitter list. + +This preserves the single-pass discovery performance while distributing complexity across focused, independently testable emitter classes. + +## Structure + +``` +SimpleModule.Generator/ +├── ModuleDiscoveryGenerator.cs # [Generator] — discovery pipeline + dispatches to emitters +├── Discovery/ +│ ├── DiscoveryData.cs # DiscoveryData record + all model types +│ └── SymbolDiscovery.cs # ExtractDiscoveryData + all Find* methods (static) +├── Emitters/ +│ ├── IEmitter.cs # interface: void Emit(SourceProductionContext, DiscoveryData) +│ ├── DiagnosticEmitter.cs # ReportDiscoveryDiagnostics + diagnostic descriptors +│ ├── ModuleExtensionsEmitter.cs # ModuleExtensions.g.cs +│ ├── EndpointExtensionsEmitter.cs # EndpointExtensions.g.cs +│ ├── MenuExtensionsEmitter.cs # MenuExtensions.g.cs +│ ├── RazorComponentExtensionsEmitter.cs # RazorComponentExtensions.g.cs +│ ├── ViewPagesEmitter.cs # ViewPages_{ModuleName}.g.cs (per module) +│ ├── JsonResolverEmitter.cs # ModulesJsonResolver.g.cs +│ ├── TypeScriptDefinitionsEmitter.cs # DtoTypeScript_{ModuleName}.g.cs (per module) +│ └── HostDbContextEmitter.cs # HostDbContext.g.cs +├── Helpers/ +│ └── TypeMappingHelpers.cs # MapCSharpTypeToTypeScript, GetModuleFieldName, shared utils +└── IsExternalInit.cs # polyfill (unchanged) +``` + +## Key Decisions + +- **`IEmitter` interface**: `void Emit(SourceProductionContext spc, DiscoveryData data)`. Each emitter implements this. +- **`SymbolDiscovery`**: Static class with pure function `Extract(Compilation) → DiscoveryData`. Contains all `Find*` methods. +- **`DiagnosticEmitter` runs first**: Reports diagnostics. `HostDbContextEmitter` checks `DiscoveryData` for error conditions itself (same as current behavior). +- **Shared helpers**: `GetModuleFieldName` and `MapCSharpTypeToTypeScript` move to `TypeMappingHelpers` since multiple emitters use them. +- **Module singleton fields**: Emitted by `ModuleExtensionsEmitter`; other emitters reference them via `ModuleExtensions.s_{name}`. + +## What Doesn't Change + +- Single `[Generator]` attribute on one class +- Incremental pipeline structure (single `CompilationProvider` → single `RegisterSourceOutput`) +- All generated output filenames and content (byte-identical) +- All existing tests pass without modification +- `DiscoveryData` equatable caching behavior diff --git a/docs/plans/2026-03-18-split-generator-plan.md b/docs/plans/2026-03-18-split-generator-plan.md new file mode 100644 index 00000000..dee1dd67 --- /dev/null +++ b/docs/plans/2026-03-18-split-generator-plan.md @@ -0,0 +1,587 @@ +# Split Generator into Focused Emitters — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Refactor the monolithic `ModuleDiscovererGenerator` into a shared discovery layer + focused emitter classes, keeping generated output byte-identical. + +**Architecture:** Single `[Generator]` entry point calls `SymbolDiscovery.Extract(compilation)` for shared discovery, then iterates an `IEmitter[]` array to dispatch to focused emitter classes. All model types move to `Discovery/`, all emitters to `Emitters/`, shared helpers to `Helpers/`. + +**Tech Stack:** C# / Roslyn `IIncrementalGenerator` / netstandard2.0 + +--- + +### Task 1: Run existing tests (green baseline) + +**Files:** None (verification only) + +**Step 1: Run all generator tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 2: Commit** — no commit needed, baseline only + +--- + +### Task 2: Extract model types to `Discovery/DiscoveryData.cs` + +Move all record types and mutable working types from `ModuleDiscovererGenerator.Models.cs` into a new standalone file. Change visibility from `private` (nested in partial class) to `internal` (top-level in namespace). + +**Files:** +- Create: `framework/SimpleModule.Generator/Discovery/DiscoveryData.cs` +- Delete: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.Models.cs` + +**Step 1: Create `Discovery/DiscoveryData.cs`** + +Copy all types from `ModuleDiscovererGenerator.Models.cs` into new file. Key changes: +- Remove the `partial class ModuleDiscovererGenerator` wrapper +- Change all `private` access modifiers to `internal` +- Add `namespace SimpleModule.Generator;` (file-scoped) +- Keep all `readonly record struct` types and mutable `sealed class` types + +```csharp +// Discovery/DiscoveryData.cs +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace SimpleModule.Generator; + +#region Equatable data model for incremental caching + +// These record types implement value equality so the incremental generator +// pipeline can detect when the extracted data hasn't changed and skip +// re-generating source files. + +internal readonly record struct DiscoveryData( + ImmutableArray Modules, + ImmutableArray DtoTypes, + ImmutableArray DbContexts, + ImmutableArray EntityConfigs +) +{ + public static readonly DiscoveryData Empty = new( + ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty + ); + + public bool Equals(DiscoveryData other) + { + return Modules.SequenceEqual(other.Modules) + && DtoTypes.SequenceEqual(other.DtoTypes) + && DbContexts.SequenceEqual(other.DbContexts) + && EntityConfigs.SequenceEqual(other.EntityConfigs); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + foreach (var m in Modules) + hash = hash * 31 + m.GetHashCode(); + foreach (var d in DtoTypes) + hash = hash * 31 + d.GetHashCode(); + foreach (var c in DbContexts) + hash = hash * 31 + c.GetHashCode(); + foreach (var e in EntityConfigs) + hash = hash * 31 + e.GetHashCode(); + return hash; + } + } +} + +// ... all other record structs and mutable classes with `internal` visibility +#endregion +``` + +**Step 2: Delete `ModuleDiscovererGenerator.Models.cs`** + +**Step 3: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 4: Commit** + +```bash +git add -A framework/SimpleModule.Generator/Discovery/ framework/SimpleModule.Generator/ModuleDiscovererGenerator.Models.cs +git commit -m "refactor: extract generator model types to Discovery/DiscoveryData.cs" +``` + +--- + +### Task 3: Extract shared helpers to `Helpers/TypeMappingHelpers.cs` + +Move `GetModuleFieldName`, `MapCSharpTypeToTypeScript`, and `GetModuleNameFromFqn` out of the emitters partial class into a standalone static helper class. + +**Files:** +- Create: `framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs` +- Modify: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs` — remove the three methods + +**Step 1: Create `Helpers/TypeMappingHelpers.cs`** + +```csharp +using System; +using System.Collections.Generic; + +namespace SimpleModule.Generator; + +internal static class TypeMappingHelpers +{ + internal static string GetModuleFieldName(string fullyQualifiedName) + { + var name = fullyQualifiedName.Replace("global::", "").Replace(".", "_"); + return $"s_{name}"; + } + + internal static string GetModuleNameFromFqn(string fqn) + { + var name = fqn.Replace("global::", ""); + var parts = name.Split('.'); + return parts.Length >= 3 ? parts[1] : parts[0]; + } + + internal static string MapCSharpTypeToTypeScript( + string typeFqn, + Dictionary? knownDtoTypes = null + ) + { + // ... exact copy of existing method body + } +} +``` + +**Step 2: Update all call sites** in `ModuleDiscovererGenerator.Emitters.cs` to use `TypeMappingHelpers.GetModuleFieldName(...)`, `TypeMappingHelpers.MapCSharpTypeToTypeScript(...)`, `TypeMappingHelpers.GetModuleNameFromFqn(...)`. Remove the three methods from the partial class. + +**Step 3: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 4: Commit** + +```bash +git add framework/SimpleModule.Generator/Helpers/ framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +git commit -m "refactor: extract shared helpers to TypeMappingHelpers" +``` + +--- + +### Task 4: Extract symbol discovery to `Discovery/SymbolDiscovery.cs` + +Move `ExtractDiscoveryData` and all `Find*` methods + helper methods (`ImplementsInterface`, `DeclaresMethod`, `InheritsFrom`, `HasComponentBaseDescendant`, `FindClosestModuleName`) from `ModuleDiscovererGenerator.cs` into a standalone static class. + +**Files:** +- Create: `framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs` +- Modify: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs` — remove all methods except `Initialize`, update call to `SymbolDiscovery.Extract` + +**Step 1: Create `Discovery/SymbolDiscovery.cs`** + +```csharp +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +internal static class SymbolDiscovery +{ + internal static DiscoveryData Extract(Compilation compilation) + { + // ... exact copy of ExtractDiscoveryData body + } + + // ... all Find* methods, ImplementsInterface, DeclaresMethod, + // InheritsFrom, HasComponentBaseDescendant, FindClosestModuleName + // all as `internal static` or `private static` +} +``` + +**Step 2: Update `ModuleDiscovererGenerator.Initialize`** + +Change: `static (compilation, _) => ExtractDiscoveryData(compilation)` +To: `static (compilation, _) => SymbolDiscovery.Extract(compilation)` + +**Step 3: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 4: Commit** + +```bash +git add framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs +git commit -m "refactor: extract symbol discovery to SymbolDiscovery static class" +``` + +--- + +### Task 5: Create `IEmitter` interface + +**Files:** +- Create: `framework/SimpleModule.Generator/Emitters/IEmitter.cs` + +**Step 1: Create the interface** + +```csharp +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +internal interface IEmitter +{ + void Emit(SourceProductionContext context, DiscoveryData data); +} +``` + +**Step 2: Commit** + +```bash +git add framework/SimpleModule.Generator/Emitters/IEmitter.cs +git commit -m "refactor: add IEmitter interface for generator dispatch" +``` + +--- + +### Task 6: Extract `DiagnosticEmitter` + +**Files:** +- Create: `framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs` +- Modify: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs` — remove `ReportDiscoveryDiagnostics` and all 7 `DiagnosticDescriptor` fields + +**Step 1: Create `Emitters/DiagnosticEmitter.cs`** + +Move `ReportDiscoveryDiagnostics` as the `Emit` method. Move all 7 `DiagnosticDescriptor` static fields. Note: `DuplicateDbSetPropertyName` (SM0001) is used by `HostDbContextEmitter` — make it `internal static` so `HostDbContextEmitter` can reference it. + +```csharp +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +internal sealed class DiagnosticEmitter : IEmitter +{ + internal static readonly DiagnosticDescriptor DuplicateDbSetPropertyName = new(...); + private static readonly DiagnosticDescriptor EmptyModuleName = new(...); + // ... other descriptors + + public void Emit(SourceProductionContext context, DiscoveryData data) + { + // ... exact copy of ReportDiscoveryDiagnostics body + } +} +``` + +**Step 2: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +git commit -m "refactor: extract DiagnosticEmitter" +``` + +--- + +### Task 7: Extract `ModuleExtensionsEmitter` + +**Files:** +- Create: `framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs` +- Modify: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs` — remove `GenerateModuleExtensions` + +**Step 1: Create the emitter** + +```csharp +internal sealed class ModuleExtensionsEmitter : IEmitter +{ + public void Emit(SourceProductionContext context, DiscoveryData data) + { + // Call existing logic with data.Modules, data.DtoTypes.Length > 0 + // Use TypeMappingHelpers.GetModuleFieldName(...) + } +} +``` + +**Step 2: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +git commit -m "refactor: extract ModuleExtensionsEmitter" +``` + +--- + +### Task 8: Extract `EndpointExtensionsEmitter` + +**Files:** +- Create: `framework/SimpleModule.Generator/Emitters/EndpointExtensionsEmitter.cs` +- Modify: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs` — remove `GenerateEndpointExtensions` + +**Step 1: Create the emitter** — move `GenerateEndpointExtensions` logic into `Emit`. Uses `TypeMappingHelpers.GetModuleFieldName`. + +**Step 2: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Generator/Emitters/EndpointExtensionsEmitter.cs framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +git commit -m "refactor: extract EndpointExtensionsEmitter" +``` + +--- + +### Task 9: Extract `MenuExtensionsEmitter` + +**Files:** +- Create: `framework/SimpleModule.Generator/Emitters/MenuExtensionsEmitter.cs` +- Modify: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs` — remove `GenerateMenuExtensions` + +**Step 1: Create the emitter** + +**Step 2: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Generator/Emitters/MenuExtensionsEmitter.cs framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +git commit -m "refactor: extract MenuExtensionsEmitter" +``` + +--- + +### Task 10: Extract `RazorComponentExtensionsEmitter` + +**Files:** +- Create: `framework/SimpleModule.Generator/Emitters/RazorComponentExtensionsEmitter.cs` +- Modify: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs` — remove `GenerateRazorComponentExtensions` + +**Step 1: Create the emitter** + +**Step 2: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Generator/Emitters/RazorComponentExtensionsEmitter.cs framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +git commit -m "refactor: extract RazorComponentExtensionsEmitter" +``` + +--- + +### Task 11: Extract `ViewPagesEmitter` + +**Files:** +- Create: `framework/SimpleModule.Generator/Emitters/ViewPagesEmitter.cs` +- Modify: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs` — remove `GenerateViewPages` + +**Step 1: Create the emitter** + +**Step 2: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Generator/Emitters/ViewPagesEmitter.cs framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +git commit -m "refactor: extract ViewPagesEmitter" +``` + +--- + +### Task 12: Extract `JsonResolverEmitter` + +**Files:** +- Create: `framework/SimpleModule.Generator/Emitters/JsonResolverEmitter.cs` +- Modify: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs` — remove `GenerateJsonResolver` + +**Step 1: Create the emitter** — conditionally emits only when `data.DtoTypes.Length > 0` + +**Step 2: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Generator/Emitters/JsonResolverEmitter.cs framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +git commit -m "refactor: extract JsonResolverEmitter" +``` + +--- + +### Task 13: Extract `TypeScriptDefinitionsEmitter` + +**Files:** +- Create: `framework/SimpleModule.Generator/Emitters/TypeScriptDefinitionsEmitter.cs` +- Modify: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs` — remove `GenerateTypeScriptDefinitions` + +**Step 1: Create the emitter** — conditionally emits only when `data.DtoTypes.Length > 0`. Uses `TypeMappingHelpers.MapCSharpTypeToTypeScript` and `TypeMappingHelpers.GetModuleNameFromFqn`. + +**Step 2: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Generator/Emitters/TypeScriptDefinitionsEmitter.cs framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +git commit -m "refactor: extract TypeScriptDefinitionsEmitter" +``` + +--- + +### Task 14: Extract `HostDbContextEmitter` + +**Files:** +- Create: `framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs` +- Modify: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs` — remove `EmitHostDbContext` (this should now be the last method in the file) + +**Step 1: Create the emitter** — conditionally emits only when `data.DbContexts.Length > 0`. References `DiagnosticEmitter.DuplicateDbSetPropertyName` for SM0001. Keep `#pragma warning disable CA1308`. + +**Step 2: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +git commit -m "refactor: extract HostDbContextEmitter" +``` + +--- + +### Task 15: Delete the old emitters partial file and wire up `ModuleDiscoveryGenerator.cs` + +At this point `ModuleDiscovererGenerator.Emitters.cs` should be empty (no methods left). Delete it and update the main generator to use the new emitter array. + +**Files:** +- Delete: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs` +- Modify: `framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs` — replace inline dispatch with emitter array + +**Step 1: Update `ModuleDiscovererGenerator.cs`** + +The class should no longer be `partial`. Replace the `RegisterSourceOutput` callback: + +```csharp +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +[Generator] +public class ModuleDiscovererGenerator : IIncrementalGenerator +{ + private static readonly IEmitter[] Emitters = + [ + new DiagnosticEmitter(), + new ModuleExtensionsEmitter(), + new EndpointExtensionsEmitter(), + new MenuExtensionsEmitter(), + new RazorComponentExtensionsEmitter(), + new ViewPagesEmitter(), + new JsonResolverEmitter(), + new TypeScriptDefinitionsEmitter(), + new HostDbContextEmitter(), + ]; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var dataProvider = context.CompilationProvider.Select( + static (compilation, _) => SymbolDiscovery.Extract(compilation) + ); + + context.RegisterSourceOutput( + dataProvider, + static (spc, data) => + { + if (data.Modules.Length == 0) + return; + + foreach (var emitter in Emitters) + { + emitter.Emit(spc, data); + } + } + ); + } +} +``` + +**Step 2: Delete `ModuleDiscovererGenerator.Emitters.cs`** + +**Step 3: Run tests** + +Run: `dotnet test tests/SimpleModule.Generator.Tests --no-restore -v quiet` +Expected: All tests pass + +**Step 4: Commit** + +```bash +git add framework/SimpleModule.Generator/ +git commit -m "refactor: wire up emitter dispatch and delete old partial files" +``` + +--- + +### Task 16: Full build and test verification + +**Step 1: Clean build** + +Run: `dotnet build` +Expected: Build succeeds with no errors + +**Step 2: Run all tests (not just generator tests)** + +Run: `dotnet test` +Expected: All tests pass + +**Step 3: Verify final file structure** + +``` +framework/SimpleModule.Generator/ +├── ModuleDiscovererGenerator.cs +├── Discovery/ +│ ├── DiscoveryData.cs +│ └── SymbolDiscovery.cs +├── Emitters/ +│ ├── IEmitter.cs +│ ├── DiagnosticEmitter.cs +│ ├── ModuleExtensionsEmitter.cs +│ ├── EndpointExtensionsEmitter.cs +│ ├── MenuExtensionsEmitter.cs +│ ├── RazorComponentExtensionsEmitter.cs +│ ├── ViewPagesEmitter.cs +│ ├── JsonResolverEmitter.cs +│ ├── TypeScriptDefinitionsEmitter.cs +│ └── HostDbContextEmitter.cs +├── Helpers/ +│ └── TypeMappingHelpers.cs +└── IsExternalInit.cs +``` + +**Step 4: Commit** — no additional commit needed if all prior commits are clean diff --git a/docs/plans/2026-03-19-admin-sidebar-layout-design.md b/docs/plans/2026-03-19-admin-sidebar-layout-design.md new file mode 100644 index 00000000..924950fd --- /dev/null +++ b/docs/plans/2026-03-19-admin-sidebar-layout-design.md @@ -0,0 +1,59 @@ +# Admin Sidebar Layout Design + +## Overview + +Admin pages (`/admin/*`) get a dedicated sidebar layout rendered in React. The rest of the app keeps the current top navbar layout unchanged. + +## Layout Structure + +``` +┌─────────────────────────────────────────────┐ +│ Logo [Back to app] [User]│ +├────────┬────────────────────────────────────┤ +│ │ │ +│ Sidebar│ Content Area │ +│ │ │ +│ Users │ (existing admin pages render │ +│ Roles │ here unchanged) │ +│ Clients│ │ +│ │ │ +│ │ │ +│[toggle]│ │ +└────────┴────────────────────────────────────┘ +``` + +## Menu System Integration + +- Add `AdminSidebar` to `MenuSection` enum. +- Modules register admin sidebar items in `ConfigureMenu` (e.g., Admin module registers Users, Roles; OpenIddict module registers Clients). +- Server shares admin sidebar menu items as Inertia shared props via middleware. +- `AdminLayout.tsx` reads these props and renders the sidebar dynamically. + +## Sidebar + +- Uses existing `Sidebar` component from `@simplemodule/ui`. +- Collapsible: `w-64` expanded (icons + labels), `w-16` collapsed (icons only). +- Toggle button at the bottom. +- Active state highlighting based on current URL. +- On mobile: hidden by default, toggled via hamburger. + +## Simplified Top Bar + +- Shown only on admin pages. +- Contains: logo (links to app home), "Back to app" link, user dropdown. +- Blazor `MainLayout.razor` still renders server-side; React `AdminLayout` handles its own chrome within the Inertia content area. + +## Implementation Approach + +- **`MenuSection.AdminSidebar`** — New enum value for admin sidebar items. +- **Module registration** — Each module adds items to `AdminSidebar` section in `ConfigureMenu`. +- **Inertia shared props** — Middleware resolves `IMenuRegistry` and shares admin sidebar items as props for authenticated admin users. +- **`AdminLayout.tsx`** — React wrapper rendering sidebar + top bar + content slot. +- **Admin pages** — Use Inertia persistent layouts to wrap with `AdminLayout`. +- **No Blazor layout changes** — Blazor SSR shell unchanged. + +## Styling + +- Follows existing theme (teal primary, glass-card effects, dark mode support). +- Sidebar background uses surface colors from the theme. +- Smooth transition animation on collapse/expand. diff --git a/docs/plans/2026-03-19-admin-sidebar-layout-plan.md b/docs/plans/2026-03-19-admin-sidebar-layout-plan.md new file mode 100644 index 00000000..706e914a --- /dev/null +++ b/docs/plans/2026-03-19-admin-sidebar-layout-plan.md @@ -0,0 +1,645 @@ +# Admin Sidebar Layout Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace the top navbar layout for admin pages with a dedicated sidebar layout, using React and the existing `@simplemodule/ui` Sidebar component. Menu items come from the `IMenuRegistry` system via a new `AdminSidebar` menu section, shared to React as Inertia props. + +**Architecture:** Add `AdminSidebar` to `MenuSection` enum. Modules register admin sidebar items via `ConfigureMenu`. A new Inertia middleware injects admin sidebar menu items as shared props. An `AdminLayout.tsx` React component wraps all admin pages with a collapsible sidebar and simplified top bar. + +**Tech Stack:** C# (MenuSection enum, Inertia middleware), React 19, Inertia.js, `@simplemodule/ui` Sidebar components, Tailwind CSS. + +--- + +### Task 1: Add `AdminSidebar` to `MenuSection` enum + +**Files:** +- Modify: `framework/SimpleModule.Core/Menu/MenuSection.cs` +- Modify: `tests/SimpleModule.Core.Tests/Menu/MenuRegistryTests.cs` +- Modify: `tests/SimpleModule.Core.Tests/Menu/MenuItemTests.cs` + +**Step 1: Write the failing test** + +Add a test in `MenuRegistryTests.cs` that uses `MenuSection.AdminSidebar`: + +```csharp +[Fact] +public void GetItems_AdminSidebar_ReturnsCorrectItems() +{ + var items = new List + { + new() + { + Label = "Users", + Url = "/admin/users", + Section = MenuSection.AdminSidebar, + }, + new() + { + Label = "Nav", + Url = "/nav", + Section = MenuSection.Navbar, + }, + }; + var registry = new MenuRegistry(items); + + var sidebarItems = registry.GetItems(MenuSection.AdminSidebar); + + sidebarItems.Should().ContainSingle().Which.Label.Should().Be("Users"); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test tests/SimpleModule.Core.Tests --filter "FullyQualifiedName~GetItems_AdminSidebar"` +Expected: FAIL — `MenuSection` does not contain `AdminSidebar`. + +**Step 3: Add `AdminSidebar` to the enum** + +In `framework/SimpleModule.Core/Menu/MenuSection.cs`: + +```csharp +[EnumExtensions] +public enum MenuSection +{ + Navbar, + UserDropdown, + AdminSidebar, +} +``` + +**Step 4: Run test to verify it passes** + +Run: `dotnet test tests/SimpleModule.Core.Tests --filter "FullyQualifiedName~GetItems_AdminSidebar"` +Expected: PASS + +**Step 5: Commit** + +```bash +git add framework/SimpleModule.Core/Menu/MenuSection.cs tests/SimpleModule.Core.Tests/Menu/MenuRegistryTests.cs +git commit -m "feat(core): add AdminSidebar to MenuSection enum" +``` + +--- + +### Task 2: Register admin sidebar menu items in modules + +**Files:** +- Modify: `modules/Admin/src/Admin/AdminModule.cs` +- Modify: `modules/OpenIddict/src/OpenIddict/OpenIddictModule.cs` + +**Step 1: Add AdminSidebar items to AdminModule.ConfigureMenu** + +In `AdminModule.cs`, add two new `MenuItem` entries with `Section = MenuSection.AdminSidebar` for Users and Roles. Keep the existing Navbar and UserDropdown items — they serve the top nav and dropdown. Use the same icons and URLs as the existing Navbar items. + +```csharp +menus.Add( + new MenuItem + { + Label = "Users", + Url = "/admin/users", + Icon = """""", + Order = 10, + Section = MenuSection.AdminSidebar, + } +); +menus.Add( + new MenuItem + { + Label = "Roles", + Url = "/admin/roles", + Icon = """""", + Order = 11, + Section = MenuSection.AdminSidebar, + } +); +``` + +**Step 2: Add AdminSidebar item to OpenIddictModule.ConfigureMenu** + +In `OpenIddictModule.cs`, add one new `MenuItem`: + +```csharp +menus.Add( + new MenuItem + { + Label = "OAuth Clients", + Url = "/openiddict/clients", + Icon = """""", + Order = 20, + Section = MenuSection.AdminSidebar, + } +); +``` + +**Step 3: Build to verify** + +Run: `dotnet build` +Expected: Success. + +**Step 4: Commit** + +```bash +git add modules/Admin/src/Admin/AdminModule.cs modules/OpenIddict/src/OpenIddict/OpenIddictModule.cs +git commit -m "feat(modules): register AdminSidebar menu items in Admin and OpenIddict" +``` + +--- + +### Task 3: Share admin sidebar menu items as Inertia props + +The current Inertia integration has no shared props mechanism. We need to inject admin sidebar menu items into every Inertia response for admin pages. The cleanest approach: modify `InertiaResult` to support shared data, or create a middleware that enriches Inertia responses. + +**Approach:** Add a scoped `InertiaSharedData` service. A middleware populates it per-request. `InertiaResult.ExecuteAsync` merges shared data into props. + +**Files:** +- Modify: `framework/SimpleModule.Core/Inertia/InertiaResult.cs` +- Create: `framework/SimpleModule.Core/Inertia/InertiaSharedData.cs` +- Create: `framework/SimpleModule.Core/Inertia/AdminSidebarMiddleware.cs` +- Modify: `template/SimpleModule.Host/Program.cs` + +**Step 1: Create `InertiaSharedData` — a per-request shared data store** + +Create `framework/SimpleModule.Core/Inertia/InertiaSharedData.cs`: + +```csharp +namespace SimpleModule.Core.Inertia; + +public sealed class InertiaSharedData +{ + private readonly Dictionary _data = []; + + public void Set(string key, object? value) => _data[key] = value; + + public IReadOnlyDictionary GetAll() => _data; +} +``` + +**Step 2: Register `InertiaSharedData` as scoped** + +In `Program.cs` (or a suitable service registration location), add: + +```csharp +builder.Services.AddScoped(); +``` + +**Step 3: Modify `InertiaResult.ExecuteAsync` to merge shared data** + +In `InertiaResult.cs`, resolve `InertiaSharedData` from DI and merge its entries into the props dictionary before serializing: + +```csharp +public async Task ExecuteAsync(HttpContext httpContext) +{ + var sharedData = httpContext.RequestServices.GetService(); + var mergedProps = MergeProps(_props, sharedData); + + var pageData = new + { + component = _component, + props = mergedProps, + url = httpContext.Request.Path + httpContext.Request.QueryString, + version = InertiaMiddleware.Version, + }; + + // ... rest unchanged +} + +private static object MergeProps(object? props, InertiaSharedData? sharedData) +{ + if (sharedData is null || sharedData.GetAll().Count == 0) + { + return props ?? new { }; + } + + var result = new Dictionary(); + + // Add shared data first (lower priority) + foreach (var kvp in sharedData.GetAll()) + { + result[kvp.Key] = kvp.Value; + } + + // Add endpoint props (higher priority — overwrites shared data) + if (props is not null) + { + foreach (var property in props.GetType().GetProperties()) + { + result[property.Name] = property.GetValue(props); + } + } + + return result; +} +``` + +Note: `GetProperties()` is fine here — this runs at request time on anonymous types, not in AOT-constrained module discovery. If AOT issues arise, the plan can use a source-generated approach instead. + +**Step 4: Create `AdminSidebarMiddleware`** + +Create `framework/SimpleModule.Core/Inertia/AdminSidebarMiddleware.cs`: + +```csharp +using SimpleModule.Core.Menu; + +namespace SimpleModule.Core.Inertia; + +public static class AdminSidebarMiddlewareExtensions +{ + public static IApplicationBuilder UseAdminSidebarSharedData(this IApplicationBuilder app) + { + return app.Use(async (context, next) => + { + var sharedData = context.RequestServices.GetService(); + if (sharedData is not null && context.User.Identity?.IsAuthenticated == true) + { + var menuRegistry = context.RequestServices.GetRequiredService(); + var items = menuRegistry.GetItems(MenuSection.AdminSidebar); + sharedData.Set("adminSidebarMenu", items.Select(i => new + { + label = i.Label, + url = i.Url, + icon = i.Icon, + order = i.Order, + }).ToList()); + } + await next(); + }); + } +} +``` + +**Step 5: Register middleware in `Program.cs`** + +Add `app.UseAdminSidebarSharedData();` after authentication/authorization middleware, before `app.UseInertia()`. + +**Step 6: Build to verify** + +Run: `dotnet build` +Expected: Success. + +**Step 7: Commit** + +```bash +git add framework/SimpleModule.Core/Inertia/InertiaSharedData.cs framework/SimpleModule.Core/Inertia/AdminSidebarMiddleware.cs framework/SimpleModule.Core/Inertia/InertiaResult.cs template/SimpleModule.Host/Program.cs +git commit -m "feat(core): add Inertia shared data and admin sidebar middleware" +``` + +--- + +### Task 4: Create `AdminLayout.tsx` React component + +**Files:** +- Create: `modules/Admin/src/Admin/Pages/AdminLayout.tsx` + +**Step 1: Create the AdminLayout component** + +This component renders: +1. A simplified top bar (logo, "Back to app" link, user info placeholder) +2. A collapsible sidebar using `@simplemodule/ui` Sidebar components +3. A content area where child pages render + +The component reads `adminSidebarMenu` from Inertia shared props via `usePage()`. + +Menu item icons come from the server-registered `MenuItem.Icon` field, which contains trusted SVG strings defined in module source code (e.g., `AdminModule.cs`). These are NOT user-supplied — they are developer-authored constants compiled into the application, making them safe to render directly. + +```tsx +import { Link, usePage } from '@inertiajs/react'; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, + SidebarTrigger, + useSidebar, +} from '@simplemodule/ui'; +import type { ReactNode } from 'react'; + +interface AdminMenuItem { + label: string; + url: string; + icon: string; + order: number; +} + +interface AdminLayoutProps { + children: ReactNode; +} + +function TopBar() { + return ( +
+ + +
+ ); +} + +function SidebarNav() { + const { props } = usePage<{ adminSidebarMenu?: AdminMenuItem[] }>(); + const menuItems = props.adminSidebarMenu ?? []; + const currentPath = window.location.pathname; + const { open } = useSidebar(); + + return ( + <> + + {open && ( + + Administration + + )} + + + + {menuItems.map((item) => { + const isActive = currentPath.startsWith(item.url); + return ( + + + + + {open && {item.label}} + + + + ); + })} + + + + + + + ); +} + +export default function AdminLayout({ children }: AdminLayoutProps) { + return ( + + + + +
+ +
+ {children} +
+
+
+ ); +} +``` + +**Step 2: Build frontend to verify** + +Run: `npm run check` from repo root. +Expected: No lint/format errors. + +**Step 3: Commit** + +```bash +git add modules/Admin/src/Admin/Pages/AdminLayout.tsx +git commit -m "feat(admin): add AdminLayout component with sidebar and top bar" +``` + +--- + +### Task 5: Wire admin pages to use AdminLayout via Inertia persistent layouts + +**Files:** +- Modify: `modules/Admin/src/Admin/Pages/Admin/Users.tsx` +- Modify: `modules/Admin/src/Admin/Pages/Admin/UsersCreate.tsx` +- Modify: `modules/Admin/src/Admin/Pages/Admin/UsersEdit.tsx` +- Modify: `modules/Admin/src/Admin/Pages/Admin/Roles.tsx` +- Modify: `modules/Admin/src/Admin/Pages/Admin/RolesCreate.tsx` +- Modify: `modules/Admin/src/Admin/Pages/Admin/RolesEdit.tsx` + +**Step 1: Add persistent layout to each admin page** + +Inertia persistent layouts work by attaching a `layout` property to the page component. For each admin page component, add after the component definition: + +```tsx +import AdminLayout from '../AdminLayout'; + +// ... existing component code ... + +Users.layout = (page: React.ReactNode) => {page}; +``` + +Do this for all 6 admin page components. The pattern is identical — import `AdminLayout` and attach `.layout`. + +**Step 2: Update `resolvePage` to support persistent layouts** + +Check `packages/SimpleModule.Client/src/resolve-page.ts`. The current `resolvePage` returns `{ default: page }`. Inertia's `createInertiaApp` needs to see the `layout` property on the resolved component. Verify this works by checking if the returned component preserves its `.layout` property. If `page.default` exists, it should already have `.layout`. If wrapping in `{ default: page }`, the layout property is preserved on the inner `page` function — Inertia reads it from there. + +No change should be needed to `resolve-page.ts`, but verify during testing. + +**Step 3: Build and verify** + +Run: `npm run check` from repo root. +Expected: No errors. + +**Step 4: Commit** + +```bash +git add modules/Admin/src/Admin/Pages/Admin/Users.tsx modules/Admin/src/Admin/Pages/Admin/UsersCreate.tsx modules/Admin/src/Admin/Pages/Admin/UsersEdit.tsx modules/Admin/src/Admin/Pages/Admin/Roles.tsx modules/Admin/src/Admin/Pages/Admin/RolesCreate.tsx modules/Admin/src/Admin/Pages/Admin/RolesEdit.tsx +git commit -m "feat(admin): wire all admin pages to use AdminLayout persistent layout" +``` + +--- + +### Task 6: Wire OpenIddict client pages to use AdminLayout + +**Files:** +- Check: `modules/OpenIddict/src/OpenIddict/Pages/index.ts` for page list +- Modify: All OpenIddict admin page components (Clients list, create, edit) + +**Step 1: Find OpenIddict page components** + +Look at `modules/OpenIddict/src/OpenIddict/Pages/index.ts` to identify all page components. + +**Step 2: Add persistent layout to each OpenIddict page** + +Same pattern as Task 5: + +```tsx +import AdminLayout from './AdminLayout'; // or create a shared AdminLayout, see note below +``` + +Note: OpenIddict pages need access to `AdminLayout`. Options: +- **Option A:** Duplicate `AdminLayout.tsx` into OpenIddict module (bad — DRY violation). +- **Option B:** Move `AdminLayout.tsx` to `@simplemodule/client` package since it depends on Inertia's `usePage()` (which is already a client concern). Export from `@simplemodule/client/admin-layout`. +- **Option C:** Create a small `AdminLayout.tsx` in each module that imports the sidebar components from `@simplemodule/ui`. + +**Recommended: Option B** — Move AdminLayout to `@simplemodule/client`. + +Steps: +1. Move `AdminLayout.tsx` from `modules/Admin/src/Admin/Pages/AdminLayout.tsx` to `packages/SimpleModule.Client/src/admin-layout.tsx` +2. Add `@simplemodule/ui` as a dependency in `packages/SimpleModule.Client/package.json` +3. Export from package: add `"./admin-layout"` to package.json exports +4. Update Admin module imports to `import { AdminLayout } from '@simplemodule/client/admin-layout'` +5. Import in OpenIddict pages: `import { AdminLayout } from '@simplemodule/client/admin-layout'` + +**Step 3: Build and verify** + +Run: `npm run check` from repo root. +Expected: No errors. + +**Step 4: Commit** + +```bash +git add packages/SimpleModule.Client/ modules/Admin/src/Admin/Pages/ modules/OpenIddict/src/OpenIddict/Pages/ +git commit -m "refactor: move AdminLayout to @simplemodule/client, wire OpenIddict pages" +``` + +--- + +### Task 7: Remove admin items from top navbar + +Now that admin pages have their own sidebar, remove the Admin and OpenIddict items from `MenuSection.Navbar` to avoid duplication. + +**Files:** +- Modify: `modules/Admin/src/Admin/AdminModule.cs` +- Modify: `modules/OpenIddict/src/OpenIddict/OpenIddictModule.cs` + +**Step 1: Remove Navbar menu items for admin pages** + +In `AdminModule.cs`, remove the two `MenuItem` entries with `Section = MenuSection.Navbar` (Users and Roles, order 20-21). Keep the `UserDropdown` items (Manage Users, Manage Roles) — those serve as quick-access links from the user menu. + +In `OpenIddictModule.cs`, remove the `MenuItem` with `Section = MenuSection.Navbar` (OAuth Clients, order 22). + +**Step 2: Add a single "Admin" navbar link** + +In `AdminModule.cs`, add one Navbar item that links to the admin area: + +```csharp +menus.Add( + new MenuItem + { + Label = "Admin", + Url = "/admin/users", + Icon = """""", + Order = 20, + Section = MenuSection.Navbar, + } +); +``` + +**Step 3: Build and verify** + +Run: `dotnet build` +Expected: Success. + +**Step 4: Commit** + +```bash +git add modules/Admin/src/Admin/AdminModule.cs modules/OpenIddict/src/OpenIddict/OpenIddictModule.cs +git commit -m "refactor: consolidate navbar to single Admin link, move items to sidebar" +``` + +--- + +### Task 8: Remove hardcoded admin links from ManageLayout + +The `ManageLayout.tsx` in the Users module has hardcoded admin links (Manage Users, Manage Roles). These should be removed now that admin has its own layout. + +**Files:** +- Modify: `modules/Users/src/Users/Pages/Account/ManageLayout.tsx` + +**Step 1: Remove the `adminItems` array and its rendering** + +Remove the `adminItems` constant and any JSX that renders admin links in the manage layout. Keep the account management nav items. + +**Step 2: Build and verify** + +Run: `npm run check` +Expected: No errors. + +**Step 3: Commit** + +```bash +git add modules/Users/src/Users/Pages/Account/ManageLayout.tsx +git commit -m "refactor(users): remove hardcoded admin links from ManageLayout" +``` + +--- + +### Task 9: Style the admin layout for full viewport + +The Blazor `MainLayout.razor` renders a `
` tag with `max-w-6xl` and centered padding. When admin pages render with their own sidebar, this constrains the layout. + +**Files:** +- Modify: `modules/Admin/src/Admin/Pages/AdminLayout.tsx` (or `packages/SimpleModule.Client/src/admin-layout.tsx` after Task 6 move) + +**Step 1: Use fixed positioning for full-viewport admin layout** + +Have `AdminLayout.tsx` render with styles that overlay the Blazor shell: + +```tsx +
+ {/* sidebar + content */} +
+``` + +This overlays the Blazor shell entirely for admin pages. The Blazor nav is still rendered server-side but hidden behind the React overlay. This avoids any Blazor changes. + +**Step 2: Verify visually** + +Run: `dotnet run --project template/SimpleModule.Host` +Navigate to `/admin/users` and verify the sidebar layout renders correctly. + +**Step 3: Commit** + +```bash +git add packages/SimpleModule.Client/src/admin-layout.tsx +git commit -m "feat(admin): use fixed positioning for full-viewport admin layout" +``` + +--- + +### Task 10: End-to-end verification and cleanup + +**Step 1: Build everything** + +Run: `dotnet build && npm run check` +Expected: Both pass. + +**Step 2: Run all tests** + +Run: `dotnet test` +Expected: All pass. The menu system tests should pass with the new `AdminSidebar` section. + +**Step 3: Manual verification** + +Run: `dotnet run --project template/SimpleModule.Host` + +Verify: +- `/admin/users` — shows sidebar layout with Users, Roles, OAuth Clients links +- Sidebar collapses to icon-only on toggle +- "Back to app" link returns to home +- Active link is highlighted +- `/admin/roles`, `/openiddict/clients` — also show sidebar layout +- `/` (home), `/products` — still show normal top navbar layout +- Mobile: sidebar is hidden, hamburger menu works + +**Step 4: Commit any cleanup** + +```bash +git commit -m "chore: admin sidebar layout cleanup" +``` diff --git a/docs/plans/2026-03-19-module-split-design.md b/docs/plans/2026-03-19-module-split-design.md new file mode 100644 index 00000000..750826f5 --- /dev/null +++ b/docs/plans/2026-03-19-module-split-design.md @@ -0,0 +1,74 @@ +# OpenIddict & Permissions Module Split + +**Date**: 2026-03-19 +**Branch**: feature/permission-system +**Goal**: Extract OpenIddict and permissions from Users module into two independent modules. + +## Current State + +The Users module currently owns: +- ASP.NET Identity (ApplicationUser, ApplicationRole, UsersDbContext) +- OpenIddict OAuth2 (authorization code flow, token endpoints, client seeding) +- Permission entities (UserPermission, RolePermission) +- Permission seeding +- User CRUD (IUserContracts) + +This is too much responsibility in one module. + +## Design + +### Users Module (slimmed down) + +Keeps only identity concerns: +- `ApplicationUser`, `ApplicationRole` entities +- `UsersDbContext` — Identity tables only (no OpenIddict, no permission tables) +- User CRUD endpoints + `IUserContracts` +- `ConsoleEmailSender` +- Login/register view logic + +### OpenIddict Module (new) + +Owns all OAuth2/OIDC infrastructure: +- `OpenIddictDbContext` — OpenIddict application/token/authorization/scope tables +- Connect endpoints: authorize, token, logout, userinfo +- `OpenIddictSeedService` — seeds client applications, dev certs +- Token generation: calls `IPermissionContracts.GetPermissionsForUserAsync(userId)` to issue permission claims +- `OpenIddict.Contracts` — auth constants (OAuth2Scheme, scopes, etc.) +- **Depends on**: `Users.Contracts`, `Permissions.Contracts` + +### Permissions Module (new) + +Owns permission data storage and queries: +- `PermissionsDbContext` — `UserPermission`, `RolePermission` tables (string-to-string mappings) +- `PermissionSeedService` — seeds Admin role permissions at startup using `PermissionRegistry` +- `Permissions.Contracts` — `IPermissionContracts` interface: + - `GetPermissionsForUserAsync(string userId)` — combined user + role permissions + - `GetPermissionsForRoleAsync(string roleId)` — role permissions + - `SetPermissionsForRoleAsync(string roleId, IEnumerable permissions)` + - `SetPermissionsForUserAsync(string userId, IEnumerable permissions)` +- **Depends on**: nothing (role IDs and permissions are plain strings) + +## Dependency Graph + +``` +Admin ──→ Users.Contracts +Admin ──→ Permissions.Contracts +OpenIddict ──→ Users.Contracts +OpenIddict ──→ Permissions.Contracts +Permissions ──→ (nothing) +Users ──→ (nothing) +``` + +## What Stays in Core (unchanged) + +- `PermissionRegistry` / `PermissionRegistryBuilder` +- `PermissionAuthorizationHandler` +- `RequirePermissionAttribute` +- `IModule.ConfigurePermissions()` + +## Key Decisions + +- **Permissions are const strings** — defined in each module's Contracts (e.g., `AdminPermissions.ManageUsers = "Admin.ManageUsers"`). +- **Each module gets its own DbContext** — follows existing project convention. +- **RolePermission/UserPermission use plain string IDs** — no entity dependency on Users module. The Permissions module doesn't validate that a role exists. +- **OpenIddict calls IPermissionContracts during token generation** — direct contract dependency, not event-based. diff --git a/docs/plans/2026-03-19-module-split-plan.md b/docs/plans/2026-03-19-module-split-plan.md new file mode 100644 index 00000000..dfa8ee9b --- /dev/null +++ b/docs/plans/2026-03-19-module-split-plan.md @@ -0,0 +1,1965 @@ +# OpenIddict & Permissions Module Split — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Extract OpenIddict and permissions into two new modules, slimming the Users module to identity-only concerns. + +**Architecture:** Create `modules/Permissions/` (owns permission entities + queries, depends on Users.Contracts for `UserId`) and `modules/OpenIddict/` (owns OAuth2/OIDC, depends on Users.Contracts + Permissions.Contracts). Users module keeps only Identity entities, UserService, and ConsoleEmailSender. Admin module switches from `UsersDbContext` to `IPermissionContracts` for permission operations. + +**Tech Stack:** .NET 10, EF Core, OpenIddict, Vogen (typed IDs), xUnit.v3, FluentAssertions, Bogus, SQLite in-memory for tests. + +--- + +### Task 1: Create Permissions.Contracts Project + +**Files:** +- Create: `modules/Permissions/src/Permissions.Contracts/Permissions.Contracts.csproj` +- Create: `modules/Permissions/src/Permissions.Contracts/IPermissionContracts.cs` +- Create: `modules/Permissions/src/Permissions.Contracts/PermissionsConstants.cs` +- Create: `modules/Permissions/src/Permissions.Contracts/RoleId.cs` + +**Step 1: Create csproj** + +```xml + + + net10.0 + Library + $(DefineConstants);VOGEN_NO_VALIDATION + + + + + + + +``` + +Note: References `Users.Contracts` for the `UserId` type. This is consistent with the project's convention that domain types like strongly-typed IDs belong in module Contracts. + +**Step 2: Create RoleId** + +```csharp +using Vogen; + +namespace SimpleModule.Permissions.Contracts; + +[ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] +public readonly partial struct RoleId +{ + private static Validation Validate(string value) => + string.IsNullOrWhiteSpace(value) + ? Validation.Invalid("RoleId cannot be empty") + : Validation.Ok; +} +``` + +**Step 3: Create IPermissionContracts** + +```csharp +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Permissions.Contracts; + +public interface IPermissionContracts +{ + Task> GetPermissionsForUserAsync(UserId userId); + Task> GetPermissionsForRoleAsync(RoleId roleId); + Task> GetAllPermissionsForUserAsync(UserId userId, IEnumerable roleIds); + Task SetPermissionsForUserAsync(UserId userId, IEnumerable permissions); + Task SetPermissionsForRoleAsync(RoleId roleId, IEnumerable permissions); +} +``` + +`GetAllPermissionsForUserAsync` combines user permissions + permissions for all given roles. This is what the AuthorizationEndpoint needs — it has the user ID and role IDs, and wants all permissions in one call. + +**Step 4: Create PermissionsConstants** + +```csharp +namespace SimpleModule.Permissions.Contracts; + +public static class PermissionsConstants +{ + public const string ModuleName = "Permissions"; +} +``` + +**Step 5: Add to solution** + +```bash +dotnet slnx add modules/Permissions/src/Permissions.Contracts/Permissions.Contracts.csproj --solution-folder /modules/Permissions/ +``` + +**Step 6: Build to verify** + +Run: `dotnet build modules/Permissions/src/Permissions.Contracts/Permissions.Contracts.csproj` +Expected: Build succeeded + +**Step 7: Commit** + +```bash +git add modules/Permissions/src/Permissions.Contracts/ SimpleModule.slnx +git commit -m "feat(permissions): add Permissions.Contracts with IPermissionContracts, RoleId" +``` + +--- + +### Task 2: Create Permissions Module Implementation + +**Files:** +- Create: `modules/Permissions/src/Permissions/Permissions.csproj` +- Create: `modules/Permissions/src/Permissions/PermissionsModule.cs` +- Create: `modules/Permissions/src/Permissions/PermissionsDbContext.cs` +- Create: `modules/Permissions/src/Permissions/Entities/UserPermission.cs` +- Create: `modules/Permissions/src/Permissions/Entities/RolePermission.cs` +- Create: `modules/Permissions/src/Permissions/PermissionService.cs` +- Create: `modules/Permissions/src/Permissions/Services/PermissionSeedService.cs` + +**Step 1: Create csproj** + +```xml + + + net10.0 + + + + + + + + +``` + +**Step 2: Create entities** + +`Entities/UserPermission.cs`: +```csharp +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Permissions.Entities; + +#pragma warning disable CA1711 // Identifiers should not have incorrect suffix +public class UserPermission +#pragma warning restore CA1711 +{ + public UserId UserId { get; set; } + public string Permission { get; set; } = string.Empty; +} +``` + +`Entities/RolePermission.cs`: +```csharp +using SimpleModule.Permissions.Contracts; + +namespace SimpleModule.Permissions.Entities; + +#pragma warning disable CA1711 // Identifiers should not have incorrect suffix +public class RolePermission +#pragma warning restore CA1711 +{ + public RoleId RoleId { get; set; } + public string Permission { get; set; } = string.Empty; +} +``` + +No navigation properties — these are pure string-backed value object mappings with no FK to Identity tables. + +**Step 3: Create PermissionsDbContext** + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SimpleModule.Database; +using SimpleModule.Permissions.Contracts; +using SimpleModule.Permissions.Entities; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Permissions; + +public class PermissionsDbContext( + DbContextOptions options, + IOptions dbOptions +) : DbContext(options) +{ + public DbSet UserPermissions => Set(); + public DbSet RolePermissions => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + builder.Entity(entity => + { + entity.HasKey(e => new { e.UserId, e.Permission }); + entity.Property(e => e.UserId).HasConversion(); + entity.ToTable("UserPermissions"); + }); + + builder.Entity(entity => + { + entity.HasKey(e => new { e.RoleId, e.Permission }); + entity.Property(e => e.RoleId).HasConversion(); + entity.ToTable("RolePermissions"); + }); + + builder.ApplyModuleSchema("Permissions", dbOptions.Value); + } +} +``` + +**Step 4: Create PermissionService** + +```csharp +using Microsoft.EntityFrameworkCore; +using SimpleModule.Permissions.Contracts; +using SimpleModule.Permissions.Entities; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Permissions; + +public class PermissionService(PermissionsDbContext db) : IPermissionContracts +{ + public async Task> GetPermissionsForUserAsync(UserId userId) + { + var perms = await db.UserPermissions + .Where(p => p.UserId == userId) + .Select(p => p.Permission) + .ToListAsync(); + + return new HashSet(perms); + } + + public async Task> GetPermissionsForRoleAsync(RoleId roleId) + { + var perms = await db.RolePermissions + .Where(p => p.RoleId == roleId) + .Select(p => p.Permission) + .ToListAsync(); + + return new HashSet(perms); + } + + public async Task> GetAllPermissionsForUserAsync( + UserId userId, + IEnumerable roleIds) + { + var roleIdList = roleIds.ToList(); + + var rolePerms = await db.RolePermissions + .Where(p => roleIdList.Contains(p.RoleId)) + .Select(p => p.Permission) + .ToListAsync(); + + var userPerms = await db.UserPermissions + .Where(p => p.UserId == userId) + .Select(p => p.Permission) + .ToListAsync(); + + var result = new HashSet(rolePerms); + foreach (var p in userPerms) + result.Add(p); + + return result; + } + + public async Task SetPermissionsForUserAsync(UserId userId, IEnumerable permissions) + { + var existing = await db.UserPermissions + .Where(p => p.UserId == userId) + .ToListAsync(); + + db.UserPermissions.RemoveRange(existing); + + foreach (var permission in permissions) + { + db.UserPermissions.Add(new UserPermission { UserId = userId, Permission = permission }); + } + + await db.SaveChangesAsync(); + } + + public async Task SetPermissionsForRoleAsync(RoleId roleId, IEnumerable permissions) + { + var existing = await db.RolePermissions + .Where(p => p.RoleId == roleId) + .ToListAsync(); + + db.RolePermissions.RemoveRange(existing); + + foreach (var permission in permissions) + { + db.RolePermissions.Add(new RolePermission { RoleId = roleId, Permission = permission }); + } + + await db.SaveChangesAsync(); + } +} +``` + +**Step 5: Create PermissionSeedService** + +```csharp +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SimpleModule.Core.Authorization; +using SimpleModule.Permissions.Contracts; +using SimpleModule.Permissions.Entities; +using SimpleModule.Users.Entities; + +namespace SimpleModule.Permissions.Services; + +public partial class PermissionSeedService( + IServiceProvider serviceProvider, + PermissionRegistry permissionRegistry, + ILogger logger +) : IHostedService +{ + private const string AdminRole = "Admin"; + + public async Task StartAsync(CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + + var adminRole = await roleManager.FindByNameAsync(AdminRole); + if (adminRole is null) + return; + + var adminRoleId = RoleId.From(adminRole.Id); + + var existingPermissions = await dbContext + .RolePermissions.Where(rp => rp.RoleId == adminRoleId) + .Select(rp => rp.Permission) + .ToListAsync(cancellationToken); + + var existingSet = new HashSet(existingPermissions); + var newPermissions = new List(); + + foreach (var permission in permissionRegistry.AllPermissions) + { + if (!existingSet.Contains(permission)) + { + newPermissions.Add( + new RolePermission { RoleId = adminRoleId, Permission = permission } + ); + } + } + + if (newPermissions.Count > 0) + { + LogSeedingPermissions(logger, newPermissions.Count); + dbContext.RolePermissions.AddRange(newPermissions); + await dbContext.SaveChangesAsync(cancellationToken); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Seeding {Count} permissions for Admin role..." + )] + private static partial void LogSeedingPermissions(ILogger logger, int count); +} +``` + +Note: The seed service references `ApplicationRole` and `RoleManager` at runtime (registered by Users module). The Permissions.csproj does NOT reference Users.csproj — it references Users.Contracts (transitively via Permissions.Contracts). For the seed service to resolve `RoleManager`, the Permissions implementation project needs a reference to Users.csproj. **Alternative**: Add `GetRoleIdByNameAsync(string name)` to `IUserContracts` and avoid the reference. This is the cleaner approach — update `IUserContracts` to include it. + +**Revised approach**: Add to `IUserContracts`: +```csharp +Task GetRoleIdByNameAsync(string roleName); +``` + +Then the seed service calls `IUserContracts.GetRoleIdByNameAsync("Admin")` and wraps in `RoleId.From(...)`. No direct dependency on Users implementation needed. + +**Step 6: Create PermissionsModule** + +```csharp +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core; +using SimpleModule.Database; +using SimpleModule.Permissions.Contracts; +using SimpleModule.Permissions.Services; + +namespace SimpleModule.Permissions; + +[Module(PermissionsConstants.ModuleName)] +public class PermissionsModule : IModule +{ + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddModuleDbContext( + configuration, + PermissionsConstants.ModuleName + ); + + services.AddScoped(); + services.AddHostedService(); + } +} +``` + +**Step 7: Add to solution** + +```bash +dotnet slnx add modules/Permissions/src/Permissions/Permissions.csproj --solution-folder /modules/Permissions/ +``` + +**Step 8: Build to verify** + +Run: `dotnet build modules/Permissions/src/Permissions/Permissions.csproj` +Expected: Build succeeded + +**Step 9: Commit** + +```bash +git add modules/Permissions/ SimpleModule.slnx +git commit -m "feat(permissions): add Permissions module with DbContext, PermissionService, seed" +``` + +--- + +### Task 3: Create Permissions Module Tests + +**Files:** +- Create: `modules/Permissions/tests/Permissions.Tests/Permissions.Tests.csproj` +- Create: `modules/Permissions/tests/Permissions.Tests/Unit/PermissionServiceTests.cs` +- Create: `modules/Permissions/tests/Permissions.Tests/Unit/PermissionServiceRoleTests.cs` +- Create: `modules/Permissions/tests/Permissions.Tests/Unit/PermissionServiceCombinedTests.cs` +- Create: `modules/Permissions/tests/Permissions.Tests/Helpers/TestDbContextFactory.cs` + +**Step 1: Create test project** + +```xml + + + net10.0 + false + true + + + + + + + + + + + + +``` + +**Step 2: Create TestDbContextFactory helper** + +```csharp +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SimpleModule.Database; + +namespace Permissions.Tests.Helpers; + +public sealed class TestDbContextFactory : IDisposable +{ + private readonly SqliteConnection _connection; + + public TestDbContextFactory() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + } + + public PermissionsDbContext Create() + { + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + var dbOptions = Options.Create(new DatabaseOptions()); + var context = new PermissionsDbContext(options, dbOptions); + context.Database.EnsureCreated(); + return context; + } + + public void Dispose() => _connection.Dispose(); +} +``` + +**Step 3: Write user permission tests** + +`Unit/PermissionServiceTests.cs`: +```csharp +using FluentAssertions; +using Permissions.Tests.Helpers; +using SimpleModule.Permissions; +using SimpleModule.Users.Contracts; + +namespace Permissions.Tests.Unit; + +public class PermissionServiceTests : IDisposable +{ + private readonly TestDbContextFactory _factory = new(); + + [Fact] + public async Task GetPermissionsForUser_NoPermissions_ReturnsEmpty() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + + var result = await sut.GetPermissionsForUserAsync(UserId.From("user-1")); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task SetPermissionsForUser_ThenGet_ReturnsSetPermissions() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + var userId = UserId.From("user-1"); + + await sut.SetPermissionsForUserAsync(userId, ["Admin.ManageUsers", "Admin.ViewAuditLog"]); + + var result = await sut.GetPermissionsForUserAsync(userId); + result.Should().HaveCount(2); + result.Should().Contain("Admin.ManageUsers"); + result.Should().Contain("Admin.ViewAuditLog"); + } + + [Fact] + public async Task SetPermissionsForUser_ReplacesExisting() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + var userId = UserId.From("user-1"); + + await sut.SetPermissionsForUserAsync(userId, ["Admin.ManageUsers", "Admin.ViewAuditLog"]); + await sut.SetPermissionsForUserAsync(userId, ["Admin.ManageRoles"]); + + var result = await sut.GetPermissionsForUserAsync(userId); + result.Should().HaveCount(1); + result.Should().Contain("Admin.ManageRoles"); + } + + [Fact] + public async Task SetPermissionsForUser_EmptyList_ClearsAll() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + var userId = UserId.From("user-1"); + + await sut.SetPermissionsForUserAsync(userId, ["Admin.ManageUsers"]); + await sut.SetPermissionsForUserAsync(userId, []); + + var result = await sut.GetPermissionsForUserAsync(userId); + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetPermissionsForUser_IsolatedBetweenUsers() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + var user1 = UserId.From("user-1"); + var user2 = UserId.From("user-2"); + + await sut.SetPermissionsForUserAsync(user1, ["Admin.ManageUsers"]); + await sut.SetPermissionsForUserAsync(user2, ["Admin.ManageRoles"]); + + var result1 = await sut.GetPermissionsForUserAsync(user1); + var result2 = await sut.GetPermissionsForUserAsync(user2); + + result1.Should().ContainSingle().Which.Should().Be("Admin.ManageUsers"); + result2.Should().ContainSingle().Which.Should().Be("Admin.ManageRoles"); + } + + public void Dispose() => _factory.Dispose(); +} +``` + +**Step 4: Write role permission tests** + +`Unit/PermissionServiceRoleTests.cs`: +```csharp +using FluentAssertions; +using Permissions.Tests.Helpers; +using SimpleModule.Permissions; +using SimpleModule.Permissions.Contracts; + +namespace Permissions.Tests.Unit; + +public class PermissionServiceRoleTests : IDisposable +{ + private readonly TestDbContextFactory _factory = new(); + + [Fact] + public async Task GetPermissionsForRole_NoPermissions_ReturnsEmpty() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + + var result = await sut.GetPermissionsForRoleAsync(RoleId.From("role-1")); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task SetPermissionsForRole_ThenGet_ReturnsSetPermissions() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + var roleId = RoleId.From("role-admin"); + + await sut.SetPermissionsForRoleAsync(roleId, ["Admin.ManageUsers", "Admin.ManageRoles", "Admin.ViewAuditLog"]); + + var result = await sut.GetPermissionsForRoleAsync(roleId); + result.Should().HaveCount(3); + result.Should().Contain("Admin.ManageUsers"); + result.Should().Contain("Admin.ManageRoles"); + result.Should().Contain("Admin.ViewAuditLog"); + } + + [Fact] + public async Task SetPermissionsForRole_ReplacesExisting() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + var roleId = RoleId.From("role-admin"); + + await sut.SetPermissionsForRoleAsync(roleId, ["Admin.ManageUsers", "Admin.ManageRoles"]); + await sut.SetPermissionsForRoleAsync(roleId, ["Admin.ViewAuditLog"]); + + var result = await sut.GetPermissionsForRoleAsync(roleId); + result.Should().ContainSingle().Which.Should().Be("Admin.ViewAuditLog"); + } + + [Fact] + public async Task SetPermissionsForRole_EmptyList_ClearsAll() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + var roleId = RoleId.From("role-admin"); + + await sut.SetPermissionsForRoleAsync(roleId, ["Admin.ManageUsers"]); + await sut.SetPermissionsForRoleAsync(roleId, []); + + var result = await sut.GetPermissionsForRoleAsync(roleId); + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetPermissionsForRole_IsolatedBetweenRoles() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + var role1 = RoleId.From("role-admin"); + var role2 = RoleId.From("role-editor"); + + await sut.SetPermissionsForRoleAsync(role1, ["Admin.ManageUsers"]); + await sut.SetPermissionsForRoleAsync(role2, ["Admin.ViewAuditLog"]); + + var result1 = await sut.GetPermissionsForRoleAsync(role1); + var result2 = await sut.GetPermissionsForRoleAsync(role2); + + result1.Should().ContainSingle().Which.Should().Be("Admin.ManageUsers"); + result2.Should().ContainSingle().Which.Should().Be("Admin.ViewAuditLog"); + } + + public void Dispose() => _factory.Dispose(); +} +``` + +**Step 5: Write combined permission tests** + +`Unit/PermissionServiceCombinedTests.cs`: +```csharp +using FluentAssertions; +using Permissions.Tests.Helpers; +using SimpleModule.Permissions; +using SimpleModule.Permissions.Contracts; +using SimpleModule.Users.Contracts; + +namespace Permissions.Tests.Unit; + +public class PermissionServiceCombinedTests : IDisposable +{ + private readonly TestDbContextFactory _factory = new(); + + [Fact] + public async Task GetAllPermissionsForUser_CombinesUserAndRolePermissions() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + var userId = UserId.From("user-1"); + var roleId = RoleId.From("role-admin"); + + await sut.SetPermissionsForUserAsync(userId, ["Admin.ViewAuditLog"]); + await sut.SetPermissionsForRoleAsync(roleId, ["Admin.ManageUsers", "Admin.ManageRoles"]); + + var result = await sut.GetAllPermissionsForUserAsync(userId, [roleId]); + + result.Should().HaveCount(3); + result.Should().Contain("Admin.ViewAuditLog"); + result.Should().Contain("Admin.ManageUsers"); + result.Should().Contain("Admin.ManageRoles"); + } + + [Fact] + public async Task GetAllPermissionsForUser_DeduplicatesOverlapping() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + var userId = UserId.From("user-1"); + var roleId = RoleId.From("role-admin"); + + await sut.SetPermissionsForUserAsync(userId, ["Admin.ManageUsers", "Admin.ViewAuditLog"]); + await sut.SetPermissionsForRoleAsync(roleId, ["Admin.ManageUsers", "Admin.ManageRoles"]); + + var result = await sut.GetAllPermissionsForUserAsync(userId, [roleId]); + + result.Should().HaveCount(3); + } + + [Fact] + public async Task GetAllPermissionsForUser_MultipleRoles_CombinesAll() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + var userId = UserId.From("user-1"); + var role1 = RoleId.From("role-admin"); + var role2 = RoleId.From("role-editor"); + + await sut.SetPermissionsForRoleAsync(role1, ["Admin.ManageUsers"]); + await sut.SetPermissionsForRoleAsync(role2, ["Admin.ViewAuditLog"]); + + var result = await sut.GetAllPermissionsForUserAsync(userId, [role1, role2]); + + result.Should().HaveCount(2); + result.Should().Contain("Admin.ManageUsers"); + result.Should().Contain("Admin.ViewAuditLog"); + } + + [Fact] + public async Task GetAllPermissionsForUser_NoRoles_ReturnsOnlyUserPermissions() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + var userId = UserId.From("user-1"); + + await sut.SetPermissionsForUserAsync(userId, ["Admin.ViewAuditLog"]); + + var result = await sut.GetAllPermissionsForUserAsync(userId, []); + + result.Should().ContainSingle().Which.Should().Be("Admin.ViewAuditLog"); + } + + [Fact] + public async Task GetAllPermissionsForUser_NoPermissionsAnywhere_ReturnsEmpty() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + + var result = await sut.GetAllPermissionsForUserAsync( + UserId.From("user-1"), + [RoleId.From("role-1")]); + + result.Should().BeEmpty(); + } + + public void Dispose() => _factory.Dispose(); +} +``` + +**Step 6: Add to solution** + +```bash +dotnet slnx add modules/Permissions/tests/Permissions.Tests/Permissions.Tests.csproj --solution-folder /modules/Permissions/ +``` + +**Step 7: Run tests** + +Run: `dotnet test modules/Permissions/tests/Permissions.Tests/` +Expected: All 15 tests pass + +**Step 8: Commit** + +```bash +git add modules/Permissions/tests/ SimpleModule.slnx +git commit -m "test(permissions): add unit tests for PermissionService" +``` + +--- + +### Task 4: Create OpenIddict.Contracts Project + +**Files:** +- Create: `modules/OpenIddict/src/OpenIddict.Contracts/OpenIddict.Contracts.csproj` +- Create: `modules/OpenIddict/src/OpenIddict.Contracts/OpenIddictModuleConstants.cs` +- Create: `modules/OpenIddict/src/OpenIddict.Contracts/AuthConstants.cs` +- Create: `modules/OpenIddict/src/OpenIddict.Contracts/ConnectRouteConstants.cs` +- Create: `modules/OpenIddict/src/OpenIddict.Contracts/ClientConstants.cs` +- Create: `modules/OpenIddict/src/OpenIddict.Contracts/ConfigKeys.cs` +- Create: `modules/OpenIddict/src/OpenIddict.Contracts/AuthErrorMessages.cs` + +**Step 1: Create csproj** + +```xml + + + net10.0 + Library + + + + + +``` + +**Step 2: Create constants — move from Users.Contracts** + +Move all constant classes from `modules/Users/src/Users.Contracts/Constants/` to OpenIddict.Contracts, changing namespace to `SimpleModule.OpenIddict.Contracts`: + +`OpenIddictModuleConstants.cs`: +```csharp +namespace SimpleModule.OpenIddict.Contracts; + +public static class OpenIddictModuleConstants +{ + public const string ModuleName = "OpenIddict"; +} +``` + +`AuthConstants.cs`: +```csharp +namespace SimpleModule.OpenIddict.Contracts; + +public static class AuthConstants +{ + public const string OAuth2Scheme = "oauth2"; + public const string SmartAuthPolicy = "SmartAuth"; + public const string OpenIdScope = "openid"; + public const string ProfileScope = "profile"; + public const string EmailScope = "email"; + public const string RolesScope = "roles"; +} +``` + +`ConnectRouteConstants.cs`: +```csharp +namespace SimpleModule.OpenIddict.Contracts; + +public static class ConnectRouteConstants +{ + public const string ConnectAuthorize = "/connect/authorize"; + public const string ConnectToken = "/connect/token"; + public const string ConnectEndSession = "/connect/endsession"; + public const string ConnectUserInfo = "/connect/userinfo"; +} +``` + +`ClientConstants.cs`: +```csharp +namespace SimpleModule.OpenIddict.Contracts; + +public static class ClientConstants +{ + public const string ClientId = "simplemodule-client"; + public const string ClientDisplayName = "SimpleModule Client"; + public const string SwaggerCallbackPath = "/swagger/oauth2-redirect.html"; + public const string OAuthCallbackPath = "/oauth-callback"; + public const string PostLogoutRedirectPath = "/"; + public const string DefaultBaseUrl = "https://localhost:5001"; +} +``` + +`ConfigKeys.cs` (OpenIddict-specific keys only): +```csharp +namespace SimpleModule.OpenIddict.Contracts; + +public static class ConfigKeys +{ + public const string OpenIddictBaseUrl = "OpenIddict:BaseUrl"; + public const string OpenIddictEncryptionCertPath = "OpenIddict:EncryptionCertificatePath"; + public const string OpenIddictSigningCertPath = "OpenIddict:SigningCertificatePath"; + public const string OpenIddictCertPassword = "OpenIddict:CertificatePassword"; + public const string OpenIddictAdditionalRedirectUris = "OpenIddict:AdditionalRedirectUris"; +} +``` + +`AuthErrorMessages.cs`: +```csharp +namespace SimpleModule.OpenIddict.Contracts; + +public static class AuthErrorMessages +{ + public const string OpenIdConnectRequestMissing = + "The OpenID Connect request cannot be retrieved."; + public const string UserDetailsMissing = "The user details cannot be retrieved."; +} +``` + +**Step 3: Add to solution** + +```bash +dotnet slnx add modules/OpenIddict/src/OpenIddict.Contracts/OpenIddict.Contracts.csproj --solution-folder /modules/OpenIddict/ +``` + +**Step 4: Build to verify** + +Run: `dotnet build modules/OpenIddict/src/OpenIddict.Contracts/OpenIddict.Contracts.csproj` +Expected: Build succeeded + +**Step 5: Commit** + +```bash +git add modules/OpenIddict/ SimpleModule.slnx +git commit -m "feat(openiddict): add OpenIddict.Contracts with auth constants" +``` + +--- + +### Task 5: Create OpenIddict Module Implementation + +**Files:** +- Create: `modules/OpenIddict/src/OpenIddict/OpenIddict.csproj` +- Create: `modules/OpenIddict/src/OpenIddict/OpenIddictModule.cs` +- Create: `modules/OpenIddict/src/OpenIddict/OpenIddictAppDbContext.cs` +- Create: `modules/OpenIddict/src/OpenIddict/Services/OpenIddictSeedService.cs` +- Create: `modules/OpenIddict/src/OpenIddict/Endpoints/Connect/AuthorizationEndpoint.cs` +- Create: `modules/OpenIddict/src/OpenIddict/Endpoints/Connect/LogoutEndpoint.cs` +- Create: `modules/OpenIddict/src/OpenIddict/Endpoints/Connect/UserinfoEndpoint.cs` + +**Step 1: Create csproj** + +```xml + + + net10.0 + + + + + + + + + + + + + +``` + +Note: References `Users.csproj` (not just Contracts) because `AuthorizationEndpoint` needs `UserManager` and `SignInManager`. This is the same pattern Admin uses. + +**Step 2: Create OpenIddictAppDbContext** + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SimpleModule.Database; + +namespace SimpleModule.OpenIddict; + +public class OpenIddictAppDbContext( + DbContextOptions options, + IOptions dbOptions +) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + builder.ApplyModuleSchema("OpenIddict", dbOptions.Value); + } +} +``` + +**Step 3: Create OpenIddictModule** + +Move the OpenIddict configuration from `UsersModule.ConfigureServices`: + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core; +using SimpleModule.Database; +using SimpleModule.OpenIddict.Contracts; +using SimpleModule.OpenIddict.Services; + +namespace SimpleModule.OpenIddict; + +[Module(OpenIddictModuleConstants.ModuleName)] +public class OpenIddictModule : IModule +{ + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddModuleDbContext( + configuration, + OpenIddictModuleConstants.ModuleName, + opts => opts.UseOpenIddict() + ); + + services + .AddOpenIddict() + .AddCore(options => + { + options.UseEntityFrameworkCore().UseDbContext(); + }) + .AddServer(options => + { + options.AllowAuthorizationCodeFlow().RequireProofKeyForCodeExchange(); + options.AllowRefreshTokenFlow(); + + options + .SetAuthorizationEndpointUris(ConnectRouteConstants.ConnectAuthorize) + .SetTokenEndpointUris(ConnectRouteConstants.ConnectToken) + .SetEndSessionEndpointUris(ConnectRouteConstants.ConnectEndSession) + .SetUserInfoEndpointUris(ConnectRouteConstants.ConnectUserInfo); + + var encryptionCertPath = configuration[ConfigKeys.OpenIddictEncryptionCertPath]; + var signingCertPath = configuration[ConfigKeys.OpenIddictSigningCertPath]; + + if ( + !string.IsNullOrEmpty(encryptionCertPath) + && !string.IsNullOrEmpty(signingCertPath) + ) + { + var certPassword = configuration[ConfigKeys.OpenIddictCertPassword]; + options.AddEncryptionCertificate( + System.Security.Cryptography.X509Certificates.X509CertificateLoader.LoadPkcs12FromFile( + encryptionCertPath, + certPassword + ) + ); + options.AddSigningCertificate( + System.Security.Cryptography.X509Certificates.X509CertificateLoader.LoadPkcs12FromFile( + signingCertPath, + certPassword + ) + ); + } + else + { + options.AddDevelopmentEncryptionCertificate().AddDevelopmentSigningCertificate(); + } + + options.RegisterScopes( + AuthConstants.OpenIdScope, + AuthConstants.ProfileScope, + AuthConstants.EmailScope, + AuthConstants.RolesScope + ); + + options + .UseAspNetCore() + .EnableAuthorizationEndpointPassthrough() + .EnableEndSessionEndpointPassthrough() + .EnableUserInfoEndpointPassthrough(); + }) + .AddValidation(options => + { + options.UseLocalServer(); + options.UseAspNetCore(); + }); + + services.AddHostedService(); + } +} +``` + +**Step 4: Move Connect endpoints** + +Move 3 endpoints from `modules/Users/src/Users/Endpoints/Connect/` to `modules/OpenIddict/src/OpenIddict/Endpoints/Connect/`. + +Key changes to `AuthorizationEndpoint`: +- Namespace: `SimpleModule.OpenIddict.Endpoints.Connect` +- Replace direct `UsersDbContext` permission query with `IPermissionContracts`: + +```csharp +// Instead of querying UsersDbContext directly: +var permissionContracts = context.RequestServices.GetRequiredService(); +var userId = UserId.From(await userManager.GetUserIdAsync(user)); + +// Get role IDs for permission lookup +var roles = await userManager.GetRolesAsync(user); +// Need role IDs — get them from the Identity DB +var userDb = context.RequestServices.GetRequiredService(); +var roleIds = await userDb.Roles + .Where(r => roles.Contains(r.Name!)) + .Select(r => RoleId.From(r.Id)) + .ToListAsync(); + +var allPermissions = await permissionContracts.GetAllPermissionsForUserAsync(userId, roleIds); + +foreach (var permission in allPermissions) +{ + identity.AddClaim("permission", permission); +} +``` + +`LogoutEndpoint` and `UserinfoEndpoint` — just namespace change, no logic changes. + +**Step 5: Move OpenIddictSeedService** + +Move from `modules/Users/src/Users/Services/OpenIddictSeedService.cs`. Keep only the client application seeding part. Remove `SeedRolesAsync` and `SeedAdminUserAsync` (those stay in Users module as `UserSeedService`). + +```csharp +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using OpenIddict.Abstractions; +using SimpleModule.OpenIddict.Contracts; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace SimpleModule.OpenIddict.Services; + +public partial class OpenIddictSeedService( + IServiceProvider serviceProvider, + IConfiguration configuration, + ILogger logger +) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + await SeedClientApplicationAsync(scope, cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + // SeedClientApplicationAsync — same as current but using OpenIddict.Contracts constants + // (copy from existing, update namespace references) +} +``` + +**Step 6: Add to solution** + +```bash +dotnet slnx add modules/OpenIddict/src/OpenIddict/OpenIddict.csproj --solution-folder /modules/OpenIddict/ +``` + +**Step 7: Build to verify** + +Run: `dotnet build modules/OpenIddict/src/OpenIddict/OpenIddict.csproj` +Expected: Build succeeded + +**Step 8: Commit** + +```bash +git add modules/OpenIddict/ SimpleModule.slnx +git commit -m "feat(openiddict): add OpenIddict module with DbContext, endpoints, seed service" +``` + +--- + +### Task 6: Slim Down Users Module + +**Files:** +- Modify: `modules/Users/src/Users/UsersModule.cs` — remove OpenIddict config, PermissionSeedService +- Modify: `modules/Users/src/Users/UsersDbContext.cs` — remove permission DbSets +- Modify: `modules/Users/src/Users/Users.csproj` — remove OpenIddict packages +- Create: `modules/Users/src/Users/Services/UserSeedService.cs` — role + admin user seeding +- Delete: `modules/Users/src/Users/Endpoints/Connect/AuthorizationEndpoint.cs` +- Delete: `modules/Users/src/Users/Endpoints/Connect/LogoutEndpoint.cs` +- Delete: `modules/Users/src/Users/Endpoints/Connect/UserinfoEndpoint.cs` +- Delete: `modules/Users/src/Users/Entities/UserPermission.cs` +- Delete: `modules/Users/src/Users/Entities/RolePermission.cs` +- Delete: `modules/Users/src/Users/Entities/UserPermissionConfiguration.cs` +- Delete: `modules/Users/src/Users/Entities/RolePermissionConfiguration.cs` +- Delete: `modules/Users/src/Users/Services/OpenIddictSeedService.cs` +- Delete: `modules/Users/src/Users/Services/PermissionSeedService.cs` +- Delete: `modules/Users/src/Users.Contracts/Constants/AuthConstants.cs` +- Delete: `modules/Users/src/Users.Contracts/Constants/ConnectRouteConstants.cs` +- Delete: `modules/Users/src/Users.Contracts/Constants/ClientConstants.cs` +- Delete: `modules/Users/src/Users.Contracts/Constants/AuthErrorMessages.cs` +- Modify: `modules/Users/src/Users.Contracts/Constants/ConfigKeys.cs` — keep only SeedAdminPassword + +**Step 1: Update UsersModule.cs** + +```csharp +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core; +using SimpleModule.Core.Menu; +using SimpleModule.Database; +using SimpleModule.Users.Contracts; +using SimpleModule.Users.Entities; +using SimpleModule.Users.Services; + +namespace SimpleModule.Users; + +[Module(UsersConstants.ModuleName)] +public class UsersModule : IModule +{ + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddModuleDbContext(configuration, UsersConstants.ModuleName); + + services + .AddIdentity(options => + { + options.Password.RequiredLength = 8; + options.Password.RequireDigit = true; + options.Password.RequireUppercase = true; + options.Password.RequireLowercase = true; + options.Password.RequireNonAlphanumeric = false; + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); + options.Lockout.MaxFailedAccessAttempts = 5; + options.User.RequireUniqueEmail = true; + options.SignIn.RequireConfirmedEmail = false; + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + services.AddHostedService(); + services.AddSingleton, ConsoleEmailSender>(); + services.AddScoped(); + } + + // ConfigureMenu — unchanged +} +``` + +**Step 2: Create UserSeedService** + +Extract role + admin user seeding from `OpenIddictSeedService`: + +```csharp +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SimpleModule.Users.Constants; +using SimpleModule.Users.Entities; + +namespace SimpleModule.Users.Services; + +public partial class UserSeedService( + IServiceProvider serviceProvider, + IConfiguration configuration, + IHostEnvironment hostEnvironment, + ILogger logger +) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + if (!hostEnvironment.IsDevelopment()) + return; + + using var scope = serviceProvider.CreateScope(); + await SeedRolesAsync(scope); + await SeedAdminUserAsync(scope); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + // SeedRolesAsync — same as current OpenIddictSeedService.SeedRolesAsync + // SeedAdminUserAsync — same as current OpenIddictSeedService.SeedAdminUserAsync + // LoggerMessage methods — same as current +} +``` + +**Step 3: Update UsersDbContext** + +Remove `UserPermissions` and `RolePermissions` DbSets: + +```csharp +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SimpleModule.Database; +using SimpleModule.Users.Entities; + +namespace SimpleModule.Users; + +public class UsersDbContext( + DbContextOptions options, + IOptions dbOptions +) : IdentityDbContext(options) +{ + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + builder.ApplyModuleSchema("Users", dbOptions.Value); + } +} +``` + +**Step 4: Update Users.csproj — remove OpenIddict packages** + +Remove: +```xml + + +``` + +Also remove `` if no longer needed (check if any remaining code uses Blazor — it was needed for Razor views in Connect endpoints). + +Change SDK from `Microsoft.NET.Sdk.Razor` to `Microsoft.NET.Sdk` if no Razor files remain. Check if any `.cshtml`/`.razor` files exist in Users module — if the module has Razor views for Identity pages, keep the Razor SDK. + +**Step 5: Update ConfigKeys.cs** + +```csharp +namespace SimpleModule.Users.Constants; + +public static class ConfigKeys +{ + public const string SeedAdminPassword = "Seed:AdminPassword"; +} +``` + +**Step 6: Delete moved files** + +Delete all files listed in the file manifest above. + +**Step 7: Build to verify** + +Run: `dotnet build modules/Users/src/Users/Users.csproj` +Expected: Build succeeded + +**Step 8: Commit** + +```bash +git add -A +git commit -m "refactor(users): remove OpenIddict and permission concerns, add UserSeedService" +``` + +--- + +### Task 7: Update Admin Module + +**Files:** +- Modify: `modules/Admin/src/Admin/Admin.csproj` — add Permissions.Contracts reference +- Modify: `modules/Admin/src/Admin/Endpoints/Admin/AdminUsersEndpoint.cs` — use `IPermissionContracts` +- Modify: `modules/Admin/src/Admin/Endpoints/Admin/AdminRolesEndpoint.cs` — use `IPermissionContracts` + +**Step 1: Update Admin.csproj** + +Add: +```xml + +``` + +Keep existing `Users.csproj` reference (needed for `UserManager`). + +**Step 2: Update AdminUsersEndpoint** + +In `POST /{id}/permissions`, replace `UsersDbContext usersDb` with `IPermissionContracts permissionContracts`: + +```csharp +async (string id, HttpContext context, IPermissionContracts permissionContracts, AuditService audit) => +{ + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + var userId = UserId.From(id); + + var form = await context.Request.ReadFormAsync(); + var newPermissions = form["permissions"] + .Where(p => !string.IsNullOrEmpty(p)) + .Select(p => p!) + .ToHashSet(); + + var currentPermissions = await permissionContracts.GetPermissionsForUserAsync(userId); + + foreach (var perm in currentPermissions.Where(p => !newPermissions.Contains(p))) + { + await audit.LogAsync(id, adminId, "PermissionRevoked", $"Revoked permission {perm}"); + } + foreach (var perm in newPermissions.Where(p => !currentPermissions.Contains(p))) + { + await audit.LogAsync(id, adminId, "PermissionGranted", $"Granted permission {perm}"); + } + + await permissionContracts.SetPermissionsForUserAsync(userId, newPermissions); + + return Results.Redirect($"/admin/users/{id}/edit?tab=roles"); +} +``` + +**Step 3: Update AdminRolesEndpoint** + +Replace all `UsersDbContext usersDb` injections with `IPermissionContracts permissionContracts`. Use `RoleId.From(role.Id)` for typed ID access. + +In `POST /` (create role): +```csharp +// After role creation: +var permissions = form["permissions"] + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(p => p!) + .ToList(); + +if (permissions.Count > 0) +{ + await permissionContracts.SetPermissionsForRoleAsync(RoleId.From(role.Id), permissions); +} +``` + +In `POST /{id}/permissions`: +```csharp +var currentPermissions = await permissionContracts.GetPermissionsForRoleAsync(RoleId.From(id)); +// ... audit diffs ... +await permissionContracts.SetPermissionsForRoleAsync(RoleId.From(id), newPermissions); +``` + +In `DELETE /{id}`: +```csharp +await permissionContracts.SetPermissionsForRoleAsync(RoleId.From(id), []); +``` + +Remove all `using SimpleModule.Users;` imports that referenced `UsersDbContext` and permission entities. Add `using SimpleModule.Permissions.Contracts;` and `using SimpleModule.Users.Contracts;`. + +**Step 4: Update Admin view endpoints if they reference UsersDbContext for permissions** + +Check `modules/Admin/src/Admin/Views/Admin/RolesEndpoint.cs` and `RolesEditEndpoint.cs` — these also query `UsersDbContext.RolePermissions`. Update them to use `IPermissionContracts`. + +**Step 5: Build to verify** + +Run: `dotnet build modules/Admin/src/Admin/Admin.csproj` +Expected: Build succeeded + +**Step 6: Commit** + +```bash +git add modules/Admin/ +git commit -m "refactor(admin): use IPermissionContracts instead of direct UsersDbContext" +``` + +--- + +### Task 8: Update Host Project + +**Files:** +- Modify: `template/SimpleModule.Host/SimpleModule.Host.csproj` — add new module references +- Modify: `template/SimpleModule.Host/Program.cs` — update usings + +**Step 1: Update Host csproj** + +Add: +```xml + + +``` + +**Step 2: Update Program.cs** + +Replace: +```csharp +using SimpleModule.Users.Constants; +``` +With: +```csharp +using SimpleModule.OpenIddict.Contracts; +``` + +The `HostDbContext` `opts => opts.UseOpenIddict()` — evaluate if still needed. If OpenIddict tables are now in `OpenIddictAppDbContext`, the HostDbContext may no longer need `UseOpenIddict()`. However, for EF migrations that need to see all tables, it might still be needed. Check if `HostDbContext` inherits from all module DbContexts or uses a unified migration. Update accordingly. + +**Step 3: Build to verify** + +Run: `dotnet build template/SimpleModule.Host/SimpleModule.Host.csproj` +Expected: Build succeeded + +**Step 4: Commit** + +```bash +git add template/SimpleModule.Host/ +git commit -m "refactor(host): add OpenIddict and Permissions module references" +``` + +--- + +### Task 9: Update Test Infrastructure + +**Files:** +- Modify: `tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs` +- Modify: `tests/SimpleModule.Tests.Shared/SimpleModule.Tests.Shared.csproj` +- Create: `tests/SimpleModule.Tests.Shared/Fakes/FakePermissionContracts.cs` + +**Step 1: Update test factory** + +Add new DbContext replacements: +```csharp +ReplaceDbContext(services); +ReplaceDbContext(services, useOpenIddict: true); +``` + +Update `UsersDbContext` — remove `useOpenIddict: true`: +```csharp +ReplaceDbContext(services); +``` + +Add usings: +```csharp +using SimpleModule.OpenIddict; +using SimpleModule.Permissions; +``` + +**Step 2: Create FakePermissionContracts** + +```csharp +using SimpleModule.Permissions.Contracts; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Tests.Shared.Fakes; + +public class FakePermissionContracts : IPermissionContracts +{ + private readonly Dictionary> _userPermissions = new(); + private readonly Dictionary> _rolePermissions = new(); + + public Task> GetPermissionsForUserAsync(UserId userId) + { + var key = userId.Value; + return Task.FromResult>( + _userPermissions.TryGetValue(key, out var perms) + ? perms + : new HashSet()); + } + + public Task> GetPermissionsForRoleAsync(RoleId roleId) + { + var key = roleId.Value; + return Task.FromResult>( + _rolePermissions.TryGetValue(key, out var perms) + ? perms + : new HashSet()); + } + + public async Task> GetAllPermissionsForUserAsync( + UserId userId, + IEnumerable roleIds) + { + var result = new HashSet(); + + var userPerms = await GetPermissionsForUserAsync(userId); + result.UnionWith(userPerms); + + foreach (var roleId in roleIds) + { + var rolePerms = await GetPermissionsForRoleAsync(roleId); + result.UnionWith(rolePerms); + } + + return result; + } + + public Task SetPermissionsForUserAsync(UserId userId, IEnumerable permissions) + { + _userPermissions[userId.Value] = new HashSet(permissions); + return Task.CompletedTask; + } + + public Task SetPermissionsForRoleAsync(RoleId roleId, IEnumerable permissions) + { + _rolePermissions[roleId.Value] = new HashSet(permissions); + return Task.CompletedTask; + } +} +``` + +**Step 3: Update test csproj** + +Add references to `Permissions.Contracts`, `Permissions`, `OpenIddict`, and `OpenIddict.Contracts`. + +**Step 4: Build tests** + +Run: `dotnet build tests/SimpleModule.Tests.Shared/SimpleModule.Tests.Shared.csproj` +Expected: Build succeeded + +**Step 5: Commit** + +```bash +git add tests/SimpleModule.Tests.Shared/ +git commit -m "test: update test infrastructure for module split" +``` + +--- + +### Task 10: Create OpenIddict Module Tests + +**Files:** +- Create: `modules/OpenIddict/tests/OpenIddict.Tests/OpenIddict.Tests.csproj` +- Create: `modules/OpenIddict/tests/OpenIddict.Tests/Integration/ConnectEndpointTests.cs` +- Create: `modules/OpenIddict/tests/OpenIddict.Tests/Integration/OpenIddictSeedServiceTests.cs` + +**Step 1: Create test project** + +```xml + + + net10.0 + false + true + + + + + + + + + + + + + +``` + +**Step 2: Write Connect endpoint integration tests** + +```csharp +using System.Net; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using SimpleModule.Tests.Shared.Fixtures; + +namespace OpenIddict.Tests.Integration; + +public class ConnectEndpointTests : IClassFixture +{ + private readonly SimpleModuleWebApplicationFactory _factory; + + public ConnectEndpointTests(SimpleModuleWebApplicationFactory factory) => _factory = factory; + + [Fact] + public async Task Authorize_Unauthenticated_Redirects() + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + var response = await client.GetAsync("/connect/authorize?response_type=code&client_id=simplemodule-client&scope=openid&redirect_uri=https://localhost:5001/oauth-callback"); + + // Should redirect to login + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + } + + [Fact] + public async Task Userinfo_Unauthenticated_Returns401() + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + var response = await client.GetAsync("/connect/userinfo"); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Userinfo_Authenticated_Returns200() + { + var client = _factory.CreateAuthenticatedClient(); + + var response = await client.GetAsync("/connect/userinfo"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task EndSession_Returns200OrRedirect() + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + var response = await client.GetAsync("/connect/endsession"); + + // Should either redirect or succeed + response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Redirect); + } +} +``` + +**Step 3: Write seed service tests** + +```csharp +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using OpenIddict.Abstractions; +using SimpleModule.Tests.Shared.Fixtures; + +namespace OpenIddict.Tests.Integration; + +public class OpenIddictSeedServiceTests : IClassFixture +{ + private readonly SimpleModuleWebApplicationFactory _factory; + + public OpenIddictSeedServiceTests(SimpleModuleWebApplicationFactory factory) => _factory = factory; + + [Fact] + public async Task SeedService_CreatesClientApplication() + { + using var scope = _factory.Services.CreateScope(); + var manager = scope.ServiceProvider.GetRequiredService(); + + var client = await manager.FindByClientIdAsync("simplemodule-client"); + + client.Should().NotBeNull(); + } +} +``` + +**Step 4: Add to solution and run** + +```bash +dotnet slnx add modules/OpenIddict/tests/OpenIddict.Tests/OpenIddict.Tests.csproj --solution-folder /modules/OpenIddict/ +dotnet test modules/OpenIddict/tests/OpenIddict.Tests/ +``` +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add modules/OpenIddict/tests/ SimpleModule.slnx +git commit -m "test(openiddict): add integration tests for Connect endpoints and seed service" +``` + +--- + +### Task 11: Update Existing Admin Tests + +**Files:** +- Modify: `modules/Admin/tests/Admin.Tests/Integration/AdminUsersEndpointTests.cs` — remove `UsersDbContext` references if any +- Modify: `modules/Admin/tests/Admin.Tests/Integration/AdminRolesEndpointTests.cs` — update imports + +**Step 1: Update test imports** + +Replace any `using SimpleModule.Users;` (for `UsersDbContext`) with `using SimpleModule.Permissions.Contracts;` where applicable. + +**Step 2: Run existing admin tests** + +Run: `dotnet test modules/Admin/tests/Admin.Tests/` +Expected: All tests pass + +**Step 3: Commit if changes needed** + +```bash +git add modules/Admin/tests/ +git commit -m "test(admin): update test imports for module split" +``` + +--- + +### Task 12: Add Integration Tests for Permissions in Admin Context + +**Files:** +- Create: `modules/Admin/tests/Admin.Tests/Integration/AdminPermissionsTests.cs` + +**Step 1: Write tests for permission assignment via Admin endpoints** + +```csharp +using System.Net; +using System.Security.Claims; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Permissions.Contracts; +using SimpleModule.Tests.Shared.Fixtures; +using SimpleModule.Users.Contracts; +using SimpleModule.Users.Entities; + +namespace Admin.Tests.Integration; + +public class AdminPermissionsTests : IClassFixture +{ + private readonly SimpleModuleWebApplicationFactory _factory; + + public AdminPermissionsTests(SimpleModuleWebApplicationFactory factory) => _factory = factory; + + private HttpClient CreateAdminClient() + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + var claims = new List + { + new(ClaimTypes.Role, "Admin"), + new(ClaimTypes.NameIdentifier, "admin-test-id"), + }; + var claimsValue = string.Join(";", claims.Select(c => $"{c.Type}={c.Value}")); + client.DefaultRequestHeaders.Add("X-Test-Claims", claimsValue); + client.DefaultRequestHeaders.Add("Authorization", "Bearer test-token"); + return client; + } + + [Fact] + public async Task SetUserPermissions_ValidData_Redirects() + { + // Seed a test user + using var scope = _factory.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = new ApplicationUser + { + UserName = $"perm-test-{Guid.NewGuid():N}@test.com", + Email = $"perm-test-{Guid.NewGuid():N}@test.com", + DisplayName = "Permission Test User", + }; + await userManager.CreateAsync(user, "TestPass123!"); + + var client = CreateAdminClient(); + using var content = new FormUrlEncodedContent(new Dictionary + { + { "permissions", "Admin.ManageUsers" }, + }); + + var response = await client.PostAsync($"/admin/users/{user.Id}/permissions", content); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + } + + [Fact] + public async Task SetRolePermissions_ValidData_Redirects() + { + using var scope = _factory.Services.CreateScope(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + var role = new ApplicationRole + { + Name = $"PermTestRole-{Guid.NewGuid().ToString()[..8]}", + Description = "Test role for permissions", + }; + await roleManager.CreateAsync(role); + + var client = CreateAdminClient(); + using var content = new FormUrlEncodedContent(new Dictionary + { + { "permissions", "Admin.ManageUsers" }, + }); + + var response = await client.PostAsync($"/admin/roles/{role.Id}/permissions", content); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + } + + [Fact] + public async Task SetUserPermissions_NonExistentUser_Redirects() + { + var client = CreateAdminClient(); + using var content = new FormUrlEncodedContent(new Dictionary + { + { "permissions", "Admin.ManageUsers" }, + }); + + // The endpoint doesn't currently check user existence — it just sets permissions + // This test verifies the endpoint works with a non-existent user ID + var response = await client.PostAsync("/admin/users/nonexistent-id/permissions", content); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + } + + [Fact] + public async Task CreateRoleWithPermissions_ValidData_CreatesRoleAndAssignsPermissions() + { + var client = CreateAdminClient(); + var roleName = $"WithPerms-{Guid.NewGuid().ToString()[..8]}"; + + using var content = new FormUrlEncodedContent(new[] + { + new KeyValuePair("name", roleName), + new KeyValuePair("description", "Test"), + new KeyValuePair("permissions", "Admin.ManageUsers"), + new KeyValuePair("permissions", "Admin.ViewAuditLog"), + }); + + var response = await client.PostAsync("/admin/roles", content); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + + // Verify permissions were assigned via IPermissionContracts + using var scope = _factory.Services.CreateScope(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + var role = await roleManager.FindByNameAsync(roleName); + role.Should().NotBeNull(); + + var permContracts = scope.ServiceProvider.GetRequiredService(); + var perms = await permContracts.GetPermissionsForRoleAsync(RoleId.From(role!.Id)); + perms.Should().HaveCount(2); + perms.Should().Contain("Admin.ManageUsers"); + perms.Should().Contain("Admin.ViewAuditLog"); + } + + [Fact] + public async Task DeleteRole_ClearsPermissions() + { + // Create role with permissions + using var scope = _factory.Services.CreateScope(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + var role = new ApplicationRole + { + Name = $"DelPermRole-{Guid.NewGuid().ToString()[..8]}", + Description = "To be deleted", + }; + await roleManager.CreateAsync(role); + + var permContracts = scope.ServiceProvider.GetRequiredService(); + await permContracts.SetPermissionsForRoleAsync( + RoleId.From(role.Id), + ["Admin.ManageUsers", "Admin.ManageRoles"]); + + var client = CreateAdminClient(); + var response = await client.DeleteAsync($"/admin/roles/{role.Id}"); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + + // Verify permissions were cleared + using var scope2 = _factory.Services.CreateScope(); + var permContracts2 = scope2.ServiceProvider.GetRequiredService(); + var perms = await permContracts2.GetPermissionsForRoleAsync(RoleId.From(role.Id)); + perms.Should().BeEmpty(); + } +} +``` + +**Step 2: Run all admin tests** + +Run: `dotnet test modules/Admin/tests/Admin.Tests/` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add modules/Admin/tests/ +git commit -m "test(admin): add integration tests for permission operations via IPermissionContracts" +``` + +--- + +### Task 13: Final Verification + +**Step 1: Full build** + +Run: `dotnet build` +Expected: Build succeeded with zero warnings + +**Step 2: Full test suite** + +Run: `dotnet test` +Expected: All tests pass + +**Step 3: Lint frontend (if any changes)** + +Run: `npm run check` +Expected: No lint errors + +**Step 4: Run application end-to-end** + +Run: `dotnet run --project template/SimpleModule.Host` +Verify: +- App starts on https://localhost:5001 +- Swagger UI loads with OAuth2 config +- OpenIddict client is seeded +- Admin role permissions are seeded +- Can authenticate and get permissions in token + +**Step 5: Final commit if needed** + +```bash +git add -A +git commit -m "chore: final cleanup after OpenIddict/Permissions module split" +``` diff --git a/docs/plans/2026-03-19-openiddict-tables-and-ui-design.md b/docs/plans/2026-03-19-openiddict-tables-and-ui-design.md new file mode 100644 index 00000000..cc922ea5 --- /dev/null +++ b/docs/plans/2026-03-19-openiddict-tables-and-ui-design.md @@ -0,0 +1,46 @@ +# OpenIddict Table Ownership + Client Management UI + +**Date**: 2026-03-19 +**Branch**: feature/permission-system +**Goal**: Fix OpenIddict table prefixes and add a client management UI to the OpenIddict module. + +## 1. Fix OpenIddict Table Prefixes + +The source generator's HostDbContext emitter currently assigns unprefixed entities to `Users_` schema. OpenIddict entities need their own `OpenIddict_` prefix (SQLite) or `openiddict` schema (PostgreSQL/SQL Server). + +- Update the source generator to detect OpenIddict entity types and prefix them with `OpenIddict_` +- Reset the migration after the fix + +## 2. Client Management UI + +**Module:** OpenIddict (at `/openiddict/clients`) + +### View Endpoints (IViewEndpoint) +- `GET /openiddict/clients` — list all registered clients +- `GET /openiddict/clients/create` — create form +- `GET /openiddict/clients/{id}/edit` — edit form with tabbed sections + +### Action Endpoints (IEndpoint) +- `POST /openiddict/clients` — create client +- `POST /openiddict/clients/{id}` — update client details +- `DELETE /openiddict/clients/{id}` — delete client + +### React Pages +- `ClientsPage` — table listing all clients (ID, display name, client type, grant types) +- `ClientsCreatePage` — form: client ID, display name, client type (public/confidential), secret (for confidential), redirect URIs, post-logout URIs, grant types (checkboxes), scopes (checkboxes) +- `ClientsEditPage` — tabbed edit: Details tab, URIs tab, Permissions tab + +### Authorization +Require Admin role (same as existing admin pages). + +### Data Access +Use `IOpenIddictApplicationManager` from OpenIddict's built-in DI — no direct DB queries. + +### Permissions +Add `OpenIddictPermissions.ManageClients` to the OpenIddict module's `ConfigurePermissions`. + +## Dependency Graph (unchanged) + +``` +OpenIddict ──→ Users.csproj + Permissions.Contracts +``` diff --git a/docs/plans/2026-03-19-openiddict-tables-and-ui-plan.md b/docs/plans/2026-03-19-openiddict-tables-and-ui-plan.md new file mode 100644 index 00000000..6d63b6d0 --- /dev/null +++ b/docs/plans/2026-03-19-openiddict-tables-and-ui-plan.md @@ -0,0 +1,1071 @@ +# OpenIddict Table Ownership + Client Management UI — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fix OpenIddict table prefixes to use `OpenIddict_` instead of `Users_`, and add a full CRUD UI for managing OAuth2 clients in the OpenIddict module. + +**Architecture:** Update the source generator's HostDbContextEmitter to recognize OpenIddict entities (added implicitly by `UseOpenIddict()`) and assign them the correct module prefix. Then add view endpoints, action endpoints, and React pages to the OpenIddict module following the exact Admin module patterns. + +**Tech Stack:** .NET 10, Roslyn source generators, OpenIddict EF Core, React 19, Inertia.js, Vite (library mode), @simplemodule/ui components. + +--- + +### Task 1: Fix Source Generator — OpenIddict Table Prefix + +**Files:** +- Modify: `framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs` + +The Identity table prefix loop (lines 240-268) catches all "unprefixed" entities — including OpenIddict entities — and assigns them `Users_`. OpenIddict entities are added implicitly by `UseOpenIddict()` on the HostDbContext options, not via DbSet properties. + +The fix: the generator already emits `alreadyPrefixed` checks for each module. OpenIddict is already listed (line 100 in generated output: `if (tableName.StartsWith("OpenIddict_", ...)) alreadyPrefixed = true;`). But since OpenIddict entities have no DbSet in ANY module's DbContext, they never get their own prefix — they just get caught by the fallback Identity prefix. + +**Solution:** After the Identity table prefix loop, add an explicit prefix pass for any remaining "OpenIddict*" tables. In the emitter, after the `alreadyPrefixed` block and before closing the `else` block, emit code that re-prefixes any Identity-prefixed OpenIddict tables: + +Actually, the cleaner approach: In the Identity table prefix loop, detect entities whose table name contains "OpenIddict" and prefix them with `OpenIddict_` instead of the Identity prefix. Modify lines 240-268 in the emitter. + +**Step 1: Modify HostDbContextEmitter.cs** + +In the Identity table prefix section (SQLite), change the logic to: +1. If the table name starts with "OpenIddict", prefix with `OpenIddict_` (not the Identity prefix) +2. Otherwise, prefix with the Identity module prefix as before + +Replace the Identity table prefix loop code (lines 240-268) with: + +```csharp +// For Identity and OpenIddict tables in SQLite, prefix them appropriately +if (identityContext != null) +{ + var identityPrefix = identityContext.Value.ModuleName + "_"; + sb.AppendLine(); + sb.AppendLine(" // Prefix Identity and OpenIddict tables for SQLite"); + sb.AppendLine(" foreach (var entityType in builder.Model.GetEntityTypes())"); + sb.AppendLine(" {"); + sb.AppendLine(" var tableName = entityType.GetTableName();"); + sb.AppendLine( + $" if (tableName is not null && !tableName.StartsWith(\"{identityPrefix}\", global::System.StringComparison.Ordinal))" + ); + + sb.AppendLine(" {"); + sb.AppendLine(" var alreadyPrefixed = false;"); + foreach (var ctx2 in data.DbContexts) + { + if (ctx2.FullyQualifiedName == identityContext.Value.FullyQualifiedName) + continue; + sb.AppendLine( + $" if (tableName.StartsWith(\"{ctx2.ModuleName}_\", global::System.StringComparison.Ordinal)) alreadyPrefixed = true;" + ); + } + + // OpenIddict tables get their own prefix instead of being lumped with Identity + sb.AppendLine( + " if (!alreadyPrefixed && tableName.StartsWith(\"OpenIddict\", global::System.StringComparison.Ordinal))"); + sb.AppendLine(" {"); + sb.AppendLine(" entityType.SetTableName(\"OpenIddict_\" + tableName);"); + sb.AppendLine(" alreadyPrefixed = true;"); + sb.AppendLine(" }"); + + sb.AppendLine( + $" if (!alreadyPrefixed) entityType.SetTableName(\"{identityPrefix}\" + tableName);" + ); + sb.AppendLine(" }"); + sb.AppendLine(" }"); +} +``` + +Also do the same for the non-SQLite schema section — add OpenIddict schema assignment. After the Identity schema loop (lines 203-215), add: + +```csharp +sb.AppendLine(); +sb.AppendLine(" // Assign OpenIddict tables to the openiddict schema"); +sb.AppendLine(" foreach (var entityType in builder.Model.GetEntityTypes())"); +sb.AppendLine(" {"); +sb.AppendLine(" var tableName = entityType.GetTableName();"); +sb.AppendLine(" if (tableName is not null && tableName.StartsWith(\"OpenIddict\", global::System.StringComparison.Ordinal))"); +sb.AppendLine(" entityType.SetSchema(\"openiddict\");"); +sb.AppendLine(" }"); +``` + +**Step 2: Build and verify** + +Run: `dotnet build` +Expected: 0 warnings, 0 errors. + +Check the generated file: +```bash +cat template/SimpleModule.Host/obj/Debug/net10.0/generated/SimpleModule.Generator/SimpleModule.Generator.ModuleDiscovererGenerator/HostDbContext.g.cs +``` +Verify OpenIddict tables get `OpenIddict_` prefix. + +**Step 3: Update generator tests** + +Update any tests in `tests/SimpleModule.Generator.Tests/` that verify the generated HostDbContext table prefixes. The `EndpointExtensionsEmitterTests` don't test this — check if there are `HostDbContextEmitterTests`. + +**Step 4: Reset migration** + +```bash +rm template/SimpleModule.Host/Migrations/* +rm template/SimpleModule.Host/app.db +dotnet ef migrations add InitialCreate --project template/SimpleModule.Host --context HostDbContext +``` + +Verify the migration creates tables with `OpenIddict_` prefix (e.g., `OpenIddict_OpenIddictApplications`). + +**Step 5: Verify app starts** + +```bash +dotnet run --project template/SimpleModule.Host --no-launch-profile --urls "https://localhost:5001" +``` +Check health: `curl -sk https://localhost:5001/health/live` + +**Step 6: Commit** + +```bash +git add -A +git commit -m "fix(generator): assign OpenIddict tables their own schema/prefix" +``` + +--- + +### Task 2: Add OpenIddict Permissions and Menu + +**Files:** +- Create: `modules/OpenIddict/src/OpenIddict.Contracts/OpenIddictPermissions.cs` +- Modify: `modules/OpenIddict/src/OpenIddict/OpenIddictModule.cs` — add ConfigurePermissions and ConfigureMenu + +**Step 1: Create OpenIddictPermissions** + +```csharp +namespace SimpleModule.OpenIddict.Contracts; + +public sealed class OpenIddictPermissions +{ + public const string ManageClients = "OpenIddict.ManageClients"; +} +``` + +**Step 2: Update OpenIddictModule** + +Add `ConfigurePermissions` and `ConfigureMenu`: + +```csharp +public void ConfigurePermissions(PermissionRegistryBuilder builder) +{ + builder.AddPermissions(); +} + +public void ConfigureMenu(IMenuBuilder menus) +{ + menus.Add(new MenuItem + { + Label = "OAuth Clients", + Url = "/openiddict/clients", + Icon = """""", + Order = 22, + Section = MenuSection.Navbar, + }); +} +``` + +Add required usings: `SimpleModule.Core.Authorization`, `SimpleModule.Core.Menu`, `SimpleModule.OpenIddict.Contracts`. + +**Step 3: Build and commit** + +```bash +dotnet build modules/OpenIddict/src/OpenIddict/OpenIddict.csproj +git add modules/OpenIddict/ +git commit -m "feat(openiddict): add ManageClients permission and menu item" +``` + +--- + +### Task 3: Create Client View Endpoints + +**Files:** +- Create: `modules/OpenIddict/src/OpenIddict/Views/OpenIddict/ClientsEndpoint.cs` +- Create: `modules/OpenIddict/src/OpenIddict/Views/OpenIddict/ClientsCreateEndpoint.cs` +- Create: `modules/OpenIddict/src/OpenIddict/Views/OpenIddict/ClientsEditEndpoint.cs` + +Follow the Admin module's `RolesEndpoint`/`RolesCreateEndpoint`/`RolesEditEndpoint` pattern exactly. + +**Step 1: Create ClientsEndpoint (list page)** + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using OpenIddict.Abstractions; +using SimpleModule.Core; +using SimpleModule.Core.Inertia; + +namespace SimpleModule.OpenIddict.Views.OpenIddict; + +public class ClientsEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/openiddict/clients", + async (IOpenIddictApplicationManager manager) => + { + var clients = new List(); + + await foreach (var app in manager.ListAsync()) + { + clients.Add(new + { + id = await manager.GetIdAsync(app), + clientId = await manager.GetClientIdAsync(app), + displayName = await manager.GetDisplayNameAsync(app), + clientType = await manager.GetClientTypeAsync(app), + }); + } + + return Inertia.Render("OpenIddict/OpenIddict/Clients", new { clients }); + } + ).RequireAuthorization(policy => policy.RequireRole("Admin")); + } +} +``` + +**Step 2: Create ClientsCreateEndpoint** + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Inertia; + +namespace SimpleModule.OpenIddict.Views.OpenIddict; + +public class ClientsCreateEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/openiddict/clients/create", + () => Inertia.Render("OpenIddict/OpenIddict/ClientsCreate", new { }) + ).RequireAuthorization(policy => policy.RequireRole("Admin")); + } +} +``` + +**Step 3: Create ClientsEditEndpoint** + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using OpenIddict.Abstractions; +using SimpleModule.Core; +using SimpleModule.Core.Inertia; + +namespace SimpleModule.OpenIddict.Views.OpenIddict; + +public class ClientsEditEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/openiddict/clients/{id}/edit", + async (string id, IOpenIddictApplicationManager manager, string? tab) => + { + var application = await manager.FindByIdAsync(id); + if (application is null) + return Results.NotFound(); + + var redirectUris = new List(); + await foreach (var uri in manager.GetRedirectUrisAsync(application)) + redirectUris.Add(uri); + + var postLogoutUris = new List(); + await foreach (var uri in manager.GetPostLogoutRedirectUrisAsync(application)) + postLogoutUris.Add(uri); + + var permissions = new List(); + await foreach (var perm in manager.GetPermissionsAsync(application)) + permissions.Add(perm); + + return Inertia.Render("OpenIddict/OpenIddict/ClientsEdit", new + { + client = new + { + id = await manager.GetIdAsync(application), + clientId = await manager.GetClientIdAsync(application), + displayName = await manager.GetDisplayNameAsync(application), + clientType = await manager.GetClientTypeAsync(application), + }, + redirectUris, + postLogoutUris, + permissions, + tab = tab ?? "details", + }); + } + ).RequireAuthorization(policy => policy.RequireRole("Admin")); + } +} +``` + +Note: `IOpenIddictApplicationManager.GetRedirectUrisAsync`, `GetPostLogoutRedirectUrisAsync`, and `GetPermissionsAsync` return `IAsyncEnumerable`. Check the actual API — if they return different types, adjust accordingly. Read the OpenIddict source or use context7 to verify. + +**Step 4: Build and commit** + +```bash +dotnet build modules/OpenIddict/src/OpenIddict/OpenIddict.csproj +git add modules/OpenIddict/src/OpenIddict/Views/ +git commit -m "feat(openiddict): add client management view endpoints" +``` + +--- + +### Task 4: Create Client Action Endpoints + +**Files:** +- Create: `modules/OpenIddict/src/OpenIddict/Endpoints/OpenIddict/ClientsActionEndpoint.cs` + +**Step 1: Create action endpoints** + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using OpenIddict.Abstractions; +using SimpleModule.Core; + +namespace SimpleModule.OpenIddict.Endpoints.OpenIddict; + +public class ClientsActionEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + var group = app.MapGroup("/openiddict/clients") + .RequireAuthorization(policy => policy.RequireRole("Admin")) + .DisableAntiforgery(); + + // POST / — Create client + group.MapPost("/", async ( + [FromForm] string clientId, + [FromForm] string displayName, + [FromForm] string clientType, + [FromForm] string? clientSecret, + HttpContext context, + IOpenIddictApplicationManager manager + ) => + { + var descriptor = new OpenIddictApplicationDescriptor + { + ClientId = clientId.Trim(), + DisplayName = displayName.Trim(), + ClientType = clientType, + }; + + if (clientType == OpenIddictConstants.ClientTypes.Confidential + && !string.IsNullOrWhiteSpace(clientSecret)) + { + descriptor.ClientSecret = clientSecret; + } + + // Parse redirect URIs from form + var form = await context.Request.ReadFormAsync(); + foreach (var uri in form["redirectUris"].Where(u => !string.IsNullOrWhiteSpace(u))) + { + descriptor.RedirectUris.Add(new Uri(uri!)); + } + foreach (var uri in form["postLogoutUris"].Where(u => !string.IsNullOrWhiteSpace(u))) + { + descriptor.PostLogoutRedirectUris.Add(new Uri(uri!)); + } + + // Parse permissions (grant types, scopes, endpoints) + foreach (var perm in form["permissions"].Where(p => !string.IsNullOrWhiteSpace(p))) + { + descriptor.Permissions.Add(perm!); + } + + var application = await manager.CreateAsync(descriptor); + var id = await manager.GetIdAsync(application); + + return Results.Redirect($"/openiddict/clients/{id}/edit"); + }); + + // POST /{id} — Update client details + group.MapPost("/{id}", async ( + string id, + [FromForm] string displayName, + [FromForm] string clientType, + IOpenIddictApplicationManager manager + ) => + { + var application = await manager.FindByIdAsync(id); + if (application is null) + return Results.NotFound(); + + var descriptor = new OpenIddictApplicationDescriptor(); + await manager.PopulateAsync(descriptor, application); + + descriptor.DisplayName = displayName.Trim(); + descriptor.ClientType = clientType; + + await manager.UpdateAsync(application, descriptor); + + return Results.Redirect($"/openiddict/clients/{id}/edit?tab=details"); + }); + + // POST /{id}/uris — Update redirect URIs + group.MapPost("/{id}/uris", async ( + string id, + HttpContext context, + IOpenIddictApplicationManager manager + ) => + { + var application = await manager.FindByIdAsync(id); + if (application is null) + return Results.NotFound(); + + var descriptor = new OpenIddictApplicationDescriptor(); + await manager.PopulateAsync(descriptor, application); + + descriptor.RedirectUris.Clear(); + descriptor.PostLogoutRedirectUris.Clear(); + + var form = await context.Request.ReadFormAsync(); + foreach (var uri in form["redirectUris"].Where(u => !string.IsNullOrWhiteSpace(u))) + { + descriptor.RedirectUris.Add(new Uri(uri!)); + } + foreach (var uri in form["postLogoutUris"].Where(u => !string.IsNullOrWhiteSpace(u))) + { + descriptor.PostLogoutRedirectUris.Add(new Uri(uri!)); + } + + await manager.UpdateAsync(application, descriptor); + + return Results.Redirect($"/openiddict/clients/{id}/edit?tab=uris"); + }); + + // POST /{id}/permissions — Update permissions + group.MapPost("/{id}/permissions", async ( + string id, + HttpContext context, + IOpenIddictApplicationManager manager + ) => + { + var application = await manager.FindByIdAsync(id); + if (application is null) + return Results.NotFound(); + + var descriptor = new OpenIddictApplicationDescriptor(); + await manager.PopulateAsync(descriptor, application); + + descriptor.Permissions.Clear(); + + var form = await context.Request.ReadFormAsync(); + foreach (var perm in form["permissions"].Where(p => !string.IsNullOrWhiteSpace(p))) + { + descriptor.Permissions.Add(perm!); + } + + await manager.UpdateAsync(application, descriptor); + + return Results.Redirect($"/openiddict/clients/{id}/edit?tab=permissions"); + }); + + // DELETE /{id} — Delete client + group.MapDelete("/{id}", async ( + string id, + IOpenIddictApplicationManager manager + ) => + { + var application = await manager.FindByIdAsync(id); + if (application is null) + return Results.NotFound(); + + await manager.DeleteAsync(application); + + return Results.Redirect("/openiddict/clients"); + }); + } +} +``` + +**Step 2: Build and commit** + +```bash +dotnet build modules/OpenIddict/src/OpenIddict/OpenIddict.csproj +git add modules/OpenIddict/src/OpenIddict/Endpoints/ +git commit -m "feat(openiddict): add client CRUD action endpoints" +``` + +--- + +### Task 5: Create React Pages for Client Management + +**Files:** +- Create: `modules/OpenIddict/src/OpenIddict/Pages/index.ts` +- Create: `modules/OpenIddict/src/OpenIddict/Pages/OpenIddict/Clients.tsx` +- Create: `modules/OpenIddict/src/OpenIddict/Pages/OpenIddict/ClientsCreate.tsx` +- Create: `modules/OpenIddict/src/OpenIddict/Pages/OpenIddict/ClientsEdit.tsx` +- Create: `modules/OpenIddict/src/OpenIddict/package.json` +- Create: `modules/OpenIddict/src/OpenIddict/vite.config.ts` +- Create: `modules/OpenIddict/src/OpenIddict/tsconfig.json` + +Follow the Admin module's pattern exactly for all files. + +**Step 1: Create package.json** + +```json +{ + "name": "@simplemodule/openiddict", + "private": true, + "type": "module", + "scripts": { + "build": "vite build" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} +``` + +**Step 2: Create vite.config.ts** + +```typescript +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + build: { + lib: { + entry: 'Pages/index.ts', + formats: ['es'], + fileName: () => 'OpenIddict.pages.js', + }, + outDir: 'wwwroot', + rollupOptions: { + external: ['react', 'react-dom', 'react/jsx-runtime', '@inertiajs/react'], + }, + }, +}); +``` + +**Step 3: Create tsconfig.json** + +```json +{ + "extends": "../../../../tsconfig.json", + "include": ["Pages/**/*"] +} +``` + +Check if there's a root tsconfig.json to extend. If not, create a standalone one matching the Admin module's pattern. + +**Step 4: Create Pages/index.ts** + +```typescript +import Clients from './OpenIddict/Clients'; +import ClientsCreate from './OpenIddict/ClientsCreate'; +import ClientsEdit from './OpenIddict/ClientsEdit'; + +export const pages: Record = { + 'OpenIddict/OpenIddict/Clients': Clients, + 'OpenIddict/OpenIddict/ClientsCreate': ClientsCreate, + 'OpenIddict/OpenIddict/ClientsEdit': ClientsEdit, +}; +``` + +**Step 5: Create Clients.tsx (list page)** + +Follow the `Roles.tsx` pattern: + +```tsx +import { router } from '@inertiajs/react'; +import { + Badge, + Button, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@simplemodule/ui'; + +interface Client { + id: string; + clientId: string; + displayName: string | null; + clientType: string | null; +} + +interface Props { + clients: Client[]; +} + +export default function Clients({ clients }: Props) { + function handleDelete(id: string, clientId: string) { + if (!confirm(`Delete client "${clientId}"?`)) return; + router.delete(`/openiddict/clients/${id}`); + } + + return ( +
+
+
+

OAuth Clients

+

{clients.length} registered clients

+
+ +
+ + + + + Client ID + Display Name + Type + + + + + {clients.map((client) => ( + + {client.clientId} + {client.displayName || '\u2014'} + + + {client.clientType || 'unknown'} + + + +
+ + +
+
+
+ ))} +
+
+
+ ); +} +``` + +**Step 6: Create ClientsCreate.tsx** + +```tsx +import { router } from '@inertiajs/react'; +import { Button, Card, CardContent, Input, Label } from '@simplemodule/ui'; +import { useState } from 'react'; + +export default function ClientsCreate() { + const [clientType, setClientType] = useState('public'); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + router.post('/openiddict/clients', new FormData(e.currentTarget)); + } + + return ( +
+
+ + + + + +

Create Client

+
+

Register a new OAuth2/OIDC client application

+ + + +
+
+ + +
+
+ + +
+
+ + +
+ {clientType === 'confidential' && ( +
+ + +
+ )} + +
+
+
+
+ ); +} +``` + +**Step 7: Create ClientsEdit.tsx** + +Follow `RolesEdit.tsx` pattern with 3 tabs (Details, URIs, Permissions): + +```tsx +import { router } from '@inertiajs/react'; +import { + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Input, + Label, +} from '@simplemodule/ui'; +import { useState } from 'react'; + +interface ClientDetail { + id: string; + clientId: string; + displayName: string | null; + clientType: string | null; +} + +interface Props { + client: ClientDetail; + redirectUris: string[]; + postLogoutUris: string[]; + permissions: string[]; + tab: string; +} + +function TabNav({ tabs, activeTab, baseUrl }: { tabs: { id: string; label: string }[]; activeTab: string; baseUrl: string }) { + return ( +
+ {tabs.map((t) => ( + + ))} +
+ ); +} + +const tabs = [ + { id: 'details', label: 'Details' }, + { id: 'uris', label: 'Redirect URIs' }, + { id: 'permissions', label: 'Permissions' }, +]; + +export default function ClientsEdit({ client, redirectUris, postLogoutUris, permissions, tab }: Props) { + return ( +
+
+ + + + + +

Edit Client

+
+

+ Client ID: {client.clientId} +

+ + + + {tab === 'details' && } + {tab === 'uris' && } + {tab === 'permissions' && } +
+ ); +} + +function DetailsTab({ client }: { client: ClientDetail }) { + return ( + + +
{ + e.preventDefault(); + router.post(`/openiddict/clients/${client.id}`, new FormData(e.currentTarget)); + }} + className="space-y-4" + > +
+ + +
+
+ + +
+ +
+
+
+ ); +} + +function UrisTab({ clientId, redirectUris, postLogoutUris }: { clientId: string; redirectUris: string[]; postLogoutUris: string[] }) { + const [redirectList, setRedirectList] = useState(redirectUris.length > 0 ? redirectUris : ['']); + const [postLogoutList, setPostLogoutList] = useState(postLogoutUris.length > 0 ? postLogoutUris : ['']); + + return ( + + + Redirect URIs + + +
{ + e.preventDefault(); + router.post(`/openiddict/clients/${clientId}/uris`, new FormData(e.currentTarget)); + }} + className="space-y-6" + > +
+ + {redirectList.map((uri, i) => ( +
+ + {redirectList.length > 1 && ( + + )} +
+ ))} + +
+ +
+ + {postLogoutList.map((uri, i) => ( +
+ + {postLogoutList.length > 1 && ( + + )} +
+ ))} + +
+ + +
+
+
+ ); +} + +function PermissionsTab({ clientId, permissions }: { clientId: string; permissions: string[] }) { + const allPermissions = [ + { group: 'Endpoints', items: ['ept:authorization', 'ept:token', 'ept:end_session', 'ept:revocation', 'ept:introspection'] }, + { group: 'Grant Types', items: ['gt:authorization_code', 'gt:refresh_token', 'gt:client_credentials', 'gt:implicit'] }, + { group: 'Response Types', items: ['rst:code', 'rst:token'] }, + { group: 'Scopes', items: ['scp:openid', 'scp:profile', 'scp:email', 'scp:roles'] }, + ]; + + return ( + + + Client Permissions + + +
{ + e.preventDefault(); + router.post(`/openiddict/clients/${clientId}/permissions`, new FormData(e.currentTarget)); + }} + className="space-y-6" + > + {allPermissions.map((group) => ( +
+

{group.group}

+
+ {group.items.map((perm) => ( + + ))} +
+
+ ))} + +
+
+
+ ); +} +``` + +Note: The permission values use OpenIddict's internal constants (e.g., `ept:authorization` = `OpenIddictConstants.Permissions.Endpoints.Authorization`). Check the actual OpenIddict permission string format — the view endpoint should map them so the React code uses the actual strings. + +**Step 8: Update OpenIddict.csproj** + +Add the Vite build target (same as Admin module): + +```xml + +``` + +Change the SDK from `Microsoft.NET.Sdk` to `Microsoft.NET.Sdk.Razor` and add the JsBuild target: + +```xml + + + +``` + +**Step 9: Install dependencies and build** + +```bash +npm install +npm run check +dotnet build modules/OpenIddict/src/OpenIddict/OpenIddict.csproj +``` + +**Step 10: Commit** + +```bash +git add modules/OpenIddict/src/OpenIddict/Pages/ modules/OpenIddict/src/OpenIddict/package.json modules/OpenIddict/src/OpenIddict/vite.config.ts modules/OpenIddict/src/OpenIddict/tsconfig.json +git commit -m "feat(openiddict): add React pages for client management" +``` + +--- + +### Task 6: Integration Tests + +**Files:** +- Modify: `modules/OpenIddict/tests/OpenIddict.Tests/Integration/ConnectEndpointTests.cs` — add client management tests + +**Step 1: Add client CRUD integration tests** + +Add tests to verify: +- `GET /openiddict/clients` — returns 200 for admin +- `GET /openiddict/clients` — returns 401 for unauthenticated +- `POST /openiddict/clients` — creates client, redirects +- `GET /openiddict/clients/{id}/edit` — returns 200 for existing client +- `DELETE /openiddict/clients/{id}` — deletes client, redirects + +**Step 2: Run tests** + +```bash +dotnet test modules/OpenIddict/tests/OpenIddict.Tests/ +``` + +**Step 3: Commit** + +```bash +git add modules/OpenIddict/tests/ +git commit -m "test(openiddict): add client management integration tests" +``` + +--- + +### Task 7: Final Verification + +**Step 1: Full build** + +Run: `dotnet build` +Expected: 0 warnings, 0 errors. + +**Step 2: Full test suite** + +Run: `dotnet test` +Expected: All tests pass. + +**Step 3: Reset migration (if not done in Task 1)** + +```bash +rm template/SimpleModule.Host/Migrations/* +rm template/SimpleModule.Host/app.db +dotnet ef migrations add InitialCreate --project template/SimpleModule.Host --context HostDbContext +``` + +Verify OpenIddict tables use `OpenIddict_` prefix. + +**Step 4: Run app and verify with Playwright** + +```bash +dotnet run --project template/SimpleModule.Host +``` + +Verify: +- Navigate to `/openiddict/clients` — see the seeded client +- Click Create — form works +- Click Edit — tabbed view loads +- Delete works + +**Step 5: Commit** + +```bash +git add -A +git commit -m "chore: final cleanup for OpenIddict table prefix and client UI" +``` diff --git a/framework/SimpleModule.Blazor/Components/Layout/AppLayout.razor b/framework/SimpleModule.Blazor/Components/Layout/AppLayout.razor new file mode 100644 index 00000000..d8236d5c --- /dev/null +++ b/framework/SimpleModule.Blazor/Components/Layout/AppLayout.razor @@ -0,0 +1,128 @@ +@using SimpleModule.Core.Menu +@inherits LayoutComponentBase +@inject IMenuRegistry MenuRegistry + +
+ + + + + +
+
+ @Body +
+
+
+ + + + +@code { + private static readonly string CacheBuster = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + + [CascadingParameter] + public HttpContext? HttpContext { get; set; } + + private string DisplayName => HttpContext?.User?.Identity?.Name ?? "User"; + private string UserEmail => HttpContext?.User?.Identity?.Name ?? ""; + private string UserInitial => (HttpContext?.User?.Identity?.Name ?? "U").Substring(0, 1).ToUpper(); + + private bool IsAdmin => HttpContext?.User?.IsInRole("Admin") == true; + + private IReadOnlyList SidebarItems => MenuRegistry.GetItems(MenuSection.AppSidebar); + private IReadOnlyList AdminSidebarItems => MenuRegistry.GetItems(MenuSection.AdminSidebar); + + private string SidebarLinkClass(string url) + { + var currentPath = HttpContext?.Request.Path.ToString() ?? ""; + var isActive = url == "/" + ? currentPath.Equals("/", StringComparison.OrdinalIgnoreCase) + : currentPath.StartsWith(url, StringComparison.OrdinalIgnoreCase); + return isActive + ? "flex items-center gap-3 px-3 py-2 rounded-xl text-sm font-medium text-primary bg-primary-subtle no-underline transition-all duration-150" + : "flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-text-secondary no-underline hover:bg-surface-raised hover:text-text transition-all duration-150"; + } +} diff --git a/framework/SimpleModule.Blazor/Components/Layout/MainLayout.razor b/framework/SimpleModule.Blazor/Components/Layout/MainLayout.razor index 84606d04..299a4645 100644 --- a/framework/SimpleModule.Blazor/Components/Layout/MainLayout.razor +++ b/framework/SimpleModule.Blazor/Components/Layout/MainLayout.razor @@ -2,7 +2,7 @@ @inherits LayoutComponentBase -
+
@Body
diff --git a/framework/SimpleModule.Blazor/Components/Layout/ManageLayout.razor b/framework/SimpleModule.Blazor/Components/Layout/ManageLayout.razor index aacf0027..947202e7 100644 --- a/framework/SimpleModule.Blazor/Components/Layout/ManageLayout.razor +++ b/framework/SimpleModule.Blazor/Components/Layout/ManageLayout.razor @@ -1,5 +1,5 @@ @inherits LayoutComponentBase -@layout MainLayout +@layout AppLayout @inject NavigationManager Navigation @inject Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider SchemeProvider diff --git a/framework/SimpleModule.Blazor/Components/Layout/PublicLayout.razor b/framework/SimpleModule.Blazor/Components/Layout/PublicLayout.razor new file mode 100644 index 00000000..35192e88 --- /dev/null +++ b/framework/SimpleModule.Blazor/Components/Layout/PublicLayout.razor @@ -0,0 +1,27 @@ +@using SimpleModule.Core.Menu +@inherits LayoutComponentBase + +
+ +
+ @Body +
diff --git a/framework/SimpleModule.Blazor/Components/ManageNav.razor b/framework/SimpleModule.Blazor/Components/ManageNav.razor index 05e4b5fc..d634e93d 100644 --- a/framework/SimpleModule.Blazor/Components/ManageNav.razor +++ b/framework/SimpleModule.Blazor/Components/ManageNav.razor @@ -26,17 +26,6 @@ Personal data - @code { diff --git a/framework/SimpleModule.Blazor/Components/UserDropdown.razor b/framework/SimpleModule.Blazor/Components/UserDropdown.razor index 66da4eec..2fea38a8 100644 --- a/framework/SimpleModule.Blazor/Components/UserDropdown.razor +++ b/framework/SimpleModule.Blazor/Components/UserDropdown.razor @@ -7,8 +7,8 @@
diff --git a/framework/SimpleModule.Core/Authorization/EndpointPermissionExtensions.cs b/framework/SimpleModule.Core/Authorization/EndpointPermissionExtensions.cs new file mode 100644 index 00000000..1dfa0fc6 --- /dev/null +++ b/framework/SimpleModule.Core/Authorization/EndpointPermissionExtensions.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Builder; + +namespace SimpleModule.Core.Authorization; + +public static class EndpointPermissionExtensions +{ + public static TBuilder RequirePermission( + this TBuilder builder, + params string[] permissions + ) + where TBuilder : IEndpointConventionBuilder + { + return builder.RequireAuthorization(policy => + { + policy.RequireAuthenticatedUser(); + foreach (var permission in permissions) + { + policy.AddRequirements(new PermissionRequirement(permission)); + } + }); + } +} diff --git a/framework/SimpleModule.Core/Authorization/PermissionAuthorizationHandler.cs b/framework/SimpleModule.Core/Authorization/PermissionAuthorizationHandler.cs new file mode 100644 index 00000000..6d952791 --- /dev/null +++ b/framework/SimpleModule.Core/Authorization/PermissionAuthorizationHandler.cs @@ -0,0 +1,27 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; + +namespace SimpleModule.Core.Authorization; + +public sealed class PermissionAuthorizationHandler : AuthorizationHandler +{ + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + PermissionRequirement requirement + ) + { + // Admin role bypasses all permission checks + if (context.User.IsInRole("Admin")) + { + context.Succeed(requirement); + return Task.CompletedTask; + } + + if (context.User.HasClaim("permission", requirement.Permission)) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } +} diff --git a/framework/SimpleModule.Core/Authorization/PermissionRegistry.cs b/framework/SimpleModule.Core/Authorization/PermissionRegistry.cs new file mode 100644 index 00000000..cf8ed75e --- /dev/null +++ b/framework/SimpleModule.Core/Authorization/PermissionRegistry.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace SimpleModule.Core.Authorization; + +public sealed class PermissionRegistry +{ + public IReadOnlySet AllPermissions { get; } + public IReadOnlyDictionary> ByModule { get; } + + internal PermissionRegistry( + IReadOnlySet allPermissions, + IReadOnlyDictionary> byModule + ) + { + AllPermissions = allPermissions; + ByModule = byModule; + } +} diff --git a/framework/SimpleModule.Core/Authorization/PermissionRegistryBuilder.cs b/framework/SimpleModule.Core/Authorization/PermissionRegistryBuilder.cs new file mode 100644 index 00000000..27e74c93 --- /dev/null +++ b/framework/SimpleModule.Core/Authorization/PermissionRegistryBuilder.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace SimpleModule.Core.Authorization; + +public sealed class PermissionRegistryBuilder +{ + private readonly Dictionary> _byModule = new(); + + public void AddPermissions() + where T : class + { + var fields = typeof(T) + .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) + .Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string)); + + foreach (var field in fields) + { + var value = (string)field.GetRawConstantValue()!; + AddPermission(value); + } + } + + public void AddPermission(string permission) + { + var dotIndex = permission.IndexOf('.', StringComparison.Ordinal); + var module = dotIndex >= 0 ? permission[..dotIndex] : "Global"; + + if (!_byModule.TryGetValue(module, out var list)) + { + list = []; + _byModule[module] = list; + } + + if (!list.Contains(permission)) + { + list.Add(permission); + } + } + + public PermissionRegistry Build() + { + var all = new HashSet(); + var byModule = new Dictionary>(); + + foreach (var (module, permissions) in _byModule) + { + byModule[module] = permissions.AsReadOnly(); + foreach (var p in permissions) + { + all.Add(p); + } + } + + return new PermissionRegistry(all, byModule); + } +} diff --git a/framework/SimpleModule.Core/Authorization/PermissionRequirement.cs b/framework/SimpleModule.Core/Authorization/PermissionRequirement.cs new file mode 100644 index 00000000..c8650f5c --- /dev/null +++ b/framework/SimpleModule.Core/Authorization/PermissionRequirement.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Authorization; + +namespace SimpleModule.Core.Authorization; + +public sealed class PermissionRequirement : IAuthorizationRequirement +{ + public string Permission { get; } + + public PermissionRequirement(string permission) + { + Permission = permission; + } +} diff --git a/framework/SimpleModule.Core/Authorization/RequirePermissionAttribute.cs b/framework/SimpleModule.Core/Authorization/RequirePermissionAttribute.cs new file mode 100644 index 00000000..0ba54df1 --- /dev/null +++ b/framework/SimpleModule.Core/Authorization/RequirePermissionAttribute.cs @@ -0,0 +1,14 @@ +using System; + +namespace SimpleModule.Core.Authorization; + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class RequirePermissionAttribute : Attribute +{ + public string[] Permissions { get; } + + public RequirePermissionAttribute(params string[] permissions) + { + Permissions = permissions; + } +} diff --git a/framework/SimpleModule.Core/Endpoints/CrudEndpoints.cs b/framework/SimpleModule.Core/Endpoints/CrudEndpoints.cs new file mode 100644 index 00000000..ad96abe4 --- /dev/null +++ b/framework/SimpleModule.Core/Endpoints/CrudEndpoints.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Http; + +namespace SimpleModule.Core.Endpoints; + +public static class CrudEndpoints +{ + public static async Task GetAll(Func>> getAll) => + TypedResults.Ok(await getAll()); + + public static async Task GetById(Func> getById) + where T : class + { + var entity = await getById(); + return entity is not null ? TypedResults.Ok(entity) : TypedResults.NotFound(); + } + + public static async Task Create( + Func> create, + Func locationFactory + ) + { + var entity = await create(); + return TypedResults.Created( + new Uri(locationFactory(entity), UriKind.RelativeOrAbsolute), + entity + ); + } + + public static async Task Update(Func> update) + where T : class => TypedResults.Ok(await update()); + + public static async Task Delete(Func delete) + { + await delete(); + return TypedResults.NoContent(); + } +} diff --git a/framework/SimpleModule.Core/Exceptions/GlobalExceptionHandler.cs b/framework/SimpleModule.Core/Exceptions/GlobalExceptionHandler.cs index bf625e7c..8c2774f9 100644 --- a/framework/SimpleModule.Core/Exceptions/GlobalExceptionHandler.cs +++ b/framework/SimpleModule.Core/Exceptions/GlobalExceptionHandler.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -22,11 +22,7 @@ CancellationToken cancellationToken ErrorMessages.ValidationErrorTitle, ve.Errors ), - NotFoundException => ( - StatusCodes.Status404NotFound, - ErrorMessages.NotFoundTitle, - (Dictionary?)null - ), + NotFoundException => (StatusCodes.Status404NotFound, ErrorMessages.NotFoundTitle, null), ConflictException => (StatusCodes.Status409Conflict, ErrorMessages.ConflictTitle, null), _ => ( StatusCodes.Status500InternalServerError, diff --git a/framework/SimpleModule.Core/IModule.cs b/framework/SimpleModule.Core/IModule.cs index 7b7d2392..676b430d 100644 --- a/framework/SimpleModule.Core/IModule.cs +++ b/framework/SimpleModule.Core/IModule.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core.Authorization; using SimpleModule.Core.Menu; namespace SimpleModule.Core; @@ -10,4 +11,5 @@ public interface IModule virtual void ConfigureServices(IServiceCollection services, IConfiguration configuration) { } virtual void ConfigureEndpoints(IEndpointRouteBuilder endpoints) { } virtual void ConfigureMenu(IMenuBuilder menus) { } + virtual void ConfigurePermissions(PermissionRegistryBuilder builder) { } } diff --git a/framework/SimpleModule.Core/Inertia/InertiaResult.cs b/framework/SimpleModule.Core/Inertia/InertiaResult.cs index a4d766bf..05769c9a 100644 --- a/framework/SimpleModule.Core/Inertia/InertiaResult.cs +++ b/framework/SimpleModule.Core/Inertia/InertiaResult.cs @@ -28,10 +28,13 @@ public InertiaResult(string component, object? props) public async Task ExecuteAsync(HttpContext httpContext) { + var sharedData = httpContext.RequestServices.GetService(); + var mergedProps = MergeProps(_props, sharedData); + var pageData = new { component = _component, - props = _props ?? new { }, + props = mergedProps, url = httpContext.Request.Path + httpContext.Request.QueryString, version = InertiaMiddleware.Version, }; @@ -50,4 +53,33 @@ public async Task ExecuteAsync(HttpContext httpContext) var renderer = httpContext.RequestServices.GetRequiredService(); await renderer.RenderPageAsync(httpContext, pageJson); } + + private static object MergeProps(object? props, InertiaSharedData? sharedData) + { + if (sharedData is null || sharedData.All.Count == 0) + { + return props ?? new { }; + } + + var result = new Dictionary(); + + // Add shared data first (lower priority) + foreach (var kvp in sharedData.All) + { + result[kvp.Key] = kvp.Value; + } + + // Add endpoint props (higher priority — overwrites shared data) + // Use JSON round-trip to merge endpoint props into shared data + if (props is not null) + { + var json = JsonSerializer.SerializeToElement(props, _camelCaseOptions); + foreach (var property in json.EnumerateObject()) + { + result[property.Name] = property.Value; + } + } + + return result; + } } diff --git a/framework/SimpleModule.Core/Inertia/InertiaSharedData.cs b/framework/SimpleModule.Core/Inertia/InertiaSharedData.cs new file mode 100644 index 00000000..a3803df7 --- /dev/null +++ b/framework/SimpleModule.Core/Inertia/InertiaSharedData.cs @@ -0,0 +1,10 @@ +namespace SimpleModule.Core.Inertia; + +public sealed class InertiaSharedData +{ + private readonly Dictionary _data = []; + + public void Set(string key, object? value) => _data[key] = value; + + public IReadOnlyDictionary All => _data; +} diff --git a/framework/SimpleModule.Core/Menu/MenuSection.cs b/framework/SimpleModule.Core/Menu/MenuSection.cs index 809391c9..a336529d 100644 --- a/framework/SimpleModule.Core/Menu/MenuSection.cs +++ b/framework/SimpleModule.Core/Menu/MenuSection.cs @@ -1,7 +1,12 @@ +using NetEscapades.EnumGenerators; + namespace SimpleModule.Core.Menu; +[EnumExtensions] public enum MenuSection { Navbar, UserDropdown, + AdminSidebar, + AppSidebar, } diff --git a/framework/SimpleModule.Core/SimpleModule.Core.csproj b/framework/SimpleModule.Core/SimpleModule.Core.csproj index 3f60bcef..d840f9b4 100644 --- a/framework/SimpleModule.Core/SimpleModule.Core.csproj +++ b/framework/SimpleModule.Core/SimpleModule.Core.csproj @@ -5,5 +5,6 @@ + diff --git a/framework/SimpleModule.Core/Validation/ValidationBuilder.cs b/framework/SimpleModule.Core/Validation/ValidationBuilder.cs new file mode 100644 index 00000000..fff65629 --- /dev/null +++ b/framework/SimpleModule.Core/Validation/ValidationBuilder.cs @@ -0,0 +1,33 @@ +namespace SimpleModule.Core.Validation; + +public sealed class ValidationBuilder +{ + private readonly Dictionary> _errors = []; + + public ValidationBuilder AddErrorIf(bool condition, string field, string message) + { + if (condition) + { + if (!_errors.TryGetValue(field, out var list)) + { + list = []; + _errors[field] = list; + } + + list.Add(message); + } + + return this; + } + + public ValidationResult Build() + { + if (_errors.Count == 0) + { + return ValidationResult.Success; + } + + var errors = _errors.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToArray()); + return ValidationResult.WithErrors(errors); + } +} diff --git a/framework/SimpleModule.Database/DatabaseInitializerExtensions.cs b/framework/SimpleModule.Database/DatabaseInitializerExtensions.cs deleted file mode 100644 index 2e74eda0..00000000 --- a/framework/SimpleModule.Database/DatabaseInitializerExtensions.cs +++ /dev/null @@ -1,140 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace SimpleModule.Database; - -public static class DatabaseInitializerExtensions -{ - /// - /// Creates module databases and tables using EnsureCreated. - /// - /// Limitation: EnsureCreated cannot evolve existing schemas. If you add or rename - /// columns after initial creation, the existing database will NOT be updated. For production - /// workloads that require schema evolution, use EF Core migrations per module instead. - /// - /// - public static void EnsureModuleDatabases(this WebApplication app) - { - using var scope = app.Services.CreateScope(); - var infos = scope.ServiceProvider.GetServices(); - var dbOptions = scope.ServiceProvider.GetRequiredService>().Value; - var createdDatabases = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var info in infos) - { - var dbContext = (DbContext)scope.ServiceProvider.GetRequiredService(info.DbContextType); - var connectionString = dbOptions.ModuleConnections.TryGetValue( - info.ModuleName, - out var moduleCs - ) - ? moduleCs - : dbOptions.DefaultConnection; - - if (createdDatabases.Add(connectionString)) - { - // First context for this database — create DB and its tables - dbContext.Database.EnsureCreated(); - } - else - { - // Shared database already created — only create this module's tables if they don't exist - var creator = dbContext.GetService(); - var provider = DatabaseProviderDetector.Detect( - connectionString, - dbOptions.Provider - ); - if (!ModuleTablesExist(dbContext, info.ModuleName, provider)) - { - creator.CreateTables(); - } - } - } - } - - private static bool ModuleTablesExist( - DbContext dbContext, - string moduleName, - DatabaseProvider provider - ) - { - var prefix = $"{moduleName}_"; - var entityTypes = dbContext.Model.GetEntityTypes(); - - foreach (var entityType in entityTypes) - { - var tableName = entityType.GetTableName(); - if (tableName is null) - continue; - - // Check for prefixed table (SQLite shared DB) or unprefixed (own DB) - var nameToCheck = tableName.StartsWith(prefix, StringComparison.Ordinal) - ? tableName - : $"{prefix}{tableName}"; - - // Schema names are lowercase by convention in PostgreSQL/SQL Server module isolation -#pragma warning disable CA1308 // Normalize strings to uppercase - schema names must be lowercase - var schemaName = moduleName.ToLowerInvariant(); -#pragma warning restore CA1308 - var exists = provider switch - { - DatabaseProvider.PostgreSql => TableExistsPostgreSql( - dbContext, - schemaName, - nameToCheck - ), - DatabaseProvider.SqlServer => TableExistsSqlServer( - dbContext, - schemaName, - nameToCheck - ), - _ => TableExistsSqlite(dbContext, nameToCheck), - }; - - if (exists) - return true; - } - - return false; - } - - private static bool TableExistsSqlite(DbContext dbContext, string tableName) - { - var count = dbContext.Database.SqlQueryRaw( - "SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type='table' AND name={0}", - tableName - ); - return count.Any(c => c > 0); - } - - private static bool TableExistsPostgreSql( - DbContext dbContext, - string schemaName, - string tableName - ) - { - var count = dbContext.Database.SqlQueryRaw( - "SELECT COUNT(*)::int AS \"Value\" FROM information_schema.tables WHERE table_schema={0} AND table_name={1}", - schemaName, - tableName - ); - return count.Any(c => c > 0); - } - - private static bool TableExistsSqlServer( - DbContext dbContext, - string schemaName, - string tableName - ) - { - var count = dbContext.Database.SqlQueryRaw( - "SELECT COUNT(*) AS [Value] FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA={0} AND TABLE_NAME={1}", - schemaName, - tableName - ); - return count.Any(c => c > 0); - } -} diff --git a/framework/SimpleModule.Database/DatabaseProvider.cs b/framework/SimpleModule.Database/DatabaseProvider.cs index 4a95341b..0f609bd9 100644 --- a/framework/SimpleModule.Database/DatabaseProvider.cs +++ b/framework/SimpleModule.Database/DatabaseProvider.cs @@ -1,5 +1,8 @@ -namespace SimpleModule.Database; +using NetEscapades.EnumGenerators; +namespace SimpleModule.Database; + +[EnumExtensions] public enum DatabaseProvider { Sqlite, diff --git a/framework/SimpleModule.Database/DatabaseProviderDetector.cs b/framework/SimpleModule.Database/DatabaseProviderDetector.cs index 791ba8b6..d07ca526 100644 --- a/framework/SimpleModule.Database/DatabaseProviderDetector.cs +++ b/framework/SimpleModule.Database/DatabaseProviderDetector.cs @@ -10,7 +10,11 @@ public static DatabaseProvider Detect(string connectionString, string? explicitP { if ( !string.IsNullOrWhiteSpace(explicitProvider) - && Enum.TryParse(explicitProvider, ignoreCase: true, out var parsed) + && DatabaseProviderExtensions.TryParse( + explicitProvider, + out var parsed, + ignoreCase: true + ) ) { return parsed; diff --git a/framework/SimpleModule.Database/ModuleDbContextOptionsBuilder.cs b/framework/SimpleModule.Database/ModuleDbContextOptionsBuilder.cs index 85b0d7fd..214301c7 100644 --- a/framework/SimpleModule.Database/ModuleDbContextOptionsBuilder.cs +++ b/framework/SimpleModule.Database/ModuleDbContextOptionsBuilder.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -44,6 +45,10 @@ public static IServiceCollection AddModuleDbContext( break; } + // Suppress PendingModelChangesWarning caused by Vogen ConfigureConventions + // value converters that don't change the actual schema + options.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)); + configureOptions?.Invoke(options); }); diff --git a/framework/SimpleModule.Database/SimpleModule.Database.csproj b/framework/SimpleModule.Database/SimpleModule.Database.csproj index 43cc83ce..59bc7e67 100644 --- a/framework/SimpleModule.Database/SimpleModule.Database.csproj +++ b/framework/SimpleModule.Database/SimpleModule.Database.csproj @@ -5,6 +5,7 @@ + diff --git a/framework/SimpleModule.Generator/AnalyzerReleases.Shipped.md b/framework/SimpleModule.Generator/AnalyzerReleases.Shipped.md new file mode 100644 index 00000000..9c6fa74f --- /dev/null +++ b/framework/SimpleModule.Generator/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md b/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md new file mode 100644 index 00000000..3a54816e --- /dev/null +++ b/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md @@ -0,0 +1,10 @@ +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------ +SM0001 | SimpleModule.Generator | Error | Duplicate DbSet property name across modules +SM0002 | SimpleModule.Generator | Warning | Module has empty name +SM0003 | SimpleModule.Generator | Error | Multiple IdentityDbContext types found +SM0005 | SimpleModule.Generator | Error | IdentityDbContext has unexpected type arguments +SM0006 | SimpleModule.Generator | Warning | Entity configuration targets entity not in any DbSet +SM0007 | SimpleModule.Generator | Error | Duplicate entity configuration diff --git a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs new file mode 100644 index 00000000..fbf5ec97 --- /dev/null +++ b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs @@ -0,0 +1,291 @@ +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace SimpleModule.Generator; + +#region Equatable data model for incremental caching + +// These record types implement value equality so the incremental generator +// pipeline can detect when the extracted data hasn't changed and skip +// re-generating source files. + +internal readonly record struct DiscoveryData( + ImmutableArray Modules, + ImmutableArray DtoTypes, + ImmutableArray DbContexts, + ImmutableArray EntityConfigs +) +{ + public static readonly DiscoveryData Empty = new( + ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty + ); + + public bool Equals(DiscoveryData other) + { + return Modules.SequenceEqual(other.Modules) + && DtoTypes.SequenceEqual(other.DtoTypes) + && DbContexts.SequenceEqual(other.DbContexts) + && EntityConfigs.SequenceEqual(other.EntityConfigs); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + foreach (var m in Modules) + hash = hash * 31 + m.GetHashCode(); + foreach (var d in DtoTypes) + hash = hash * 31 + d.GetHashCode(); + foreach (var c in DbContexts) + hash = hash * 31 + c.GetHashCode(); + foreach (var e in EntityConfigs) + hash = hash * 31 + e.GetHashCode(); + return hash; + } + } +} + +internal readonly record struct ModuleInfoRecord( + string FullyQualifiedName, + string ModuleName, + bool HasConfigureServices, + bool HasConfigureEndpoints, + bool HasConfigureMenu, + bool HasConfigurePermissions, + bool HasRazorComponents, + string RoutePrefix, + string ViewPrefix, + ImmutableArray Endpoints, + ImmutableArray Views +) +{ + public bool Equals(ModuleInfoRecord other) + { + return FullyQualifiedName == other.FullyQualifiedName + && ModuleName == other.ModuleName + && HasConfigureServices == other.HasConfigureServices + && HasConfigureEndpoints == other.HasConfigureEndpoints + && HasConfigureMenu == other.HasConfigureMenu + && HasConfigurePermissions == other.HasConfigurePermissions + && HasRazorComponents == other.HasRazorComponents + && RoutePrefix == other.RoutePrefix + && ViewPrefix == other.ViewPrefix + && Endpoints.SequenceEqual(other.Endpoints) + && Views.SequenceEqual(other.Views); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = hash * 31 + FullyQualifiedName.GetHashCode(); + hash = hash * 31 + (ModuleName ?? "").GetHashCode(); + hash = hash * 31 + HasConfigureServices.GetHashCode(); + hash = hash * 31 + HasConfigureEndpoints.GetHashCode(); + hash = hash * 31 + HasConfigureMenu.GetHashCode(); + hash = hash * 31 + HasConfigurePermissions.GetHashCode(); + hash = hash * 31 + HasRazorComponents.GetHashCode(); + hash = hash * 31 + (RoutePrefix ?? "").GetHashCode(); + hash = hash * 31 + (ViewPrefix ?? "").GetHashCode(); + foreach (var e in Endpoints) + hash = hash * 31 + e.GetHashCode(); + foreach (var v in Views) + hash = hash * 31 + v.GetHashCode(); + return hash; + } + } +} + +internal readonly record struct EndpointInfoRecord( + string FullyQualifiedName, + ImmutableArray RequiredPermissions, + bool AllowAnonymous +) +{ + public bool Equals(EndpointInfoRecord other) + { + return FullyQualifiedName == other.FullyQualifiedName + && AllowAnonymous == other.AllowAnonymous + && RequiredPermissions.SequenceEqual(other.RequiredPermissions); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = hash * 31 + FullyQualifiedName.GetHashCode(); + hash = hash * 31 + AllowAnonymous.GetHashCode(); + foreach (var p in RequiredPermissions) + hash = hash * 31 + p.GetHashCode(); + return hash; + } + } +} + +internal readonly record struct ViewInfoRecord(string FullyQualifiedName, string Page); + +internal readonly record struct DtoTypeInfoRecord( + string FullyQualifiedName, + string SafeName, + ImmutableArray Properties +) +{ + public bool Equals(DtoTypeInfoRecord other) + { + return FullyQualifiedName == other.FullyQualifiedName + && SafeName == other.SafeName + && Properties.SequenceEqual(other.Properties); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = hash * 31 + FullyQualifiedName.GetHashCode(); + hash = hash * 31 + SafeName.GetHashCode(); + foreach (var p in Properties) + hash = hash * 31 + p.GetHashCode(); + return hash; + } + } +} + +internal readonly record struct DtoPropertyInfoRecord( + string Name, + string TypeFqn, + string? UnderlyingTypeFqn, + bool HasSetter +); + +internal readonly record struct DbContextInfoRecord( + string FullyQualifiedName, + string ModuleName, + bool IsIdentityDbContext, + string IdentityUserTypeFqn, + string IdentityRoleTypeFqn, + string IdentityKeyTypeFqn, + ImmutableArray DbSets +) +{ + public bool Equals(DbContextInfoRecord other) + { + return FullyQualifiedName == other.FullyQualifiedName + && ModuleName == other.ModuleName + && IsIdentityDbContext == other.IsIdentityDbContext + && IdentityUserTypeFqn == other.IdentityUserTypeFqn + && IdentityRoleTypeFqn == other.IdentityRoleTypeFqn + && IdentityKeyTypeFqn == other.IdentityKeyTypeFqn + && DbSets.SequenceEqual(other.DbSets); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = hash * 31 + FullyQualifiedName.GetHashCode(); + hash = hash * 31 + (ModuleName ?? "").GetHashCode(); + hash = hash * 31 + IsIdentityDbContext.GetHashCode(); + hash = hash * 31 + (IdentityUserTypeFqn ?? "").GetHashCode(); + hash = hash * 31 + (IdentityRoleTypeFqn ?? "").GetHashCode(); + hash = hash * 31 + (IdentityKeyTypeFqn ?? "").GetHashCode(); + foreach (var d in DbSets) + hash = hash * 31 + d.GetHashCode(); + return hash; + } + } +} + +internal readonly record struct DbSetInfoRecord(string PropertyName, string EntityFqn); + +internal readonly record struct EntityConfigInfoRecord( + string ConfigFqn, + string EntityFqn, + string ModuleName +); + +#endregion + +#region Mutable working types (used during symbol traversal only) + +internal sealed class ModuleInfo +{ + public string FullyQualifiedName { get; set; } = ""; + public string ModuleName { get; set; } = ""; + public bool HasConfigureServices { get; set; } + public bool HasConfigureEndpoints { get; set; } + public bool HasConfigureMenu { get; set; } + public bool HasConfigurePermissions { get; set; } + public bool HasRazorComponents { get; set; } + public string RoutePrefix { get; set; } = ""; + public string ViewPrefix { get; set; } = ""; + public List Endpoints { get; set; } = new(); + public List Views { get; set; } = new(); +} + +internal sealed class EndpointInfo +{ + public string FullyQualifiedName { get; set; } = ""; + public List RequiredPermissions { get; set; } = new(); + public bool AllowAnonymous { get; set; } +} + +internal sealed class ViewInfo +{ + public string FullyQualifiedName { get; set; } = ""; + public string Page { get; set; } = ""; +} + +internal sealed class DtoTypeInfo +{ + public string FullyQualifiedName { get; set; } = ""; + public string SafeName { get; set; } = ""; + public List Properties { get; set; } = new(); +} + +internal sealed class DtoPropertyInfo +{ + public string Name { get; set; } = ""; + public string TypeFqn { get; set; } = ""; + + /// + /// For value objects (e.g. Vogen), the underlying primitive type FQN. + /// Null if the type is not a value object wrapper. + /// + public string? UnderlyingTypeFqn { get; set; } + + public bool HasSetter { get; set; } +} + +internal sealed class DbContextInfo +{ + public string FullyQualifiedName { get; set; } = ""; + public string ModuleName { get; set; } = ""; + public bool IsIdentityDbContext { get; set; } + public string IdentityUserTypeFqn { get; set; } = ""; + public string IdentityRoleTypeFqn { get; set; } = ""; + public string IdentityKeyTypeFqn { get; set; } = ""; + public List DbSets { get; set; } = new(); +} + +internal sealed class DbSetInfo +{ + public string PropertyName { get; set; } = ""; + public string EntityFqn { get; set; } = ""; +} + +internal sealed class EntityConfigInfo +{ + public string ConfigFqn { get; set; } = ""; + public string EntityFqn { get; set; } = ""; + public string ModuleName { get; set; } = ""; +} + +#endregion diff --git a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs new file mode 100644 index 00000000..daebc967 --- /dev/null +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -0,0 +1,722 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +internal static class SymbolDiscovery +{ + internal static DiscoveryData Extract(Compilation compilation) + { + var moduleAttributeSymbol = compilation.GetTypeByMetadataName( + "SimpleModule.Core.ModuleAttribute" + ); + if (moduleAttributeSymbol is null) + return DiscoveryData.Empty; + + var dtoAttributeSymbol = compilation.GetTypeByMetadataName( + "SimpleModule.Core.DtoAttribute" + ); + + var endpointInterfaceSymbol = compilation.GetTypeByMetadataName( + "SimpleModule.Core.IEndpoint" + ); + + var viewEndpointInterfaceSymbol = compilation.GetTypeByMetadataName( + "SimpleModule.Core.IViewEndpoint" + ); + + var modules = new List(); + + foreach (var reference in compilation.References) + { + if ( + compilation.GetAssemblyOrModuleSymbol(reference) + is not IAssemblySymbol assemblySymbol + ) + continue; + + FindModuleTypes(assemblySymbol.GlobalNamespace, moduleAttributeSymbol, modules); + } + + FindModuleTypes(compilation.Assembly.GlobalNamespace, moduleAttributeSymbol, modules); + + if (modules.Count == 0) + return DiscoveryData.Empty; + + // Discover IEndpoint implementors per module assembly. + // Classification is by interface type: IViewEndpoint -> view, IEndpoint -> API. + if (endpointInterfaceSymbol is not null) + { + foreach (var module in modules) + { + var metadataName = module.FullyQualifiedName.Replace("global::", ""); + var typeSymbol = compilation.GetTypeByMetadataName(metadataName); + if (typeSymbol is null) + continue; + + var assembly = typeSymbol.ContainingAssembly; + FindEndpointTypes( + assembly.GlobalNamespace, + endpointInterfaceSymbol, + viewEndpointInterfaceSymbol, + module.ModuleName, + module.Endpoints, + module.Views + ); + } + } + + // Discover DbContext subclasses and IEntityTypeConfiguration per module assembly. + // Scan each assembly once, then match DbContexts/configs to the nearest module by namespace. + var dbContexts = new List(); + var entityConfigs = new List(); + var scannedAssemblies = new HashSet(SymbolEqualityComparer.Default); + foreach (var module in modules) + { + var metadataName = module.FullyQualifiedName.Replace("global::", ""); + var typeSymbol = compilation.GetTypeByMetadataName(metadataName); + if (typeSymbol is null) + continue; + + var assembly = typeSymbol.ContainingAssembly; + if (!scannedAssemblies.Add(assembly)) + continue; + + // Collect unmatched items from this assembly + var rawDbContexts = new List(); + var rawEntityConfigs = new List(); + FindDbContextTypes(assembly.GlobalNamespace, "", rawDbContexts); + FindEntityConfigTypes(assembly.GlobalNamespace, "", rawEntityConfigs); + + // Match each DbContext to the module whose namespace is closest + foreach (var ctx in rawDbContexts) + { + var ctxNs = ctx.FullyQualifiedName.Replace("global::", ""); + ctx.ModuleName = FindClosestModuleName(ctxNs, modules); + dbContexts.Add(ctx); + } + + foreach (var cfg in rawEntityConfigs) + { + var cfgNs = cfg.ConfigFqn.Replace("global::", ""); + cfg.ModuleName = FindClosestModuleName(cfgNs, modules); + entityConfigs.Add(cfg); + } + } + + var dtoTypes = new List(); + if (dtoAttributeSymbol is not null) + { + foreach (var reference in compilation.References) + { + if ( + compilation.GetAssemblyOrModuleSymbol(reference) + is not IAssemblySymbol assemblySymbol + ) + continue; + + FindDtoTypes(assemblySymbol.GlobalNamespace, dtoAttributeSymbol, dtoTypes); + } + + FindDtoTypes(compilation.Assembly.GlobalNamespace, dtoAttributeSymbol, dtoTypes); + } + + var componentBaseSymbol = compilation.GetTypeByMetadataName( + "Microsoft.AspNetCore.Components.ComponentBase" + ); + if (componentBaseSymbol is not null) + { + var assembliesWithComponents = new HashSet( + SymbolEqualityComparer.Default + ); + foreach (var reference in compilation.References) + { + if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol asm) + continue; + + if (HasComponentBaseDescendant(asm.GlobalNamespace, componentBaseSymbol)) + assembliesWithComponents.Add(asm); + } + + foreach (var module in modules) + { + var metadataName = module.FullyQualifiedName.Replace("global::", ""); + var typeSymbol = compilation.GetTypeByMetadataName(metadataName); + if ( + typeSymbol is not null + && assembliesWithComponents.Contains(typeSymbol.ContainingAssembly) + ) + module.HasRazorComponents = true; + } + } + + return new DiscoveryData( + modules + .Select(m => new ModuleInfoRecord( + m.FullyQualifiedName, + m.ModuleName, + m.HasConfigureServices, + m.HasConfigureEndpoints, + m.HasConfigureMenu, + m.HasConfigurePermissions, + m.HasRazorComponents, + m.RoutePrefix, + m.ViewPrefix, + m.Endpoints.Select(e => new EndpointInfoRecord( + e.FullyQualifiedName, + e.RequiredPermissions.ToImmutableArray(), + e.AllowAnonymous + )) + .ToImmutableArray(), + m.Views.Select(v => new ViewInfoRecord(v.FullyQualifiedName, v.Page)) + .ToImmutableArray() + )) + .ToImmutableArray(), + dtoTypes + .Select(d => new DtoTypeInfoRecord( + d.FullyQualifiedName, + d.SafeName, + d.Properties.Select(p => new DtoPropertyInfoRecord( + p.Name, + p.TypeFqn, + p.UnderlyingTypeFqn, + p.HasSetter + )) + .ToImmutableArray() + )) + .ToImmutableArray(), + dbContexts + .Select(c => new DbContextInfoRecord( + c.FullyQualifiedName, + c.ModuleName, + c.IsIdentityDbContext, + c.IdentityUserTypeFqn, + c.IdentityRoleTypeFqn, + c.IdentityKeyTypeFqn, + c.DbSets.Select(d => new DbSetInfoRecord(d.PropertyName, d.EntityFqn)) + .ToImmutableArray() + )) + .ToImmutableArray(), + entityConfigs + .Select(e => new EntityConfigInfoRecord(e.ConfigFqn, e.EntityFqn, e.ModuleName)) + .ToImmutableArray() + ); + } + + private static void FindModuleTypes( + INamespaceSymbol namespaceSymbol, + INamedTypeSymbol moduleAttributeSymbol, + List modules + ) + { + foreach (var member in namespaceSymbol.GetMembers()) + { + if (member is INamespaceSymbol childNamespace) + { + FindModuleTypes(childNamespace, moduleAttributeSymbol, modules); + } + else if (member is INamedTypeSymbol typeSymbol) + { + foreach (var attr in typeSymbol.GetAttributes()) + { + if ( + SymbolEqualityComparer.Default.Equals( + attr.AttributeClass, + moduleAttributeSymbol + ) + ) + { + var moduleName = + attr.ConstructorArguments.Length > 0 + ? attr.ConstructorArguments[0].Value as string ?? "" + : ""; + var routePrefix = ""; + var viewPrefix = ""; + foreach (var namedArg in attr.NamedArguments) + { + if ( + namedArg.Key == "RoutePrefix" + && namedArg.Value.Value is string prefix + ) + { + routePrefix = prefix; + } + else if ( + namedArg.Key == "ViewPrefix" + && namedArg.Value.Value is string vPrefix + ) + { + viewPrefix = vPrefix; + } + } + + modules.Add( + new ModuleInfo + { + FullyQualifiedName = typeSymbol.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ), + ModuleName = moduleName, + HasConfigureServices = DeclaresMethod( + typeSymbol, + "ConfigureServices" + ), + HasConfigureEndpoints = DeclaresMethod( + typeSymbol, + "ConfigureEndpoints" + ), + HasConfigureMenu = DeclaresMethod(typeSymbol, "ConfigureMenu"), + HasConfigurePermissions = DeclaresMethod( + typeSymbol, + "ConfigurePermissions" + ), + RoutePrefix = routePrefix, + ViewPrefix = viewPrefix, + } + ); + break; + } + } + } + } + } + + private static void FindEndpointTypes( + INamespaceSymbol namespaceSymbol, + INamedTypeSymbol endpointInterfaceSymbol, + INamedTypeSymbol? viewEndpointInterfaceSymbol, + string moduleName, + List endpoints, + List views + ) + { + foreach (var member in namespaceSymbol.GetMembers()) + { + if (member is INamespaceSymbol childNamespace) + { + FindEndpointTypes( + childNamespace, + endpointInterfaceSymbol, + viewEndpointInterfaceSymbol, + moduleName, + endpoints, + views + ); + } + else if (member is INamedTypeSymbol typeSymbol) + { + if (!typeSymbol.IsAbstract && !typeSymbol.IsStatic) + { + var fqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + if ( + viewEndpointInterfaceSymbol is not null + && ImplementsInterface(typeSymbol, viewEndpointInterfaceSymbol) + ) + { + var className = typeSymbol.Name; + if (className.EndsWith("Endpoint", StringComparison.Ordinal)) + className = className.Substring( + 0, + className.Length - "Endpoint".Length + ); + else if (className.EndsWith("View", StringComparison.Ordinal)) + className = className.Substring(0, className.Length - "View".Length); + + views.Add( + new ViewInfo + { + FullyQualifiedName = fqn, + Page = moduleName + "/" + className, + } + ); + } + else if (ImplementsInterface(typeSymbol, endpointInterfaceSymbol)) + { + var info = new EndpointInfo { FullyQualifiedName = fqn }; + + foreach (var attr in typeSymbol.GetAttributes()) + { + var attrName = attr.AttributeClass?.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ); + + if ( + attrName + == "global::SimpleModule.Core.Authorization.RequirePermissionAttribute" + ) + { + if (attr.ConstructorArguments.Length > 0) + { + var arg = attr.ConstructorArguments[0]; + if (arg.Kind == TypedConstantKind.Array) + { + foreach (var val in arg.Values) + { + if (val.Value is string s) + info.RequiredPermissions.Add(s); + } + } + else if (arg.Value is string single) + { + info.RequiredPermissions.Add(single); + } + } + } + else if ( + attrName + == "global::Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute" + ) + { + info.AllowAnonymous = true; + } + } + + endpoints.Add(info); + } + } + } + } + } + + private static bool ImplementsInterface( + INamedTypeSymbol typeSymbol, + INamedTypeSymbol interfaceSymbol + ) + { + foreach (var iface in typeSymbol.AllInterfaces) + { + if (SymbolEqualityComparer.Default.Equals(iface, interfaceSymbol)) + return true; + } + return false; + } + + private static bool DeclaresMethod(INamedTypeSymbol typeSymbol, string methodName) + { + foreach (var member in typeSymbol.GetMembers(methodName)) + { + if (member is IMethodSymbol) + return true; + } + return false; + } + + private static void FindDtoTypes( + INamespaceSymbol namespaceSymbol, + INamedTypeSymbol dtoAttributeSymbol, + List dtoTypes + ) + { + foreach (var member in namespaceSymbol.GetMembers()) + { + if (member is INamespaceSymbol childNamespace) + { + FindDtoTypes(childNamespace, dtoAttributeSymbol, dtoTypes); + } + else if (member is INamedTypeSymbol typeSymbol) + { + foreach (var attr in typeSymbol.GetAttributes()) + { + if ( + SymbolEqualityComparer.Default.Equals( + attr.AttributeClass, + dtoAttributeSymbol + ) + ) + { + var fqn = typeSymbol.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ); + var safeName = fqn.Replace("global::", "").Replace(".", "_"); + + var properties = new List(); + foreach (var m in typeSymbol.GetMembers()) + { + if ( + m is IPropertySymbol prop + && prop.DeclaredAccessibility == Accessibility.Public + && !prop.IsStatic + && !prop.IsIndexer + && prop.GetMethod is not null + ) + { + var resolvedType = ResolveUnderlyingType(prop.Type); + var actualType = prop.Type.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ); + properties.Add( + new DtoPropertyInfo + { + Name = prop.Name, + TypeFqn = actualType, + UnderlyingTypeFqn = + resolvedType != actualType ? resolvedType : null, + HasSetter = + prop.SetMethod is not null + && prop.SetMethod.DeclaredAccessibility + == Accessibility.Public, + } + ); + } + } + + dtoTypes.Add( + new DtoTypeInfo + { + FullyQualifiedName = fqn, + SafeName = safeName, + Properties = properties, + } + ); + break; + } + } + } + } + } + + private static bool HasComponentBaseDescendant( + INamespaceSymbol namespaceSymbol, + INamedTypeSymbol componentBaseSymbol + ) + { + foreach (var member in namespaceSymbol.GetMembers()) + { + if (member is INamespaceSymbol childNamespace) + { + if (HasComponentBaseDescendant(childNamespace, componentBaseSymbol)) + return true; + } + else if (member is INamedTypeSymbol typeSymbol) + { + if (InheritsFrom(typeSymbol, componentBaseSymbol)) + return true; + } + } + return false; + } + + private static bool InheritsFrom(INamedTypeSymbol typeSymbol, INamedTypeSymbol baseType) + { + var current = typeSymbol.BaseType; + while (current is not null) + { + if (SymbolEqualityComparer.Default.Equals(current, baseType)) + return true; + current = current.BaseType; + } + return false; + } + + private static string FindClosestModuleName(string typeFqn, List modules) + { + // Match by longest shared namespace prefix between the type and each module class. + var bestMatch = ""; + var bestLength = -1; + foreach (var module in modules) + { + var moduleFqn = module.FullyQualifiedName.Replace("global::", ""); + var moduleNs = moduleFqn.Contains(".") + ? moduleFqn.Substring(0, moduleFqn.LastIndexOf('.')) + : ""; + + if ( + typeFqn.StartsWith(moduleNs, StringComparison.Ordinal) + && moduleNs.Length > bestLength + ) + { + bestLength = moduleNs.Length; + bestMatch = module.ModuleName; + } + } + + return bestMatch.Length > 0 ? bestMatch : modules[0].ModuleName; + } + + private static void FindDbContextTypes( + INamespaceSymbol namespaceSymbol, + string moduleName, + List dbContexts + ) + { + foreach (var member in namespaceSymbol.GetMembers()) + { + if (member is INamespaceSymbol childNamespace) + { + FindDbContextTypes(childNamespace, moduleName, dbContexts); + } + else if ( + member is INamedTypeSymbol typeSymbol + && !typeSymbol.IsAbstract + && !typeSymbol.IsStatic + ) + { + // Walk base type chain looking for DbContext + var isDbContext = false; + var isIdentity = false; + string identityUserFqn = ""; + string identityRoleFqn = ""; + string identityKeyFqn = ""; + + var current = typeSymbol.BaseType; + while (current is not null) + { + var baseFqn = current.OriginalDefinition.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ); + + if ( + baseFqn + == "global::Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext" + ) + { + isDbContext = true; + isIdentity = true; + if (current.TypeArguments.Length >= 3) + { + identityUserFqn = current + .TypeArguments[0] + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + identityRoleFqn = current + .TypeArguments[1] + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + identityKeyFqn = current + .TypeArguments[2] + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } + break; + } + + if (baseFqn == "global::Microsoft.EntityFrameworkCore.DbContext") + { + isDbContext = true; + break; + } + + current = current.BaseType; + } + + if (!isDbContext) + continue; + + var info = new DbContextInfo + { + FullyQualifiedName = typeSymbol.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ), + ModuleName = moduleName, + IsIdentityDbContext = isIdentity, + IdentityUserTypeFqn = identityUserFqn, + IdentityRoleTypeFqn = identityRoleFqn, + IdentityKeyTypeFqn = identityKeyFqn, + }; + + // Collect DbSet properties + foreach (var m in typeSymbol.GetMembers()) + { + if ( + m is IPropertySymbol prop + && prop.DeclaredAccessibility == Accessibility.Public + && !prop.IsStatic + && prop.Type is INamedTypeSymbol propType + && propType.IsGenericType + && propType.OriginalDefinition.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ) == "global::Microsoft.EntityFrameworkCore.DbSet" + ) + { + var entityFqn = propType + .TypeArguments[0] + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + info.DbSets.Add( + new DbSetInfo { PropertyName = prop.Name, EntityFqn = entityFqn } + ); + } + } + + dbContexts.Add(info); + } + } + } + + private static void FindEntityConfigTypes( + INamespaceSymbol namespaceSymbol, + string moduleName, + List entityConfigs + ) + { + foreach (var member in namespaceSymbol.GetMembers()) + { + if (member is INamespaceSymbol childNamespace) + { + FindEntityConfigTypes(childNamespace, moduleName, entityConfigs); + } + else if ( + member is INamedTypeSymbol typeSymbol + && !typeSymbol.IsAbstract + && !typeSymbol.IsStatic + ) + { + foreach (var iface in typeSymbol.AllInterfaces) + { + if ( + iface.IsGenericType + && iface.OriginalDefinition.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ) + == "global::Microsoft.EntityFrameworkCore.IEntityTypeConfiguration" + ) + { + var entityFqn = iface + .TypeArguments[0] + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + entityConfigs.Add( + new EntityConfigInfo + { + ConfigFqn = typeSymbol.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ), + EntityFqn = entityFqn, + ModuleName = moduleName, + } + ); + break; + } + } + } + } + } + + /// + /// If the type is a Vogen value object, returns the FQN of its underlying primitive type. + /// Otherwise returns the type's own FQN. + /// + private static string ResolveUnderlyingType(ITypeSymbol typeSymbol) + { + foreach (var attr in typeSymbol.GetAttributes()) + { + var attrClass = attr.AttributeClass; + if (attrClass is null) + continue; + + // Vogen uses generic attribute ValueObjectAttribute + if ( + attrClass.IsGenericType + && attrClass.Name == "ValueObjectAttribute" + && attrClass.ContainingNamespace.ToDisplayString() == "Vogen" + && attrClass.TypeArguments.Length == 1 + ) + { + return attrClass + .TypeArguments[0] + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } + } + + return typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } +} diff --git a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs new file mode 100644 index 00000000..6237be08 --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs @@ -0,0 +1,171 @@ +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +internal sealed class DiagnosticEmitter : IEmitter +{ + internal static readonly DiagnosticDescriptor DuplicateDbSetPropertyName = new( + id: "SM0001", + title: "Duplicate DbSet property name across modules", + messageFormat: "DbSet property name '{0}' is used by multiple modules: {1} (entity {2}) and {3} (entity {4}). Each module must use unique DbSet property names to avoid table name conflicts in the unified HostDbContext.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + private static readonly DiagnosticDescriptor EmptyModuleName = new( + id: "SM0002", + title: "Module has empty name", + messageFormat: "Module class '{0}' has an empty [Module] name. Provide a non-empty name: [Module(\"MyModule\")]. An empty name will cause broken route prefixes, schema names, and TypeScript module grouping.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + private static readonly DiagnosticDescriptor MultipleIdentityDbContexts = new( + id: "SM0003", + title: "Multiple IdentityDbContext types found", + messageFormat: "Multiple modules define an IdentityDbContext: '{0}' (module {1}) and '{2}' (module {3}). Only one module should provide Identity. The unified HostDbContext can only extend one IdentityDbContext base class.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + private static readonly DiagnosticDescriptor IdentityDbContextBadTypeArgs = new( + id: "SM0005", + title: "IdentityDbContext has unexpected type arguments", + messageFormat: "IdentityDbContext '{0}' in module '{1}' must extend IdentityDbContext with exactly 3 type arguments, but {2} were found. Use the 3-argument form: IdentityDbContext.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + private static readonly DiagnosticDescriptor EntityConfigForMissingEntity = new( + id: "SM0006", + title: "Entity configuration targets entity not in any DbSet", + messageFormat: "IEntityTypeConfiguration<{0}> in '{1}' (module '{2}') configures an entity that is not exposed as a DbSet in any module's DbContext. Add a DbSet<{0}> property to a DbContext, or remove this configuration.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + private static readonly DiagnosticDescriptor DuplicateEntityConfiguration = new( + id: "SM0007", + title: "Duplicate entity configuration", + messageFormat: "Entity '{0}' has multiple IEntityTypeConfiguration implementations: '{1}' and '{2}'. EF Core only supports one configuration per entity type. Remove the duplicate.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + public void Emit(SourceProductionContext context, DiscoveryData data) + { + // SM0002: Empty module name + foreach (var module in data.Modules) + { + if (string.IsNullOrEmpty(module.ModuleName)) + { + context.ReportDiagnostic( + Diagnostic.Create( + EmptyModuleName, + Location.None, + module.FullyQualifiedName.Replace("global::", "") + ) + ); + } + } + + // SM0004: DbContext with no DbSets — silently skipped. + // Some DbContexts (e.g., OpenIddict) manage tables internally without public DbSet + // properties. These are excluded from the unified HostDbContext but are not an error. + + // SM0003: Multiple IdentityDbContexts + DbContextInfoRecord? firstIdentity = null; + foreach (var ctx in data.DbContexts) + { + if (!ctx.IsIdentityDbContext) + continue; + + if (firstIdentity is null) + { + firstIdentity = ctx; + } + else + { + context.ReportDiagnostic( + Diagnostic.Create( + MultipleIdentityDbContexts, + Location.None, + firstIdentity.Value.FullyQualifiedName.Replace("global::", ""), + firstIdentity.Value.ModuleName, + ctx.FullyQualifiedName.Replace("global::", ""), + ctx.ModuleName + ) + ); + } + } + + // SM0005: IdentityDbContext with wrong type args + foreach (var ctx in data.DbContexts) + { + if (ctx.IsIdentityDbContext && string.IsNullOrEmpty(ctx.IdentityUserTypeFqn)) + { + context.ReportDiagnostic( + Diagnostic.Create( + IdentityDbContextBadTypeArgs, + Location.None, + ctx.FullyQualifiedName.Replace("global::", ""), + ctx.ModuleName, + 0 + ) + ); + } + } + + // SM0006: Entity config for entity not in any DbSet + var allEntityFqns = new System.Collections.Generic.HashSet(); + foreach (var ctx in data.DbContexts) + { + foreach (var dbSet in ctx.DbSets) + allEntityFqns.Add(dbSet.EntityFqn); + } + + foreach (var config in data.EntityConfigs) + { + if (!allEntityFqns.Contains(config.EntityFqn)) + { + context.ReportDiagnostic( + Diagnostic.Create( + EntityConfigForMissingEntity, + Location.None, + config.EntityFqn.Replace("global::", ""), + config.ConfigFqn.Replace("global::", ""), + config.ModuleName + ) + ); + } + } + + // SM0007: Duplicate EntityTypeConfiguration for same entity + var entityConfigOwners = new System.Collections.Generic.Dictionary(); + foreach (var config in data.EntityConfigs) + { + if (entityConfigOwners.TryGetValue(config.EntityFqn, out var existing)) + { + context.ReportDiagnostic( + Diagnostic.Create( + DuplicateEntityConfiguration, + Location.None, + config.EntityFqn.Replace("global::", ""), + existing, + config.ConfigFqn.Replace("global::", "") + ) + ); + } + else + { + entityConfigOwners[config.EntityFqn] = config.ConfigFqn.Replace("global::", ""); + } + } + } +} diff --git a/framework/SimpleModule.Generator/Emitters/EndpointExtensionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/EndpointExtensionsEmitter.cs new file mode 100644 index 00000000..f6f060a7 --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/EndpointExtensionsEmitter.cs @@ -0,0 +1,110 @@ +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SimpleModule.Generator; + +internal sealed class EndpointExtensionsEmitter : IEmitter +{ + public void Emit(SourceProductionContext context, DiscoveryData data) + { + var modules = data.Modules; + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("using Microsoft.AspNetCore.Builder;"); + sb.AppendLine("using Microsoft.AspNetCore.Routing;"); + sb.AppendLine("using Microsoft.AspNetCore.Http;"); + sb.AppendLine("using Microsoft.AspNetCore.Authorization;"); + sb.AppendLine(); + sb.AppendLine("namespace SimpleModule.Core;"); + sb.AppendLine(); + sb.AppendLine("public static class EndpointExtensions"); + sb.AppendLine("{"); + sb.AppendLine( + " public static WebApplication MapModuleEndpoints(this WebApplication app)" + ); + sb.AppendLine(" {"); + + // Auto-registered endpoints (IEndpoint implementors) + // Skip modules that define ConfigureEndpoints — they manage their own registration. + foreach (var module in modules) + { + if (module.Endpoints.Length == 0 || module.HasConfigureEndpoints) + continue; + + sb.AppendLine(); + sb.AppendLine($" // Auto-registered endpoints for {module.FullyQualifiedName}"); + sb.AppendLine(" {"); + + if (!string.IsNullOrEmpty(module.RoutePrefix)) + { + sb.AppendLine( + $" var group = app.MapGroup(\"{module.RoutePrefix}\").WithTags(\"{module.ModuleName}\").RequireAuthorization();" + ); + foreach (var endpoint in module.Endpoints) + { + sb.AppendLine($" new {endpoint.FullyQualifiedName}().Map(group);"); + } + } + else + { + foreach (var endpoint in module.Endpoints) + { + sb.AppendLine($" new {endpoint.FullyQualifiedName}().Map(app);"); + } + } + + sb.AppendLine(" }"); + } + + // Auto-registered view endpoints (IViewEndpoint implementors) + foreach (var module in modules) + { + if (module.Views.Length == 0) + continue; + + sb.AppendLine(); + sb.AppendLine( + $" // Auto-registered view endpoints for {module.FullyQualifiedName}" + ); + sb.AppendLine(" {"); + + if (!string.IsNullOrEmpty(module.ViewPrefix)) + { + sb.AppendLine( + $" var viewGroup = app.MapGroup(\"{module.ViewPrefix}\").WithTags(\"{module.ModuleName}\").ExcludeFromDescription().RequireAuthorization();" + ); + foreach (var view in module.Views) + { + sb.AppendLine($" new {view.FullyQualifiedName}().Map(viewGroup);"); + } + } + else + { + foreach (var view in module.Views) + { + sb.AppendLine($" new {view.FullyQualifiedName}().Map(app);"); + } + } + + sb.AppendLine(" }"); + } + + // Manual ConfigureEndpoints (escape hatch) + foreach (var module in modules.Where(m => m.HasConfigureEndpoints)) + { + var fieldName = TypeMappingHelpers.GetModuleFieldName(module.FullyQualifiedName); + sb.AppendLine(); + sb.AppendLine($" ModuleExtensions.{fieldName}.ConfigureEndpoints(app);"); + } + + sb.AppendLine(); + sb.AppendLine(" return app;"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + + context.AddSource("EndpointExtensions.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); + } +} diff --git a/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs b/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs new file mode 100644 index 00000000..c9b16f79 --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs @@ -0,0 +1,278 @@ +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SimpleModule.Generator; + +internal sealed class HostDbContextEmitter : IEmitter +{ +#pragma warning disable CA1308 // Schema names are conventionally lowercase in PostgreSQL/SQL Server + public void Emit(SourceProductionContext context, DiscoveryData data) + { + if (data.DbContexts.Length == 0) + return; + + // Abort if any error-level diagnostics were reported (SM0003, SM0005, SM0007) + var identityCount = 0; + var hasBadIdentityArgs = false; + foreach (var ctx in data.DbContexts) + { + if (ctx.IsIdentityDbContext) + { + identityCount++; + if (string.IsNullOrEmpty(ctx.IdentityUserTypeFqn)) + hasBadIdentityArgs = true; + } + } + + var hasDuplicateConfigs = false; + var configEntities = new System.Collections.Generic.HashSet(); + foreach (var config in data.EntityConfigs) + { + if (!configEntities.Add(config.EntityFqn)) + hasDuplicateConfigs = true; + } + + if (identityCount > 1 || hasBadIdentityArgs || hasDuplicateConfigs) + return; + + // Determine if any module uses IdentityDbContext + DbContextInfoRecord? identityContext = null; + foreach (var ctx in data.DbContexts) + { + if (ctx.IsIdentityDbContext) + { + identityContext = ctx; + break; + } + } + + // Collect all DbSet across modules, deduplicated by entity FQN + var seenEntities = new System.Collections.Generic.HashSet(); + var allDbSets = new System.Collections.Generic.List<( + string PropertyName, + string EntityFqn, + string ModuleName + )>(); + foreach (var ctx in data.DbContexts) + { + foreach (var dbSet in ctx.DbSets) + { + if (seenEntities.Add(dbSet.EntityFqn)) + { + allDbSets.Add((dbSet.PropertyName, dbSet.EntityFqn, ctx.ModuleName)); + } + } + } + + // Check for duplicate DbSet property names across modules + var propertyNameOwners = new System.Collections.Generic.Dictionary< + string, + (string EntityFqn, string ModuleName) + >(); + var hasDuplicates = false; + foreach (var entry in allDbSets) + { + if (propertyNameOwners.TryGetValue(entry.PropertyName, out var existing)) + { + // Same entity type with same property name is fine (deduplicated earlier) + if (existing.EntityFqn != entry.EntityFqn) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticEmitter.DuplicateDbSetPropertyName, + Location.None, + entry.PropertyName, + existing.ModuleName, + existing.EntityFqn.Replace("global::", ""), + entry.ModuleName, + entry.EntityFqn.Replace("global::", "") + ) + ); + hasDuplicates = true; + } + } + else + { + propertyNameOwners[entry.PropertyName] = (entry.EntityFqn, entry.ModuleName); + } + } + + if (hasDuplicates) + return; + + // Build entity-to-module mapping for schema isolation + var entityToModule = new System.Collections.Generic.Dictionary(); + foreach (var entry in allDbSets) + { + entityToModule[entry.EntityFqn] = entry.ModuleName; + } + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#pragma warning disable"); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + sb.AppendLine("using Microsoft.EntityFrameworkCore;"); + sb.AppendLine("using Microsoft.Extensions.Options;"); + sb.AppendLine("using SimpleModule.Database;"); + + if (identityContext != null) + { + sb.AppendLine("using Microsoft.AspNetCore.Identity.EntityFrameworkCore;"); + } + + sb.AppendLine(); + sb.AppendLine("namespace SimpleModule.Host;"); + sb.AppendLine(); + + // Class declaration with primary constructor + if (identityContext != null) + { + sb.AppendLine($"public partial class HostDbContext("); + sb.AppendLine(" DbContextOptions options,"); + sb.AppendLine(" IOptions dbOptions"); + sb.AppendLine($") : IdentityDbContext<{identityContext.Value.IdentityUserTypeFqn},"); + sb.AppendLine($" {identityContext.Value.IdentityRoleTypeFqn},"); + sb.AppendLine( + $" {identityContext.Value.IdentityKeyTypeFqn}>(options)" + ); + } + else + { + sb.AppendLine("public class HostDbContext("); + sb.AppendLine(" DbContextOptions options,"); + sb.AppendLine(" IOptions dbOptions"); + sb.AppendLine(") : DbContext(options)"); + } + + sb.AppendLine("{"); + + // DbSet properties + foreach (var dbSet in allDbSets) + { + sb.AppendLine($" public DbSet<{dbSet.EntityFqn}> {dbSet.PropertyName}"); + sb.AppendLine($" => Set<{dbSet.EntityFqn}>();"); + sb.AppendLine(); + } + + // OnModelCreating + sb.AppendLine(" protected override void OnModelCreating(ModelBuilder builder)"); + sb.AppendLine(" {"); + + if (identityContext != null) + { + sb.AppendLine(" base.OnModelCreating(builder);"); + sb.AppendLine(); + } + + // Apply IEntityTypeConfiguration implementations + if (data.EntityConfigs.Length > 0) + { + sb.AppendLine( + " // Apply IEntityTypeConfiguration found in module assemblies" + ); + foreach (var config in data.EntityConfigs) + { + sb.AppendLine($" builder.ApplyConfiguration(new {config.ConfigFqn}());"); + } + sb.AppendLine(); + } + + // Schema isolation (non-SQLite only) + sb.AppendLine(" // Schema isolation (PostgreSQL/SQL Server only)"); + sb.AppendLine(" var provider = DatabaseProviderDetector.Detect("); + sb.AppendLine(" dbOptions.Value.DefaultConnection, dbOptions.Value.Provider);"); + sb.AppendLine(" if (provider != DatabaseProvider.Sqlite)"); + sb.AppendLine(" {"); + + // Group entities by module for schema assignment + var moduleNames = new System.Collections.Generic.HashSet(); + foreach (var entry in allDbSets) + { + moduleNames.Add(entry.ModuleName); + } + + foreach (var entry in allDbSets) + { + var schema = entry.ModuleName.ToLowerInvariant(); + sb.AppendLine($" builder.Entity<{entry.EntityFqn}>()"); + sb.AppendLine($" .Metadata.SetSchema(\"{schema}\");"); + } + + // If there's an IdentityDbContext, assign remaining entities (Identity tables) + // to that module's schema + if (identityContext != null) + { + var identitySchema = identityContext.Value.ModuleName.ToLowerInvariant(); + sb.AppendLine(); + sb.AppendLine(" // Assign Identity tables to the identity module's schema"); + sb.AppendLine(" foreach (var entityType in builder.Model.GetEntityTypes())"); + sb.AppendLine(" {"); + sb.AppendLine(" if (entityType.GetSchema() is null)"); + sb.AppendLine($" entityType.SetSchema(\"{identitySchema}\");"); + sb.AppendLine(" }"); + } + + sb.AppendLine(" }"); + + // SQLite table prefix isolation + sb.AppendLine(" else"); + sb.AppendLine(" {"); + + // For SQLite, apply table prefixes per module + foreach (var ctx in data.DbContexts) + { + var prefix = ctx.ModuleName + "_"; + foreach (var dbSet in ctx.DbSets) + { + if (!seenEntities.Contains(dbSet.EntityFqn)) + continue; + sb.AppendLine( + $" builder.Entity<{dbSet.EntityFqn}>().ToTable(\"{prefix}\" +" + ); + sb.AppendLine( + $" (builder.Entity<{dbSet.EntityFqn}>().Metadata.GetTableName() ?? \"{dbSet.PropertyName}\"));" + ); + } + } + + // For Identity tables in SQLite, prefix them too + if (identityContext != null) + { + var identityPrefix = identityContext.Value.ModuleName + "_"; + sb.AppendLine(); + sb.AppendLine(" // Prefix Identity tables for SQLite"); + sb.AppendLine(" foreach (var entityType in builder.Model.GetEntityTypes())"); + sb.AppendLine(" {"); + sb.AppendLine(" var tableName = entityType.GetTableName();"); + sb.AppendLine( + $" if (tableName is not null && !tableName.StartsWith(\"{identityPrefix}\", global::System.StringComparison.Ordinal))" + ); + + // Check it's not already prefixed by another module + sb.AppendLine(" {"); + sb.AppendLine(" var alreadyPrefixed = false;"); + foreach (var ctx2 in data.DbContexts) + { + if (ctx2.FullyQualifiedName == identityContext.Value.FullyQualifiedName) + continue; + sb.AppendLine( + $" if (tableName.StartsWith(\"{ctx2.ModuleName}_\", global::System.StringComparison.Ordinal)) alreadyPrefixed = true;" + ); + } + sb.AppendLine( + $" if (!alreadyPrefixed) entityType.SetTableName(\"{identityPrefix}\" + tableName);" + ); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + } + + sb.AppendLine(" }"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + + context.AddSource("HostDbContext.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); + } +#pragma warning restore CA1308 +} diff --git a/framework/SimpleModule.Generator/Emitters/IEmitter.cs b/framework/SimpleModule.Generator/Emitters/IEmitter.cs new file mode 100644 index 00000000..d6d4649c --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/IEmitter.cs @@ -0,0 +1,8 @@ +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +internal interface IEmitter +{ + void Emit(SourceProductionContext context, DiscoveryData data); +} diff --git a/framework/SimpleModule.Generator/Emitters/JsonResolverEmitter.cs b/framework/SimpleModule.Generator/Emitters/JsonResolverEmitter.cs new file mode 100644 index 00000000..73340bee --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/JsonResolverEmitter.cs @@ -0,0 +1,90 @@ +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SimpleModule.Generator; + +internal sealed class JsonResolverEmitter : IEmitter +{ + public void Emit(SourceProductionContext context, DiscoveryData data) + { + if (data.DtoTypes.Length == 0) + return; + + var dtoTypes = data.DtoTypes; + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine("#pragma warning disable IL2026"); + sb.AppendLine("#pragma warning disable IL3050"); + sb.AppendLine("using System;"); + sb.AppendLine("using System.Text.Json;"); + sb.AppendLine("using System.Text.Json.Serialization.Metadata;"); + sb.AppendLine(); + sb.AppendLine("namespace SimpleModule.Core;"); + sb.AppendLine(); + sb.AppendLine("public sealed class ModulesJsonResolver : IJsonTypeInfoResolver"); + sb.AppendLine("{"); + sb.AppendLine(" public static readonly ModulesJsonResolver Instance = new();"); + sb.AppendLine(); + sb.AppendLine( + " public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options)" + ); + sb.AppendLine(" {"); + + foreach (var dto in dtoTypes) + { + sb.AppendLine($" if (type == typeof({dto.FullyQualifiedName}))"); + sb.AppendLine($" return Create_{dto.SafeName}(options);"); + } + + sb.AppendLine(" return null;"); + sb.AppendLine(" }"); + + foreach (var dto in dtoTypes) + { + sb.AppendLine(); + sb.AppendLine( + $" private static JsonTypeInfo Create_{dto.SafeName}(JsonSerializerOptions options)" + ); + sb.AppendLine(" {"); + sb.AppendLine( + $" var info = JsonTypeInfo.CreateJsonTypeInfo<{dto.FullyQualifiedName}>(options);" + ); + sb.AppendLine( + $" info.CreateObject = static () => new {dto.FullyQualifiedName}();" + ); + + foreach (var prop in dto.Properties) + { + var jsonName = char.ToLowerInvariant(prop.Name[0]) + prop.Name.Substring(1); + sb.AppendLine( + $" var prop_{prop.Name} = info.CreateJsonPropertyInfo(typeof({prop.TypeFqn}), \"{jsonName}\");" + ); + sb.AppendLine( + $" prop_{prop.Name}.Get = static obj => (({dto.FullyQualifiedName})obj).{prop.Name};" + ); + + if (prop.HasSetter) + { + sb.AppendLine( + $" prop_{prop.Name}.Set = static (obj, val) => (({dto.FullyQualifiedName})obj).{prop.Name} = ({prop.TypeFqn})val!;" + ); + } + + sb.AppendLine($" info.Properties.Add(prop_{prop.Name});"); + } + + sb.AppendLine(" return info;"); + sb.AppendLine(" }"); + } + + sb.AppendLine("}"); + + context.AddSource( + "ModulesJsonResolver.g.cs", + SourceText.From(sb.ToString(), Encoding.UTF8) + ); + } +} diff --git a/framework/SimpleModule.Generator/Emitters/MenuExtensionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/MenuExtensionsEmitter.cs new file mode 100644 index 00000000..e3c1081d --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/MenuExtensionsEmitter.cs @@ -0,0 +1,44 @@ +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SimpleModule.Generator; + +internal sealed class MenuExtensionsEmitter : IEmitter +{ + public void Emit(SourceProductionContext context, DiscoveryData data) + { + var modules = data.Modules; + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("using Microsoft.Extensions.DependencyInjection;"); + sb.AppendLine("using SimpleModule.Core.Menu;"); + sb.AppendLine(); + sb.AppendLine("namespace SimpleModule.Core;"); + sb.AppendLine(); + sb.AppendLine("public static class MenuExtensions"); + sb.AppendLine("{"); + sb.AppendLine( + " public static IServiceCollection CollectModuleMenuItems(this IServiceCollection services)" + ); + sb.AppendLine(" {"); + sb.AppendLine(" var menus = new MenuBuilder();"); + + foreach (var module in modules.Where(m => m.HasConfigureMenu)) + { + var fieldName = TypeMappingHelpers.GetModuleFieldName(module.FullyQualifiedName); + sb.AppendLine($" ModuleExtensions.{fieldName}.ConfigureMenu(menus);"); + } + + sb.AppendLine( + " services.AddSingleton(new MenuRegistry(menus.ToList()));" + ); + sb.AppendLine(" return services;"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + + context.AddSource("MenuExtensions.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); + } +} diff --git a/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs new file mode 100644 index 00000000..6b39ef90 --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs @@ -0,0 +1,93 @@ +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SimpleModule.Generator; + +internal sealed class ModuleExtensionsEmitter : IEmitter +{ + public void Emit(SourceProductionContext context, DiscoveryData data) + { + var modules = data.Modules; + var hasDtoTypes = data.DtoTypes.Length > 0; + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#pragma warning disable IL2026"); + sb.AppendLine("#pragma warning disable IL3050"); + sb.AppendLine("using Microsoft.AspNetCore.Http.Json;"); + sb.AppendLine("using Microsoft.Extensions.Configuration;"); + sb.AppendLine("using Microsoft.Extensions.DependencyInjection;"); + sb.AppendLine("using Microsoft.AspNetCore.Authorization;"); + sb.AppendLine("using SimpleModule.Core.Authorization;"); + sb.AppendLine(); + sb.AppendLine("namespace SimpleModule.Core;"); + sb.AppendLine(); + sb.AppendLine("public static class ModuleExtensions"); + sb.AppendLine("{"); + + // Generate shared module instances + foreach (var module in modules) + { + var fieldName = TypeMappingHelpers.GetModuleFieldName(module.FullyQualifiedName); + sb.AppendLine( + $" internal static readonly {module.FullyQualifiedName} {fieldName} = new();" + ); + } + + sb.AppendLine(); + sb.AppendLine( + " public static IServiceCollection AddModules(this IServiceCollection services, IConfiguration configuration)" + ); + sb.AppendLine(" {"); + + foreach (var module in modules.Where(m => m.HasConfigureServices)) + { + var fieldName = TypeMappingHelpers.GetModuleFieldName(module.FullyQualifiedName); + sb.AppendLine($" {fieldName}.ConfigureServices(services, configuration);"); + } + + sb.AppendLine(); + sb.AppendLine(" var permissionBuilder = new PermissionRegistryBuilder();"); + + foreach (var module in modules.Where(m => m.HasConfigurePermissions)) + { + var fieldName = TypeMappingHelpers.GetModuleFieldName(module.FullyQualifiedName); + sb.AppendLine($" {fieldName}.ConfigurePermissions(permissionBuilder);"); + } + + sb.AppendLine(" var permissionRegistry = permissionBuilder.Build();"); + sb.AppendLine(" services.AddSingleton(permissionRegistry);"); + sb.AppendLine(); + sb.AppendLine(" services.AddAuthorizationBuilder()"); + sb.AppendLine( + " .SetDefaultPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());" + ); + sb.AppendLine( + " services.AddSingleton();" + ); + + if (hasDtoTypes) + { + sb.AppendLine(); + sb.AppendLine(" services.ConfigureHttpJsonOptions(options =>"); + sb.AppendLine(" {"); + sb.AppendLine( + " options.SerializerOptions.TypeInfoResolver = System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine(" + ); + sb.AppendLine(" ModulesJsonResolver.Instance,"); + sb.AppendLine( + " new System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver());" + ); + sb.AppendLine(" });"); + } + + sb.AppendLine(); + sb.AppendLine(" return services;"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + + context.AddSource("ModuleExtensions.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); + } +} diff --git a/framework/SimpleModule.Generator/Emitters/RazorComponentExtensionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/RazorComponentExtensionsEmitter.cs new file mode 100644 index 00000000..45f58771 --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/RazorComponentExtensionsEmitter.cs @@ -0,0 +1,50 @@ +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SimpleModule.Generator; + +internal sealed class RazorComponentExtensionsEmitter : IEmitter +{ + public void Emit(SourceProductionContext context, DiscoveryData data) + { + var modules = data.Modules; + var razorModules = modules.Where(m => m.HasRazorComponents).ToList(); + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("using Microsoft.AspNetCore.Builder;"); + sb.AppendLine(); + sb.AppendLine("namespace SimpleModule.Core;"); + sb.AppendLine(); + sb.AppendLine("public static class RazorComponentExtensions"); + sb.AppendLine("{"); + sb.AppendLine( + " public static RazorComponentsEndpointConventionBuilder AddModuleAssemblies(" + ); + sb.AppendLine(" this RazorComponentsEndpointConventionBuilder builder)"); + sb.AppendLine(" {"); + + if (razorModules.Count > 0) + { + sb.AppendLine(" builder.AddAdditionalAssemblies("); + for (var i = 0; i < razorModules.Count; i++) + { + var suffix = i < razorModules.Count - 1 ? "," : ");"; + sb.AppendLine( + $" typeof({razorModules[i].FullyQualifiedName}).Assembly{suffix}" + ); + } + } + + sb.AppendLine(" return builder;"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + + context.AddSource( + "RazorComponentExtensions.g.cs", + SourceText.From(sb.ToString(), Encoding.UTF8) + ); + } +} diff --git a/framework/SimpleModule.Generator/Emitters/TypeScriptDefinitionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/TypeScriptDefinitionsEmitter.cs new file mode 100644 index 00000000..00757f5d --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/TypeScriptDefinitionsEmitter.cs @@ -0,0 +1,83 @@ +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SimpleModule.Generator; + +internal sealed class TypeScriptDefinitionsEmitter : IEmitter +{ + public void Emit(SourceProductionContext context, DiscoveryData data) + { + if (data.DtoTypes.Length == 0) + return; + + var dtoTypes = data.DtoTypes; + + // Build a set of known DTO FQNs so nested references resolve to their TS interface name + var knownDtoTypes = new System.Collections.Generic.Dictionary(); + foreach (var dto in dtoTypes) + { + var typeName = dto.FullyQualifiedName; + var shortName = typeName.Contains(".") + ? typeName.Substring(typeName.LastIndexOf('.') + 1) + : typeName; + shortName = shortName.Replace("global::", ""); + knownDtoTypes[dto.FullyQualifiedName] = shortName; + } + + // Group DTOs by module name (first namespace segment, e.g. "Products" from "global::Products.Contracts.Product") + var moduleGroups = new System.Collections.Generic.Dictionary< + string, + System.Collections.Generic.List + >(); + foreach (var dto in dtoTypes) + { + var moduleName = TypeMappingHelpers.GetModuleNameFromFqn(dto.FullyQualifiedName); + if (!moduleGroups.TryGetValue(moduleName, out var list)) + { + list = new System.Collections.Generic.List(); + moduleGroups[moduleName] = list; + } + list.Add(dto); + } + + // Emit one file per module + foreach (var kvp in moduleGroups) + { + var moduleName = kvp.Key; + var moduleDtos = kvp.Value; + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#if SIMPLEMODULE_TS"); + sb.AppendLine("/*"); + sb.AppendLine($"// @module {moduleName}"); + sb.AppendLine(); + + foreach (var dto in moduleDtos) + { + var shortName = knownDtoTypes[dto.FullyQualifiedName]; + sb.AppendLine($"export interface {shortName} {{"); + foreach (var prop in dto.Properties) + { + var tsType = TypeMappingHelpers.MapCSharpTypeToTypeScript( + prop.UnderlyingTypeFqn ?? prop.TypeFqn, + knownDtoTypes + ); + var camelName = char.ToLowerInvariant(prop.Name[0]) + prop.Name.Substring(1); + sb.AppendLine($" {camelName}: {tsType};"); + } + sb.AppendLine("}"); + sb.AppendLine(); + } + + sb.AppendLine("*/"); + sb.AppendLine("#endif"); + + context.AddSource( + $"DtoTypeScript_{moduleName}.g.cs", + SourceText.From(sb.ToString(), Encoding.UTF8) + ); + } + } +} diff --git a/framework/SimpleModule.Generator/Emitters/ViewPagesEmitter.cs b/framework/SimpleModule.Generator/Emitters/ViewPagesEmitter.cs new file mode 100644 index 00000000..e7788355 --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/ViewPagesEmitter.cs @@ -0,0 +1,60 @@ +using System; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SimpleModule.Generator; + +internal sealed class ViewPagesEmitter : IEmitter +{ + public void Emit(SourceProductionContext context, DiscoveryData data) + { + foreach (var module in data.Modules) + { + if (module.Views.Length == 0) + continue; + + // Extract module name from FQN (e.g., "global::SimpleModule.Products.ProductsModule" -> "Products") + var fqn = module.FullyQualifiedName.Replace("global::", ""); + var moduleName = fqn.Contains(".") ? fqn.Substring(fqn.LastIndexOf('.') + 1) : fqn; + if (moduleName.EndsWith("Module", StringComparison.Ordinal)) + moduleName = moduleName.Substring(0, moduleName.Length - "Module".Length); + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#if SIMPLEMODULE_TS"); + sb.AppendLine("/*"); + + // Generate import statements — component name is the part after "/" + foreach (var view in module.Views) + { + var componentName = view.Page.Contains("/") + ? view.Page.Substring(view.Page.LastIndexOf('/') + 1) + : view.Page; + sb.AppendLine($"import {componentName} from '../Views/{componentName}';"); + } + + sb.AppendLine(); + sb.AppendLine("export const pages: Record = {"); + + for (var i = 0; i < module.Views.Length; i++) + { + var view = module.Views[i]; + var componentName = view.Page.Contains("/") + ? view.Page.Substring(view.Page.LastIndexOf('/') + 1) + : view.Page; + var comma = i < module.Views.Length - 1 ? "," : ""; + sb.AppendLine($" '{view.Page}': {componentName}{comma}"); + } + + sb.AppendLine("};"); + sb.AppendLine("*/"); + sb.AppendLine("#endif"); + + context.AddSource( + $"ViewPages_{moduleName}.g.cs", + SourceText.From(sb.ToString(), Encoding.UTF8) + ); + } + } +} diff --git a/framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs b/framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs new file mode 100644 index 00000000..e56b1e48 --- /dev/null +++ b/framework/SimpleModule.Generator/Helpers/TypeMappingHelpers.cs @@ -0,0 +1,90 @@ +using System; + +namespace SimpleModule.Generator; + +internal static class TypeMappingHelpers +{ + internal static string GetModuleFieldName(string fullyQualifiedName) + { + var name = fullyQualifiedName.Replace("global::", "").Replace(".", "_"); + return $"s_{name}"; + } + + internal static string MapCSharpTypeToTypeScript( + string typeFqn, + System.Collections.Generic.Dictionary? knownDtoTypes = null + ) + { + var type = typeFqn.Replace("global::", ""); + + // Nullable -> T | null + if ( + type.StartsWith("System.Nullable<", StringComparison.Ordinal) + && type.EndsWith(">", StringComparison.Ordinal) + ) + { + var inner = type.Substring( + "System.Nullable<".Length, + type.Length - "System.Nullable<".Length - 1 + ); + return MapCSharpTypeToTypeScript("global::" + inner, knownDtoTypes) + " | null"; + } + + // Collection types + if ( + type.StartsWith("System.Collections.Generic.List<", StringComparison.Ordinal) + || type.StartsWith("System.Collections.Generic.IList<", StringComparison.Ordinal) + || type.StartsWith("System.Collections.Generic.IEnumerable<", StringComparison.Ordinal) + || type.StartsWith( + "System.Collections.Generic.IReadOnlyList<", + StringComparison.Ordinal + ) + || type.StartsWith("System.Collections.Generic.ICollection<", StringComparison.Ordinal) + ) + { + var start = type.IndexOf('<') + 1; + var inner = type.Substring(start, type.Length - start - 1); + return MapCSharpTypeToTypeScript("global::" + inner, knownDtoTypes) + "[]"; + } + + // Check if this is a known [Dto] type + if (knownDtoTypes is not null && knownDtoTypes.TryGetValue(typeFqn, out var tsName)) + return tsName; + + // Also try with global:: prefix + if ( + knownDtoTypes is not null + && knownDtoTypes.TryGetValue("global::" + type, out var tsName2) + ) + return tsName2; + + return type switch + { + "string" or "System.String" => "string", + "int" or "System.Int32" => "number", + "long" or "System.Int64" => "number", + "short" or "System.Int16" => "number", + "byte" or "System.Byte" => "number", + "float" or "System.Single" => "number", + "double" or "System.Double" => "number", + "decimal" or "System.Decimal" => "number", + "bool" or "System.Boolean" => "boolean", + "System.DateTime" + or "System.DateTimeOffset" + or "System.DateOnly" + or "System.TimeOnly" => "string", + "System.Guid" => "string", + _ => "any", + }; + } + + internal static string GetModuleNameFromFqn(string fqn) + { + // "global::SimpleModule.Products.Contracts.Product" -> "Products" + var name = fqn.Replace("global::", ""); + var parts = name.Split('.'); + // Convention: SimpleModule.{ModuleName}.Contracts.{TypeName} + // Return the second segment (module name) + return parts.Length >= 3 ? parts[1] : parts[0]; + } +} diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs deleted file mode 100644 index 671be0a7..00000000 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Emitters.cs +++ /dev/null @@ -1,541 +0,0 @@ -using System; -using System.Collections.Immutable; -using System.Linq; -using System.Text; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Text; - -namespace SimpleModule.Generator; - -public partial class ModuleDiscovererGenerator -{ - private static void GenerateModuleExtensions( - SourceProductionContext context, - ImmutableArray modules, - bool hasDtoTypes - ) - { - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("#pragma warning disable IL2026"); - sb.AppendLine("#pragma warning disable IL3050"); - sb.AppendLine("using Microsoft.AspNetCore.Http.Json;"); - sb.AppendLine("using Microsoft.Extensions.Configuration;"); - sb.AppendLine("using Microsoft.Extensions.DependencyInjection;"); - sb.AppendLine(); - sb.AppendLine("namespace SimpleModule.Core;"); - sb.AppendLine(); - sb.AppendLine("public static class ModuleExtensions"); - sb.AppendLine("{"); - - // Generate shared module instances - foreach (var module in modules) - { - var fieldName = GetModuleFieldName(module.FullyQualifiedName); - sb.AppendLine( - $" internal static readonly {module.FullyQualifiedName} {fieldName} = new();" - ); - } - - sb.AppendLine(); - sb.AppendLine( - " public static IServiceCollection AddModules(this IServiceCollection services, IConfiguration configuration)" - ); - sb.AppendLine(" {"); - - foreach (var module in modules.Where(m => m.HasConfigureServices)) - { - var fieldName = GetModuleFieldName(module.FullyQualifiedName); - sb.AppendLine($" {fieldName}.ConfigureServices(services, configuration);"); - } - - if (hasDtoTypes) - { - sb.AppendLine(); - sb.AppendLine(" services.ConfigureHttpJsonOptions(options =>"); - sb.AppendLine(" {"); - sb.AppendLine( - " options.SerializerOptions.TypeInfoResolver = System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine(" - ); - sb.AppendLine(" ModulesJsonResolver.Instance,"); - sb.AppendLine( - " new System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver());" - ); - sb.AppendLine(" });"); - } - - sb.AppendLine(); - sb.AppendLine(" return services;"); - sb.AppendLine(" }"); - sb.AppendLine("}"); - - context.AddSource("ModuleExtensions.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); - } - - private static void GenerateEndpointExtensions( - SourceProductionContext context, - ImmutableArray modules - ) - { - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("using Microsoft.AspNetCore.Builder;"); - sb.AppendLine("using Microsoft.AspNetCore.Routing;"); - sb.AppendLine("using Microsoft.AspNetCore.Http;"); - sb.AppendLine(); - sb.AppendLine("namespace SimpleModule.Core;"); - sb.AppendLine(); - sb.AppendLine("public static class EndpointExtensions"); - sb.AppendLine("{"); - sb.AppendLine( - " public static WebApplication MapModuleEndpoints(this WebApplication app)" - ); - sb.AppendLine(" {"); - - // Auto-registered endpoints (IEndpoint implementors) - // Skip modules that define ConfigureEndpoints — they manage their own registration. - foreach (var module in modules) - { - if (module.Endpoints.Length == 0 || module.HasConfigureEndpoints) - continue; - - sb.AppendLine(); - sb.AppendLine($" // Auto-registered endpoints for {module.FullyQualifiedName}"); - sb.AppendLine(" {"); - - if (!string.IsNullOrEmpty(module.RoutePrefix)) - { - sb.AppendLine( - $" var group = app.MapGroup(\"{module.RoutePrefix}\").WithTags(\"{module.ModuleName}\");" - ); - foreach (var endpoint in module.Endpoints) - { - sb.AppendLine($" new {endpoint.FullyQualifiedName}().Map(group);"); - } - } - else - { - foreach (var endpoint in module.Endpoints) - { - sb.AppendLine($" new {endpoint.FullyQualifiedName}().Map(app);"); - } - } - - sb.AppendLine(" }"); - } - - // Auto-registered view endpoints (IViewEndpoint implementors) - foreach (var module in modules) - { - if (module.Views.Length == 0) - continue; - - sb.AppendLine(); - sb.AppendLine( - $" // Auto-registered view endpoints for {module.FullyQualifiedName}" - ); - sb.AppendLine(" {"); - - if (!string.IsNullOrEmpty(module.ViewPrefix)) - { - sb.AppendLine( - $" var viewGroup = app.MapGroup(\"{module.ViewPrefix}\").WithTags(\"{module.ModuleName}\").ExcludeFromDescription();" - ); - foreach (var view in module.Views) - { - sb.AppendLine($" new {view.FullyQualifiedName}().Map(viewGroup);"); - } - } - else - { - foreach (var view in module.Views) - { - sb.AppendLine($" new {view.FullyQualifiedName}().Map(app);"); - } - } - - sb.AppendLine(" }"); - } - - // Manual ConfigureEndpoints (escape hatch) - foreach (var module in modules.Where(m => m.HasConfigureEndpoints)) - { - var fieldName = GetModuleFieldName(module.FullyQualifiedName); - sb.AppendLine(); - sb.AppendLine($" ModuleExtensions.{fieldName}.ConfigureEndpoints(app);"); - } - - sb.AppendLine(); - sb.AppendLine(" return app;"); - sb.AppendLine(" }"); - sb.AppendLine("}"); - - context.AddSource("EndpointExtensions.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); - } - - private static void GenerateMenuExtensions( - SourceProductionContext context, - ImmutableArray modules - ) - { - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("using Microsoft.Extensions.DependencyInjection;"); - sb.AppendLine("using SimpleModule.Core.Menu;"); - sb.AppendLine(); - sb.AppendLine("namespace SimpleModule.Core;"); - sb.AppendLine(); - sb.AppendLine("public static class MenuExtensions"); - sb.AppendLine("{"); - sb.AppendLine( - " public static IServiceCollection CollectModuleMenuItems(this IServiceCollection services)" - ); - sb.AppendLine(" {"); - sb.AppendLine(" var menus = new MenuBuilder();"); - - foreach (var module in modules.Where(m => m.HasConfigureMenu)) - { - var fieldName = GetModuleFieldName(module.FullyQualifiedName); - sb.AppendLine($" ModuleExtensions.{fieldName}.ConfigureMenu(menus);"); - } - - sb.AppendLine( - " services.AddSingleton(new MenuRegistry(menus.ToList()));" - ); - sb.AppendLine(" return services;"); - sb.AppendLine(" }"); - sb.AppendLine("}"); - - context.AddSource("MenuExtensions.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); - } - - private static void GenerateJsonResolver( - SourceProductionContext context, - ImmutableArray dtoTypes - ) - { - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("#nullable enable"); - sb.AppendLine("#pragma warning disable IL2026"); - sb.AppendLine("#pragma warning disable IL3050"); - sb.AppendLine("using System;"); - sb.AppendLine("using System.Text.Json;"); - sb.AppendLine("using System.Text.Json.Serialization.Metadata;"); - sb.AppendLine(); - sb.AppendLine("namespace SimpleModule.Core;"); - sb.AppendLine(); - sb.AppendLine("public sealed class ModulesJsonResolver : IJsonTypeInfoResolver"); - sb.AppendLine("{"); - sb.AppendLine(" public static readonly ModulesJsonResolver Instance = new();"); - sb.AppendLine(); - sb.AppendLine( - " public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options)" - ); - sb.AppendLine(" {"); - - foreach (var dto in dtoTypes) - { - sb.AppendLine($" if (type == typeof({dto.FullyQualifiedName}))"); - sb.AppendLine($" return Create_{dto.SafeName}(options);"); - } - - sb.AppendLine(" return null;"); - sb.AppendLine(" }"); - - foreach (var dto in dtoTypes) - { - sb.AppendLine(); - sb.AppendLine( - $" private static JsonTypeInfo Create_{dto.SafeName}(JsonSerializerOptions options)" - ); - sb.AppendLine(" {"); - sb.AppendLine( - $" var info = JsonTypeInfo.CreateJsonTypeInfo<{dto.FullyQualifiedName}>(options);" - ); - sb.AppendLine( - $" info.CreateObject = static () => new {dto.FullyQualifiedName}();" - ); - - foreach (var prop in dto.Properties) - { - var jsonName = char.ToLowerInvariant(prop.Name[0]) + prop.Name.Substring(1); - sb.AppendLine( - $" var prop_{prop.Name} = info.CreateJsonPropertyInfo(typeof({prop.TypeFqn}), \"{jsonName}\");" - ); - sb.AppendLine( - $" prop_{prop.Name}.Get = static obj => (({dto.FullyQualifiedName})obj).{prop.Name};" - ); - - if (prop.HasSetter) - { - sb.AppendLine( - $" prop_{prop.Name}.Set = static (obj, val) => (({dto.FullyQualifiedName})obj).{prop.Name} = ({prop.TypeFqn})val!;" - ); - } - - sb.AppendLine($" info.Properties.Add(prop_{prop.Name});"); - } - - sb.AppendLine(" return info;"); - sb.AppendLine(" }"); - } - - sb.AppendLine("}"); - - context.AddSource( - "ModulesJsonResolver.g.cs", - SourceText.From(sb.ToString(), Encoding.UTF8) - ); - } - - private static void GenerateTypeScriptDefinitions( - SourceProductionContext context, - ImmutableArray dtoTypes - ) - { - // Build a set of known DTO FQNs so nested references resolve to their TS interface name - var knownDtoTypes = new System.Collections.Generic.Dictionary(); - foreach (var dto in dtoTypes) - { - var typeName = dto.FullyQualifiedName; - var shortName = typeName.Contains(".") - ? typeName.Substring(typeName.LastIndexOf('.') + 1) - : typeName; - shortName = shortName.Replace("global::", ""); - knownDtoTypes[dto.FullyQualifiedName] = shortName; - } - - // Group DTOs by module name (first namespace segment, e.g. "Products" from "global::Products.Contracts.Product") - var moduleGroups = - new System.Collections.Generic.Dictionary>(); - foreach (var dto in dtoTypes) - { - var moduleName = GetModuleNameFromFqn(dto.FullyQualifiedName); - if (!moduleGroups.TryGetValue(moduleName, out var list)) - { - list = new System.Collections.Generic.List(); - moduleGroups[moduleName] = list; - } - list.Add(dto); - } - - // Emit one file per module - foreach (var kvp in moduleGroups) - { - var moduleName = kvp.Key; - var moduleDtos = kvp.Value; - - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("#if SIMPLEMODULE_TS"); - sb.AppendLine("/*"); - sb.AppendLine($"// @module {moduleName}"); - sb.AppendLine(); - - foreach (var dto in moduleDtos) - { - var shortName = knownDtoTypes[dto.FullyQualifiedName]; - sb.AppendLine($"export interface {shortName} {{"); - foreach (var prop in dto.Properties) - { - var tsType = MapCSharpTypeToTypeScript(prop.TypeFqn, knownDtoTypes); - var camelName = - char.ToLowerInvariant(prop.Name[0]) + prop.Name.Substring(1); - sb.AppendLine($" {camelName}: {tsType};"); - } - sb.AppendLine("}"); - sb.AppendLine(); - } - - sb.AppendLine("*/"); - sb.AppendLine("#endif"); - - context.AddSource( - $"DtoTypeScript_{moduleName}.g.cs", - SourceText.From(sb.ToString(), Encoding.UTF8) - ); - } - } - - private static string GetModuleNameFromFqn(string fqn) - { - // "global::SimpleModule.Products.Contracts.Product" -> "Products" - var name = fqn.Replace("global::", ""); - var parts = name.Split('.'); - // Convention: SimpleModule.{ModuleName}.Contracts.{TypeName} - // Return the second segment (module name) - return parts.Length >= 3 ? parts[1] : parts[0]; - } - - private static void GenerateViewPages( - SourceProductionContext context, - ImmutableArray modules - ) - { - foreach (var module in modules) - { - if (module.Views.Length == 0) - continue; - - // Extract module name from FQN (e.g., "global::SimpleModule.Products.ProductsModule" -> "Products") - var fqn = module.FullyQualifiedName.Replace("global::", ""); - var moduleName = fqn.Contains(".") ? fqn.Substring(fqn.LastIndexOf('.') + 1) : fqn; - if (moduleName.EndsWith("Module", StringComparison.Ordinal)) - moduleName = moduleName.Substring(0, moduleName.Length - "Module".Length); - - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("#if SIMPLEMODULE_TS"); - sb.AppendLine("/*"); - - // Generate import statements — component name is the part after "/" - foreach (var view in module.Views) - { - var componentName = view.Page.Contains("/") - ? view.Page.Substring(view.Page.LastIndexOf('/') + 1) - : view.Page; - sb.AppendLine($"import {componentName} from '../Views/{componentName}';"); - } - - sb.AppendLine(); - sb.AppendLine("export const pages: Record = {"); - - for (var i = 0; i < module.Views.Length; i++) - { - var view = module.Views[i]; - var componentName = view.Page.Contains("/") - ? view.Page.Substring(view.Page.LastIndexOf('/') + 1) - : view.Page; - var comma = i < module.Views.Length - 1 ? "," : ""; - sb.AppendLine($" '{view.Page}': {componentName}{comma}"); - } - - sb.AppendLine("};"); - sb.AppendLine("*/"); - sb.AppendLine("#endif"); - - context.AddSource( - $"ViewPages_{moduleName}.g.cs", - SourceText.From(sb.ToString(), Encoding.UTF8) - ); - } - } - - private static void GenerateRazorComponentExtensions( - SourceProductionContext context, - ImmutableArray modules - ) - { - var razorModules = modules.Where(m => m.HasRazorComponents).ToList(); - - var sb = new StringBuilder(); - sb.AppendLine("// "); - sb.AppendLine("using Microsoft.AspNetCore.Builder;"); - sb.AppendLine(); - sb.AppendLine("namespace SimpleModule.Core;"); - sb.AppendLine(); - sb.AppendLine("public static class RazorComponentExtensions"); - sb.AppendLine("{"); - sb.AppendLine( - " public static RazorComponentsEndpointConventionBuilder AddModuleAssemblies(" - ); - sb.AppendLine(" this RazorComponentsEndpointConventionBuilder builder)"); - sb.AppendLine(" {"); - - if (razorModules.Count > 0) - { - sb.AppendLine(" builder.AddAdditionalAssemblies("); - for (var i = 0; i < razorModules.Count; i++) - { - var suffix = i < razorModules.Count - 1 ? "," : ");"; - sb.AppendLine( - $" typeof({razorModules[i].FullyQualifiedName}).Assembly{suffix}" - ); - } - } - - sb.AppendLine(" return builder;"); - sb.AppendLine(" }"); - sb.AppendLine("}"); - - context.AddSource( - "RazorComponentExtensions.g.cs", - SourceText.From(sb.ToString(), Encoding.UTF8) - ); - } - - private static string MapCSharpTypeToTypeScript( - string typeFqn, - System.Collections.Generic.Dictionary? knownDtoTypes = null - ) - { - var type = typeFqn.Replace("global::", ""); - - // Nullable -> T | null - if ( - type.StartsWith("System.Nullable<", StringComparison.Ordinal) - && type.EndsWith(">", StringComparison.Ordinal) - ) - { - var inner = type.Substring( - "System.Nullable<".Length, - type.Length - "System.Nullable<".Length - 1 - ); - return MapCSharpTypeToTypeScript("global::" + inner, knownDtoTypes) + " | null"; - } - - // Collection types - if ( - type.StartsWith("System.Collections.Generic.List<", StringComparison.Ordinal) - || type.StartsWith("System.Collections.Generic.IList<", StringComparison.Ordinal) - || type.StartsWith("System.Collections.Generic.IEnumerable<", StringComparison.Ordinal) - || type.StartsWith( - "System.Collections.Generic.IReadOnlyList<", - StringComparison.Ordinal - ) - || type.StartsWith("System.Collections.Generic.ICollection<", StringComparison.Ordinal) - ) - { - var start = type.IndexOf('<') + 1; - var inner = type.Substring(start, type.Length - start - 1); - return MapCSharpTypeToTypeScript("global::" + inner, knownDtoTypes) + "[]"; - } - - // Check if this is a known [Dto] type - if (knownDtoTypes is not null && knownDtoTypes.TryGetValue(typeFqn, out var tsName)) - return tsName; - - // Also try with global:: prefix - if ( - knownDtoTypes is not null - && knownDtoTypes.TryGetValue("global::" + type, out var tsName2) - ) - return tsName2; - - return type switch - { - "string" or "System.String" => "string", - "int" or "System.Int32" => "number", - "long" or "System.Int64" => "number", - "short" or "System.Int16" => "number", - "byte" or "System.Byte" => "number", - "float" or "System.Single" => "number", - "double" or "System.Double" => "number", - "decimal" or "System.Decimal" => "number", - "bool" or "System.Boolean" => "boolean", - "System.DateTime" - or "System.DateTimeOffset" - or "System.DateOnly" - or "System.TimeOnly" => "string", - "System.Guid" => "string", - _ => "any", - }; - } - - private static string GetModuleFieldName(string fullyQualifiedName) - { - var name = fullyQualifiedName.Replace("global::", "").Replace(".", "_"); - return $"s_{name}"; - } -} diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Models.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Models.cs deleted file mode 100644 index 10d8667e..00000000 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.Models.cs +++ /dev/null @@ -1,173 +0,0 @@ -using System.Collections.Generic; -using System.Collections.Immutable; - -namespace SimpleModule.Generator; - -public partial class ModuleDiscovererGenerator -{ - #region Equatable data model for incremental caching - - // These record types implement value equality so the incremental generator - // pipeline can detect when the extracted data hasn't changed and skip - // re-generating source files. - - private readonly record struct DiscoveryData( - ImmutableArray Modules, - ImmutableArray DtoTypes - ) - { - public static readonly DiscoveryData Empty = new( - ImmutableArray.Empty, - ImmutableArray.Empty - ); - - public bool Equals(DiscoveryData other) - { - return Modules.SequenceEqual(other.Modules) && DtoTypes.SequenceEqual(other.DtoTypes); - } - - public override int GetHashCode() - { - unchecked - { - var hash = 17; - foreach (var m in Modules) - hash = hash * 31 + m.GetHashCode(); - foreach (var d in DtoTypes) - hash = hash * 31 + d.GetHashCode(); - return hash; - } - } - } - - private readonly record struct ModuleInfoRecord( - string FullyQualifiedName, - string ModuleName, - bool HasConfigureServices, - bool HasConfigureEndpoints, - bool HasConfigureMenu, - bool HasRazorComponents, - string RoutePrefix, - string ViewPrefix, - ImmutableArray Endpoints, - ImmutableArray Views - ) - { - public bool Equals(ModuleInfoRecord other) - { - return FullyQualifiedName == other.FullyQualifiedName - && ModuleName == other.ModuleName - && HasConfigureServices == other.HasConfigureServices - && HasConfigureEndpoints == other.HasConfigureEndpoints - && HasConfigureMenu == other.HasConfigureMenu - && HasRazorComponents == other.HasRazorComponents - && RoutePrefix == other.RoutePrefix - && ViewPrefix == other.ViewPrefix - && Endpoints.SequenceEqual(other.Endpoints) - && Views.SequenceEqual(other.Views); - } - - public override int GetHashCode() - { - unchecked - { - var hash = 17; - hash = hash * 31 + FullyQualifiedName.GetHashCode(); - hash = hash * 31 + (ModuleName ?? "").GetHashCode(); - hash = hash * 31 + HasConfigureServices.GetHashCode(); - hash = hash * 31 + HasConfigureEndpoints.GetHashCode(); - hash = hash * 31 + HasConfigureMenu.GetHashCode(); - hash = hash * 31 + HasRazorComponents.GetHashCode(); - hash = hash * 31 + (RoutePrefix ?? "").GetHashCode(); - hash = hash * 31 + (ViewPrefix ?? "").GetHashCode(); - foreach (var e in Endpoints) - hash = hash * 31 + e.GetHashCode(); - foreach (var v in Views) - hash = hash * 31 + v.GetHashCode(); - return hash; - } - } - } - - private readonly record struct EndpointInfoRecord(string FullyQualifiedName); - - private readonly record struct ViewInfoRecord(string FullyQualifiedName, string Page); - - private readonly record struct DtoTypeInfoRecord( - string FullyQualifiedName, - string SafeName, - ImmutableArray Properties - ) - { - public bool Equals(DtoTypeInfoRecord other) - { - return FullyQualifiedName == other.FullyQualifiedName - && SafeName == other.SafeName - && Properties.SequenceEqual(other.Properties); - } - - public override int GetHashCode() - { - unchecked - { - var hash = 17; - hash = hash * 31 + FullyQualifiedName.GetHashCode(); - hash = hash * 31 + SafeName.GetHashCode(); - foreach (var p in Properties) - hash = hash * 31 + p.GetHashCode(); - return hash; - } - } - } - - private readonly record struct DtoPropertyInfoRecord( - string Name, - string TypeFqn, - bool HasSetter - ); - - #endregion - - #region Mutable working types (used during symbol traversal only) - - private sealed class ModuleInfo - { - public string FullyQualifiedName { get; set; } = ""; - public string ModuleName { get; set; } = ""; - public bool HasConfigureServices { get; set; } - public bool HasConfigureEndpoints { get; set; } - public bool HasConfigureMenu { get; set; } - public bool HasRazorComponents { get; set; } - public string RoutePrefix { get; set; } = ""; - public string ViewPrefix { get; set; } = ""; - public List Endpoints { get; set; } = new(); - public List Views { get; set; } = new(); - } - - private sealed class EndpointInfo - { - public string FullyQualifiedName { get; set; } = ""; - } - - private sealed class ViewInfo - { - public string FullyQualifiedName { get; set; } = ""; - public string Page { get; set; } = ""; - } - - private sealed class DtoTypeInfo - { - public string FullyQualifiedName { get; set; } = ""; - public string SafeName { get; set; } = ""; - public List Properties { get; set; } = new(); - } - - private sealed class DtoPropertyInfo - { - public string Name { get; set; } = ""; - public string TypeFqn { get; set; } = ""; - public bool HasSetter { get; set; } - } - - #endregion -} diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs index b1826076..aaf2627c 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs @@ -1,20 +1,29 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; using Microsoft.CodeAnalysis; namespace SimpleModule.Generator; [Generator] -public partial class ModuleDiscovererGenerator : IIncrementalGenerator +public class ModuleDiscovererGenerator : IIncrementalGenerator { + private static readonly IEmitter[] Emitters = + [ + new DiagnosticEmitter(), + new ModuleExtensionsEmitter(), + new EndpointExtensionsEmitter(), + new MenuExtensionsEmitter(), + new RazorComponentExtensionsEmitter(), + new ViewPagesEmitter(), + new JsonResolverEmitter(), + new TypeScriptDefinitionsEmitter(), + new HostDbContextEmitter(), + ]; + public void Initialize(IncrementalGeneratorInitializationContext context) { // Extract an equatable data model from the compilation so the incremental // pipeline can cache results and skip re-generation when nothing changes. var dataProvider = context.CompilationProvider.Select( - static (compilation, _) => ExtractDiscoveryData(compilation) + static (compilation, _) => SymbolDiscovery.Extract(compilation) ); context.RegisterSourceOutput( @@ -24,416 +33,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context) if (data.Modules.Length == 0) return; - GenerateModuleExtensions(spc, data.Modules, data.DtoTypes.Length > 0); - GenerateEndpointExtensions(spc, data.Modules); - GenerateMenuExtensions(spc, data.Modules); - GenerateRazorComponentExtensions(spc, data.Modules); - GenerateViewPages(spc, data.Modules); - - if (data.DtoTypes.Length > 0) + foreach (var emitter in Emitters) { - GenerateJsonResolver(spc, data.DtoTypes); - GenerateTypeScriptDefinitions(spc, data.DtoTypes); + emitter.Emit(spc, data); } } ); } - - private static DiscoveryData ExtractDiscoveryData(Compilation compilation) - { - var moduleAttributeSymbol = compilation.GetTypeByMetadataName( - "SimpleModule.Core.ModuleAttribute" - ); - if (moduleAttributeSymbol is null) - return DiscoveryData.Empty; - - var dtoAttributeSymbol = compilation.GetTypeByMetadataName( - "SimpleModule.Core.DtoAttribute" - ); - - var endpointInterfaceSymbol = compilation.GetTypeByMetadataName( - "SimpleModule.Core.IEndpoint" - ); - - var viewEndpointInterfaceSymbol = compilation.GetTypeByMetadataName( - "SimpleModule.Core.IViewEndpoint" - ); - - var modules = new List(); - - foreach (var reference in compilation.References) - { - if ( - compilation.GetAssemblyOrModuleSymbol(reference) - is not IAssemblySymbol assemblySymbol - ) - continue; - - FindModuleTypes(assemblySymbol.GlobalNamespace, moduleAttributeSymbol, modules); - } - - FindModuleTypes(compilation.Assembly.GlobalNamespace, moduleAttributeSymbol, modules); - - if (modules.Count == 0) - return DiscoveryData.Empty; - - // Discover IEndpoint implementors per module assembly. - // Classification is by interface type: IViewEndpoint -> view, IEndpoint -> API. - if (endpointInterfaceSymbol is not null) - { - foreach (var module in modules) - { - var metadataName = module.FullyQualifiedName.Replace("global::", ""); - var typeSymbol = compilation.GetTypeByMetadataName(metadataName); - if (typeSymbol is null) - continue; - - var assembly = typeSymbol.ContainingAssembly; - FindEndpointTypes( - assembly.GlobalNamespace, - endpointInterfaceSymbol, - viewEndpointInterfaceSymbol, - module.ModuleName, - module.Endpoints, - module.Views - ); - } - } - - var dtoTypes = new List(); - if (dtoAttributeSymbol is not null) - { - foreach (var reference in compilation.References) - { - if ( - compilation.GetAssemblyOrModuleSymbol(reference) - is not IAssemblySymbol assemblySymbol - ) - continue; - - FindDtoTypes(assemblySymbol.GlobalNamespace, dtoAttributeSymbol, dtoTypes); - } - - FindDtoTypes(compilation.Assembly.GlobalNamespace, dtoAttributeSymbol, dtoTypes); - } - - var componentBaseSymbol = compilation.GetTypeByMetadataName( - "Microsoft.AspNetCore.Components.ComponentBase" - ); - if (componentBaseSymbol is not null) - { - var assembliesWithComponents = new HashSet( - SymbolEqualityComparer.Default - ); - foreach (var reference in compilation.References) - { - if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol asm) - continue; - - if (HasComponentBaseDescendant(asm.GlobalNamespace, componentBaseSymbol)) - assembliesWithComponents.Add(asm); - } - - foreach (var module in modules) - { - var metadataName = module.FullyQualifiedName.Replace("global::", ""); - var typeSymbol = compilation.GetTypeByMetadataName(metadataName); - if ( - typeSymbol is not null - && assembliesWithComponents.Contains(typeSymbol.ContainingAssembly) - ) - module.HasRazorComponents = true; - } - } - - return new DiscoveryData( - modules - .Select(m => new ModuleInfoRecord( - m.FullyQualifiedName, - m.ModuleName, - m.HasConfigureServices, - m.HasConfigureEndpoints, - m.HasConfigureMenu, - m.HasRazorComponents, - m.RoutePrefix, - m.ViewPrefix, - m.Endpoints.Select(e => new EndpointInfoRecord(e.FullyQualifiedName)) - .ToImmutableArray(), - m.Views.Select(v => new ViewInfoRecord(v.FullyQualifiedName, v.Page)) - .ToImmutableArray() - )) - .ToImmutableArray(), - dtoTypes - .Select(d => new DtoTypeInfoRecord( - d.FullyQualifiedName, - d.SafeName, - d.Properties.Select(p => new DtoPropertyInfoRecord( - p.Name, - p.TypeFqn, - p.HasSetter - )) - .ToImmutableArray() - )) - .ToImmutableArray() - ); - } - - private static void FindModuleTypes( - INamespaceSymbol namespaceSymbol, - INamedTypeSymbol moduleAttributeSymbol, - List modules - ) - { - foreach (var member in namespaceSymbol.GetMembers()) - { - if (member is INamespaceSymbol childNamespace) - { - FindModuleTypes(childNamespace, moduleAttributeSymbol, modules); - } - else if (member is INamedTypeSymbol typeSymbol) - { - foreach (var attr in typeSymbol.GetAttributes()) - { - if ( - SymbolEqualityComparer.Default.Equals( - attr.AttributeClass, - moduleAttributeSymbol - ) - ) - { - var moduleName = - attr.ConstructorArguments.Length > 0 - ? attr.ConstructorArguments[0].Value as string ?? "" - : ""; - var routePrefix = ""; - var viewPrefix = ""; - foreach (var namedArg in attr.NamedArguments) - { - if ( - namedArg.Key == "RoutePrefix" - && namedArg.Value.Value is string prefix - ) - { - routePrefix = prefix; - } - else if ( - namedArg.Key == "ViewPrefix" - && namedArg.Value.Value is string vPrefix - ) - { - viewPrefix = vPrefix; - } - } - - modules.Add( - new ModuleInfo - { - FullyQualifiedName = typeSymbol.ToDisplayString( - SymbolDisplayFormat.FullyQualifiedFormat - ), - ModuleName = moduleName, - HasConfigureServices = DeclaresMethod( - typeSymbol, - "ConfigureServices" - ), - HasConfigureEndpoints = DeclaresMethod( - typeSymbol, - "ConfigureEndpoints" - ), - HasConfigureMenu = DeclaresMethod(typeSymbol, "ConfigureMenu"), - RoutePrefix = routePrefix, - ViewPrefix = viewPrefix, - } - ); - break; - } - } - } - } - } - - private static void FindEndpointTypes( - INamespaceSymbol namespaceSymbol, - INamedTypeSymbol endpointInterfaceSymbol, - INamedTypeSymbol? viewEndpointInterfaceSymbol, - string moduleName, - List endpoints, - List views - ) - { - foreach (var member in namespaceSymbol.GetMembers()) - { - if (member is INamespaceSymbol childNamespace) - { - FindEndpointTypes( - childNamespace, - endpointInterfaceSymbol, - viewEndpointInterfaceSymbol, - moduleName, - endpoints, - views - ); - } - else if (member is INamedTypeSymbol typeSymbol) - { - if (!typeSymbol.IsAbstract && !typeSymbol.IsStatic) - { - var fqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - - if ( - viewEndpointInterfaceSymbol is not null - && ImplementsInterface(typeSymbol, viewEndpointInterfaceSymbol) - ) - { - var className = typeSymbol.Name; - if (className.EndsWith("Endpoint", StringComparison.Ordinal)) - className = className.Substring( - 0, - className.Length - "Endpoint".Length - ); - else if (className.EndsWith("View", StringComparison.Ordinal)) - className = className.Substring(0, className.Length - "View".Length); - - views.Add( - new ViewInfo - { - FullyQualifiedName = fqn, - Page = moduleName + "/" + className, - } - ); - } - else if (ImplementsInterface(typeSymbol, endpointInterfaceSymbol)) - { - endpoints.Add(new EndpointInfo { FullyQualifiedName = fqn }); - } - } - } - } - } - - private static bool ImplementsInterface( - INamedTypeSymbol typeSymbol, - INamedTypeSymbol interfaceSymbol - ) - { - foreach (var iface in typeSymbol.AllInterfaces) - { - if (SymbolEqualityComparer.Default.Equals(iface, interfaceSymbol)) - return true; - } - return false; - } - - private static bool DeclaresMethod(INamedTypeSymbol typeSymbol, string methodName) - { - foreach (var member in typeSymbol.GetMembers(methodName)) - { - if (member is IMethodSymbol) - return true; - } - return false; - } - - private static void FindDtoTypes( - INamespaceSymbol namespaceSymbol, - INamedTypeSymbol dtoAttributeSymbol, - List dtoTypes - ) - { - foreach (var member in namespaceSymbol.GetMembers()) - { - if (member is INamespaceSymbol childNamespace) - { - FindDtoTypes(childNamespace, dtoAttributeSymbol, dtoTypes); - } - else if (member is INamedTypeSymbol typeSymbol) - { - foreach (var attr in typeSymbol.GetAttributes()) - { - if ( - SymbolEqualityComparer.Default.Equals( - attr.AttributeClass, - dtoAttributeSymbol - ) - ) - { - var fqn = typeSymbol.ToDisplayString( - SymbolDisplayFormat.FullyQualifiedFormat - ); - var safeName = fqn.Replace("global::", "").Replace(".", "_"); - - var properties = new List(); - foreach (var m in typeSymbol.GetMembers()) - { - if ( - m is IPropertySymbol prop - && prop.DeclaredAccessibility == Accessibility.Public - && !prop.IsStatic - && !prop.IsIndexer - && prop.GetMethod is not null - ) - { - properties.Add( - new DtoPropertyInfo - { - Name = prop.Name, - TypeFqn = prop.Type.ToDisplayString( - SymbolDisplayFormat.FullyQualifiedFormat - ), - HasSetter = - prop.SetMethod is not null - && prop.SetMethod.DeclaredAccessibility - == Accessibility.Public, - } - ); - } - } - - dtoTypes.Add( - new DtoTypeInfo - { - FullyQualifiedName = fqn, - SafeName = safeName, - Properties = properties, - } - ); - break; - } - } - } - } - } - - private static bool HasComponentBaseDescendant( - INamespaceSymbol namespaceSymbol, - INamedTypeSymbol componentBaseSymbol - ) - { - foreach (var member in namespaceSymbol.GetMembers()) - { - if (member is INamespaceSymbol childNamespace) - { - if (HasComponentBaseDescendant(childNamespace, componentBaseSymbol)) - return true; - } - else if (member is INamedTypeSymbol typeSymbol) - { - if (InheritsFrom(typeSymbol, componentBaseSymbol)) - return true; - } - } - return false; - } - - private static bool InheritsFrom(INamedTypeSymbol typeSymbol, INamedTypeSymbol baseType) - { - var current = typeSymbol.BaseType; - while (current is not null) - { - if (SymbolEqualityComparer.Default.Equals(current, baseType)) - return true; - current = current.BaseType; - } - return false; - } } diff --git a/framework/SimpleModule.Generator/SimpleModule.Generator.csproj b/framework/SimpleModule.Generator/SimpleModule.Generator.csproj index e7dfe435..9d5df94e 100644 --- a/framework/SimpleModule.Generator/SimpleModule.Generator.csproj +++ b/framework/SimpleModule.Generator/SimpleModule.Generator.csproj @@ -8,4 +8,8 @@ + + + + diff --git a/modules/Admin/src/Admin.Contracts/Admin.Contracts.csproj b/modules/Admin/src/Admin.Contracts/Admin.Contracts.csproj new file mode 100644 index 00000000..b33451b0 --- /dev/null +++ b/modules/Admin/src/Admin.Contracts/Admin.Contracts.csproj @@ -0,0 +1,9 @@ + + + net10.0 + Library + + + + + diff --git a/modules/Admin/src/Admin.Contracts/AdminConstants.cs b/modules/Admin/src/Admin.Contracts/AdminConstants.cs new file mode 100644 index 00000000..77e43600 --- /dev/null +++ b/modules/Admin/src/Admin.Contracts/AdminConstants.cs @@ -0,0 +1,7 @@ +namespace SimpleModule.Admin.Contracts; + +public static class AdminConstants +{ + public const string ModuleName = "Admin"; + public const string RoutePrefix = "/admin"; +} diff --git a/modules/Admin/src/Admin.Contracts/AdminPermissions.cs b/modules/Admin/src/Admin.Contracts/AdminPermissions.cs new file mode 100644 index 00000000..3241c754 --- /dev/null +++ b/modules/Admin/src/Admin.Contracts/AdminPermissions.cs @@ -0,0 +1,8 @@ +namespace SimpleModule.Admin.Contracts; + +public sealed class AdminPermissions +{ + public const string ManageUsers = "Admin.ManageUsers"; + public const string ManageRoles = "Admin.ManageRoles"; + public const string ViewAuditLog = "Admin.ViewAuditLog"; +} diff --git a/modules/Admin/src/Admin.Contracts/AuditLogEntryDto.cs b/modules/Admin/src/Admin.Contracts/AuditLogEntryDto.cs new file mode 100644 index 00000000..04baae1f --- /dev/null +++ b/modules/Admin/src/Admin.Contracts/AuditLogEntryDto.cs @@ -0,0 +1,15 @@ +using SimpleModule.Core; + +namespace SimpleModule.Admin.Contracts; + +[Dto] +public class AuditLogEntryDto +{ + public long Id { get; set; } + public string UserId { get; set; } = string.Empty; + public string PerformedByUserId { get; set; } = string.Empty; + public string PerformedByName { get; set; } = string.Empty; + public string Action { get; set; } = string.Empty; + public string? Details { get; set; } + public DateTimeOffset Timestamp { get; set; } +} diff --git a/modules/Admin/src/Admin/Admin.csproj b/modules/Admin/src/Admin/Admin.csproj new file mode 100644 index 00000000..ecdb5bd8 --- /dev/null +++ b/modules/Admin/src/Admin/Admin.csproj @@ -0,0 +1,20 @@ + + + net10.0 + + + + + + + + + + + + + diff --git a/modules/Admin/src/Admin/AdminDbContext.cs b/modules/Admin/src/Admin/AdminDbContext.cs new file mode 100644 index 00000000..7b72ff34 --- /dev/null +++ b/modules/Admin/src/Admin/AdminDbContext.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SimpleModule.Admin.Contracts; +using SimpleModule.Admin.Entities; +using SimpleModule.Database; + +namespace SimpleModule.Admin; + +public class AdminDbContext( + DbContextOptions options, + IOptions dbOptions +) : DbContext(options) +{ + public DbSet AuditLogEntries => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AdminDbContext).Assembly); + modelBuilder.ApplyModuleSchema(AdminConstants.ModuleName, dbOptions.Value); + } +} diff --git a/modules/Admin/src/Admin/AdminModule.cs b/modules/Admin/src/Admin/AdminModule.cs new file mode 100644 index 00000000..14388153 --- /dev/null +++ b/modules/Admin/src/Admin/AdminModule.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Admin.Contracts; +using SimpleModule.Admin.Services; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Menu; +using SimpleModule.Database; + +namespace SimpleModule.Admin; + +[Module(AdminConstants.ModuleName)] +public class AdminModule : IModule +{ + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddModuleDbContext(configuration, AdminConstants.ModuleName); + services.AddScoped(); + } + + public void ConfigurePermissions(PermissionRegistryBuilder builder) + { + builder.AddPermissions(); + } + + public void ConfigureMenu(IMenuBuilder menus) + { + menus.Add( + new MenuItem + { + Label = "Users", + Url = "/admin/users", + Icon = + """""", + Order = 10, + Section = MenuSection.AdminSidebar, + } + ); + menus.Add( + new MenuItem + { + Label = "Roles", + Url = "/admin/roles", + Icon = + """""", + Order = 11, + Section = MenuSection.AdminSidebar, + } + ); + } +} diff --git a/modules/Admin/src/Admin/Endpoints/Admin/AdminRolesEndpoint.cs b/modules/Admin/src/Admin/Endpoints/Admin/AdminRolesEndpoint.cs new file mode 100644 index 00000000..458cdb89 --- /dev/null +++ b/modules/Admin/src/Admin/Endpoints/Admin/AdminRolesEndpoint.cs @@ -0,0 +1,208 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Admin.Services; +using SimpleModule.Core; +using SimpleModule.Permissions.Contracts; +using SimpleModule.Users; +using SimpleModule.Users.Entities; + +namespace SimpleModule.Admin.Endpoints.Admin; + +public class AdminRolesEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + var group = app.MapGroup("/admin/roles") + .RequireAuthorization(policy => policy.RequireRole("Admin")) + .DisableAntiforgery(); + + // POST / — Create role with permissions + group + .MapPost( + "/", + async ( + [FromForm] string name, + [FromForm] string? description, + HttpContext context, + RoleManager roleManager, + IPermissionContracts permissionContracts, + AuditService audit + ) => + { + var trimmedName = name.Trim(); + if (string.IsNullOrEmpty(trimmedName)) + return Results.Redirect("/admin/roles/create"); + + var role = new ApplicationRole + { + Name = trimmedName, + Description = description?.Trim() is { Length: > 0 } d ? d : null, + }; + + var result = await roleManager.CreateAsync(role); + if (!result.Succeeded) + return Results.Redirect("/admin/roles/create"); + + var form = await context.Request.ReadFormAsync(); + var permissions = form["permissions"] + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(p => p!) + .ToList(); + + if (permissions.Count > 0) + { + await permissionContracts.SetPermissionsForRoleAsync( + RoleId.From(role.Id), + permissions + ); + } + + var adminUserId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + await audit.LogAsync( + role.Id, + adminUserId, + "RoleCreated", + $"Role '{trimmedName}' created" + ); + + return Results.Redirect($"/admin/roles/{role.Id}/edit"); + } + ) + .DisableAntiforgery(); + + // POST /{id} — Update role details + group + .MapPost( + "/{id}", + async ( + string id, + [FromForm] string name, + [FromForm] string? description, + HttpContext context, + RoleManager roleManager, + AuditService audit + ) => + { + var role = await roleManager.FindByIdAsync(id); + if (role is null) + return Results.NotFound(); + + role.Name = name.Trim(); + role.Description = description?.Trim() is { Length: > 0 } d ? d : null; + await roleManager.UpdateAsync(role); + + var adminUserId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + await audit.LogAsync( + role.Id, + adminUserId, + "RoleUpdated", + $"Role '{role.Name}' updated" + ); + + return Results.Redirect($"/admin/roles/{id}/edit?tab=details"); + } + ) + .DisableAntiforgery(); + + // POST /{id}/permissions — Set role permissions + group + .MapPost( + "/{id}/permissions", + async ( + string id, + HttpContext context, + RoleManager roleManager, + IPermissionContracts permissionContracts, + AuditService audit + ) => + { + var role = await roleManager.FindByIdAsync(id); + if (role is null) + return Results.NotFound(); + + var form = await context.Request.ReadFormAsync(); + var newPermissions = form["permissions"] + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(p => p!) + .ToHashSet(StringComparer.Ordinal); + + var roleId = RoleId.From(id); + var currentPermissions = await permissionContracts.GetPermissionsForRoleAsync( + roleId + ); + + var adminUserId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + + // Audit removed permissions + foreach (var perm in currentPermissions.Where(p => !newPermissions.Contains(p))) + { + await audit.LogAsync( + role.Id, + adminUserId, + "RolePermissionRemoved", + $"Permission '{perm}' removed from role '{role.Name}'" + ); + } + + // Audit added permissions + foreach (var perm in newPermissions.Where(p => !currentPermissions.Contains(p))) + { + await audit.LogAsync( + role.Id, + adminUserId, + "RolePermissionAdded", + $"Permission '{perm}' added to role '{role.Name}'" + ); + } + + await permissionContracts.SetPermissionsForRoleAsync(roleId, newPermissions); + + return Results.Redirect($"/admin/roles/{id}/edit?tab=permissions"); + } + ) + .DisableAntiforgery(); + + // DELETE /{id} — Delete role + group.MapDelete( + "/{id}", + async ( + string id, + HttpContext context, + RoleManager roleManager, + UserManager userManager, + IPermissionContracts permissionContracts, + AuditService audit + ) => + { + var role = await roleManager.FindByIdAsync(id); + if (role is null) + return Results.NotFound(); + + var usersInRole = await userManager.GetUsersInRoleAsync(role.Name!); + if (usersInRole.Count > 0) + return Results.BadRequest( + new { error = "Cannot delete a role that has users assigned to it." } + ); + + // Remove role permissions first + await permissionContracts.SetPermissionsForRoleAsync(RoleId.From(id), []); + + var adminUserId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + await audit.LogAsync( + role.Id, + adminUserId, + "RoleDeleted", + $"Role '{role.Name}' deleted" + ); + + await roleManager.DeleteAsync(role); + + return Results.Redirect("/admin/roles"); + } + ); + } +} diff --git a/modules/Admin/src/Admin/Endpoints/Admin/AdminUsersEndpoint.cs b/modules/Admin/src/Admin/Endpoints/Admin/AdminUsersEndpoint.cs new file mode 100644 index 00000000..b03acadf --- /dev/null +++ b/modules/Admin/src/Admin/Endpoints/Admin/AdminUsersEndpoint.cs @@ -0,0 +1,385 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Admin.Services; +using SimpleModule.Core; +using SimpleModule.Permissions.Contracts; +using SimpleModule.Users; +using SimpleModule.Users.Contracts; +using SimpleModule.Users.Entities; + +namespace SimpleModule.Admin.Endpoints.Admin; + +public class AdminUsersEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + var group = app.MapGroup("/admin/users") + .WithTags("Admin") + .RequireAuthorization(policy => policy.RequireRole("Admin")) + .DisableAntiforgery(); + + // POST /admin/users — Create user + group.MapPost( + "/", + async ( + [FromForm] string email, + [FromForm] string displayName, + [FromForm] string password, + [FromForm] bool emailConfirmed, + HttpContext context, + UserManager userManager, + AuditService audit + ) => + { + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + + var user = new ApplicationUser + { + UserName = email, + Email = email, + DisplayName = displayName, + EmailConfirmed = emailConfirmed, + }; + + var result = await userManager.CreateAsync(user, password); + if (!result.Succeeded) + { + return Results.Redirect("/admin/users?error=create-failed"); + } + + var form = await context.Request.ReadFormAsync(); + var roles = form["roles"] + .Where(r => !string.IsNullOrEmpty(r)) + .Select(r => r!) + .ToList(); + if (roles.Count > 0) + { + await userManager.AddToRolesAsync(user, roles); + } + + await audit.LogAsync(user.Id, adminId, "UserCreated", $"Created user {email}"); + + return Results.Redirect($"/admin/users/{user.Id}/edit"); + } + ); + + // POST /admin/users/{id} — Update details + group.MapPost( + "/{id}", + async ( + string id, + [FromForm] string displayName, + [FromForm] string email, + [FromForm] string? emailConfirmed, + HttpContext context, + UserManager userManager, + AuditService audit + ) => + { + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + var user = await userManager.FindByIdAsync(id); + if (user is null) + { + return Results.NotFound(); + } + + user.DisplayName = displayName; + user.Email = email; + user.UserName = email; + user.EmailConfirmed = emailConfirmed is not null; + + await userManager.UpdateAsync(user); + await audit.LogAsync( + id, + adminId, + "UserUpdated", + $"Updated user details for {email}" + ); + + return Results.Redirect($"/admin/users/{id}/edit?tab=details"); + } + ); + + // POST /admin/users/{id}/roles — Set roles + group.MapPost( + "/{id}/roles", + async ( + string id, + HttpContext context, + UserManager userManager, + AuditService audit + ) => + { + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + var user = await userManager.FindByIdAsync(id); + if (user is null) + { + return Results.NotFound(); + } + + var form = await context.Request.ReadFormAsync(); + var newRoles = form["roles"] + .Where(r => !string.IsNullOrEmpty(r)) + .Select(r => r!) + .ToHashSet(); + var currentRoles = (await userManager.GetRolesAsync(user)).ToHashSet(); + + var toRemove = currentRoles.Except(newRoles).ToList(); + var toAdd = newRoles.Except(currentRoles).ToList(); + + if (toRemove.Count > 0) + { + await userManager.RemoveFromRolesAsync(user, toRemove); + foreach (var role in toRemove) + { + await audit.LogAsync(id, adminId, "RoleRemoved", $"Removed role {role}"); + } + } + + if (toAdd.Count > 0) + { + await userManager.AddToRolesAsync(user, toAdd); + foreach (var role in toAdd) + { + await audit.LogAsync(id, adminId, "RoleAdded", $"Added role {role}"); + } + } + + return Results.Redirect($"/admin/users/{id}/edit?tab=roles"); + } + ); + + // POST /admin/users/{id}/permissions — Set direct permissions + group.MapPost( + "/{id}/permissions", + async ( + string id, + HttpContext context, + IPermissionContracts permissionContracts, + AuditService audit + ) => + { + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + var userId = UserId.From(id); + + var form = await context.Request.ReadFormAsync(); + var newPermissions = form["permissions"] + .Where(p => !string.IsNullOrEmpty(p)) + .Select(p => p!) + .ToHashSet(); + + var currentPermissions = await permissionContracts.GetPermissionsForUserAsync( + userId + ); + + foreach (var perm in currentPermissions.Where(p => !newPermissions.Contains(p))) + { + await audit.LogAsync( + id, + adminId, + "PermissionRevoked", + $"Revoked permission {perm}" + ); + } + + foreach (var perm in newPermissions.Where(p => !currentPermissions.Contains(p))) + { + await audit.LogAsync( + id, + adminId, + "PermissionGranted", + $"Granted permission {perm}" + ); + } + + await permissionContracts.SetPermissionsForUserAsync(userId, newPermissions); + + return Results.Redirect($"/admin/users/{id}/edit?tab=roles"); + } + ); + + // POST /admin/users/{id}/reset-password — Reset password + group.MapPost( + "/{id}/reset-password", + async ( + string id, + [FromForm] string newPassword, + HttpContext context, + UserManager userManager, + AuditService audit + ) => + { + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + var user = await userManager.FindByIdAsync(id); + if (user is null) + { + return Results.NotFound(); + } + + var token = await userManager.GeneratePasswordResetTokenAsync(user); + await userManager.ResetPasswordAsync(user, token, newPassword); + await audit.LogAsync(id, adminId, "PasswordReset"); + + return Results.Redirect($"/admin/users/{id}/edit?tab=security"); + } + ); + + // POST /admin/users/{id}/lock — Lock account + group.MapPost( + "/{id}/lock", + async ( + string id, + HttpContext context, + UserManager userManager, + AuditService audit + ) => + { + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + var user = await userManager.FindByIdAsync(id); + if (user is null) + { + return Results.NotFound(); + } + + await userManager.SetLockoutEnabledAsync(user, true); + await userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddYears(100)); + await audit.LogAsync(id, adminId, "AccountLocked"); + + return Results.Redirect($"/admin/users/{id}/edit?tab=security"); + } + ); + + // POST /admin/users/{id}/unlock — Unlock account + group.MapPost( + "/{id}/unlock", + async ( + string id, + HttpContext context, + UserManager userManager, + AuditService audit + ) => + { + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + var user = await userManager.FindByIdAsync(id); + if (user is null) + { + return Results.NotFound(); + } + + await userManager.SetLockoutEndDateAsync(user, null); + await userManager.ResetAccessFailedCountAsync(user); + await audit.LogAsync(id, adminId, "AccountUnlocked"); + + return Results.Redirect($"/admin/users/{id}/edit?tab=security"); + } + ); + + // POST /admin/users/{id}/force-reverify — Force email re-verification + group.MapPost( + "/{id}/force-reverify", + async ( + string id, + HttpContext context, + UserManager userManager, + AuditService audit + ) => + { + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + var user = await userManager.FindByIdAsync(id); + if (user is null) + { + return Results.NotFound(); + } + + user.EmailConfirmed = false; + await userManager.UpdateAsync(user); + await audit.LogAsync(id, adminId, "EmailReverified"); + + return Results.Redirect($"/admin/users/{id}/edit?tab=security"); + } + ); + + // POST /admin/users/{id}/disable-2fa — Disable two-factor authentication + group.MapPost( + "/{id}/disable-2fa", + async ( + string id, + HttpContext context, + UserManager userManager, + AuditService audit + ) => + { + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + var user = await userManager.FindByIdAsync(id); + if (user is null) + { + return Results.NotFound(); + } + + await userManager.SetTwoFactorEnabledAsync(user, false); + await userManager.ResetAuthenticatorKeyAsync(user); + await audit.LogAsync(id, adminId, "TwoFactorDisabled"); + + return Results.Redirect($"/admin/users/{id}/edit?tab=security"); + } + ); + + // POST /admin/users/{id}/deactivate — Soft-delete (deactivate) user + group.MapPost( + "/{id}/deactivate", + async ( + string id, + HttpContext context, + UserManager userManager, + AuditService audit + ) => + { + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + var user = await userManager.FindByIdAsync(id); + if (user is null) + { + return Results.NotFound(); + } + + user.DeactivatedAt = DateTimeOffset.UtcNow; + await userManager.UpdateAsync(user); + await userManager.SetLockoutEnabledAsync(user, true); + await userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddYears(100)); + await audit.LogAsync(id, adminId, "UserDeactivated"); + + return Results.Redirect($"/admin/users/{id}/edit?tab=details"); + } + ); + + // POST /admin/users/{id}/reactivate — Reactivate user + group.MapPost( + "/{id}/reactivate", + async ( + string id, + HttpContext context, + UserManager userManager, + AuditService audit + ) => + { + var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + var user = await userManager.FindByIdAsync(id); + if (user is null) + { + return Results.NotFound(); + } + + user.DeactivatedAt = null; + await userManager.UpdateAsync(user); + await userManager.SetLockoutEndDateAsync(user, null); + await userManager.ResetAccessFailedCountAsync(user); + await audit.LogAsync(id, adminId, "UserReactivated"); + + return Results.Redirect($"/admin/users/{id}/edit?tab=details"); + } + ); + } +} diff --git a/modules/Admin/src/Admin/Entities/AuditLogEntry.cs b/modules/Admin/src/Admin/Entities/AuditLogEntry.cs new file mode 100644 index 00000000..36c3425a --- /dev/null +++ b/modules/Admin/src/Admin/Entities/AuditLogEntry.cs @@ -0,0 +1,11 @@ +namespace SimpleModule.Admin.Entities; + +public class AuditLogEntry +{ + public long Id { get; set; } + public string UserId { get; set; } = string.Empty; + public string PerformedByUserId { get; set; } = string.Empty; + public string Action { get; set; } = string.Empty; + public string? Details { get; set; } + public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/modules/Admin/src/Admin/Entities/AuditLogEntryConfiguration.cs b/modules/Admin/src/Admin/Entities/AuditLogEntryConfiguration.cs new file mode 100644 index 00000000..d80de436 --- /dev/null +++ b/modules/Admin/src/Admin/Entities/AuditLogEntryConfiguration.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace SimpleModule.Admin.Entities; + +public class AuditLogEntryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + builder.Property(e => e.UserId).IsRequired(); + builder.Property(e => e.PerformedByUserId).IsRequired(); + builder.Property(e => e.Action).IsRequired().HasMaxLength(100); + builder.Property(e => e.Details).HasMaxLength(4000); + builder.HasIndex(e => e.UserId); + builder.HasIndex(e => e.Timestamp); + } +} diff --git a/modules/Users/src/Users/Pages/Admin/Roles.tsx b/modules/Admin/src/Admin/Pages/Admin/Roles.tsx similarity index 92% rename from modules/Users/src/Users/Pages/Admin/Roles.tsx rename to modules/Admin/src/Admin/Pages/Admin/Roles.tsx index 8fe8f717..3387656f 100644 --- a/modules/Users/src/Users/Pages/Admin/Roles.tsx +++ b/modules/Admin/src/Admin/Pages/Admin/Roles.tsx @@ -15,6 +15,7 @@ interface Role { name: string; description: string | null; userCount: number; + permissionCount: number; createdAt: string; } @@ -46,6 +47,7 @@ export default function Roles({ roles }: Props) { Name Description Users + Permissions Created @@ -58,6 +60,9 @@ export default function Roles({ roles }: Props) { {role.userCount} + + {role.permissionCount} + {new Date(role.createdAt).toLocaleDateString()} diff --git a/modules/Users/src/Users/Pages/Admin/RolesCreate.tsx b/modules/Admin/src/Admin/Pages/Admin/RolesCreate.tsx similarity index 59% rename from modules/Users/src/Users/Pages/Admin/RolesCreate.tsx rename to modules/Admin/src/Admin/Pages/Admin/RolesCreate.tsx index c36ef24d..edae394b 100644 --- a/modules/Users/src/Users/Pages/Admin/RolesCreate.tsx +++ b/modules/Admin/src/Admin/Pages/Admin/RolesCreate.tsx @@ -1,11 +1,15 @@ import { router } from '@inertiajs/react'; import { Button, Card, CardContent, Input, Label } from '@simplemodule/ui'; +import { PermissionGroups } from '../components/PermissionGroups'; -export default function RolesCreate() { +interface Props { + permissionsByModule: Record; +} + +export default function RolesCreate({ permissionsByModule }: Props) { function handleSubmit(e: React.FormEvent) { e.preventDefault(); - const formData = new FormData(e.currentTarget); - router.post('/admin/roles', formData); + router.post('/admin/roles', new FormData(e.currentTarget)); } return ( @@ -27,11 +31,13 @@ export default function RolesCreate() {

Create Role

-

Add a new application role

+

+ Add a new application role with permissions +

-
+
@@ -40,7 +46,17 @@ export default function RolesCreate() {
- +
+ +
+ +
+
+
diff --git a/modules/Admin/src/Admin/Pages/Admin/RolesEdit.tsx b/modules/Admin/src/Admin/Pages/Admin/RolesEdit.tsx new file mode 100644 index 00000000..f29935a6 --- /dev/null +++ b/modules/Admin/src/Admin/Pages/Admin/RolesEdit.tsx @@ -0,0 +1,169 @@ +import { router } from '@inertiajs/react'; +import { + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Input, + Label, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@simplemodule/ui'; +import { PermissionGroups } from '../components/PermissionGroups'; +import { TabNav } from '../components/TabNav'; + +interface RoleDetail { + id: string; + name: string; + description: string | null; + createdAt: string; +} + +interface UserSummary { + id: string; + displayName: string; + email: string; +} + +interface Props { + role: RoleDetail; + users: UserSummary[]; + rolePermissions: string[]; + permissionsByModule: Record; + tab: string; +} + +const tabs = [ + { id: 'details', label: 'Details' }, + { id: 'permissions', label: 'Permissions' }, + { id: 'users', label: 'Users' }, +]; + +export default function RolesEdit({ + role, + users, + rolePermissions, + permissionsByModule, + tab, +}: Props) { + return ( +
+
+ + + + + +

Edit Role

+
+

+ Created: {new Date(role.createdAt).toLocaleString()} +

+ + + + {tab === 'details' && ( + + +
{ + e.preventDefault(); + router.post(`/admin/roles/${role.id}`, new FormData(e.currentTarget)); + }} + className="space-y-4" + > +
+ + +
+
+ + +
+ +
+
+
+ )} + + {tab === 'permissions' && ( + + + Role Permissions + + +
{ + e.preventDefault(); + router.post(`/admin/roles/${role.id}/permissions`, new FormData(e.currentTarget)); + }} + > + + + +
+
+ )} + + {tab === 'users' && ( + + + Assigned Users ({users.length}) + + + {users.length === 0 ? ( +

No users assigned to this role.

+ ) : ( + + + + Name + Email + + + + + {users.map((u) => ( + + {u.displayName || '\u2014'} + {u.email} + + + + + ))} + +
+ )} +
+
+ )} +
+ ); +} diff --git a/modules/Users/src/Users/Pages/Admin/Users.tsx b/modules/Admin/src/Admin/Pages/Admin/Users.tsx similarity index 58% rename from modules/Users/src/Users/Pages/Admin/Users.tsx rename to modules/Admin/src/Admin/Pages/Admin/Users.tsx index 52595f76..93843fae 100644 --- a/modules/Users/src/Users/Pages/Admin/Users.tsx +++ b/modules/Admin/src/Admin/Pages/Admin/Users.tsx @@ -19,6 +19,7 @@ interface User { emailConfirmed: boolean; roles: string[]; isLockedOut: boolean; + isDeactivated: boolean; createdAt: string; } @@ -30,6 +31,12 @@ interface Props { totalCount: number; } +function userStatus(user: User) { + if (user.isDeactivated) return { label: 'Deactivated', variant: 'secondary' as const }; + if (user.isLockedOut) return { label: 'Locked', variant: 'danger' as const }; + return { label: 'Active', variant: 'success' as const }; +} + export default function Users({ users, search, page, totalPages, totalCount }: Props) { const [searchValue, setSearchValue] = useState(search); @@ -49,6 +56,7 @@ export default function Users({ users, search, page, totalPages, totalCount }: P

Users

{totalCount} total users

+
@@ -73,47 +81,46 @@ export default function Users({ users, search, page, totalPages, totalCount }: P - {users.map((user) => ( - - {user.displayName || '\u2014'} - - {user.email} - {!user.emailConfirmed && ( - - unverified - - )} - - -
- {user.roles.map((role) => ( - - {role} + {users.map((user) => { + const status = userStatus(user); + return ( + + {user.displayName || '\u2014'} + + {user.email} + {!user.emailConfirmed && ( + + unverified - ))} -
-
- - {user.isLockedOut ? ( - Locked - ) : ( - Active - )} - - - {new Date(user.createdAt).toLocaleDateString()} - - - - -
- ))} + )} + + +
+ {user.roles.map((role) => ( + + {role} + + ))} +
+
+ + {status.label} + + + {new Date(user.createdAt).toLocaleDateString()} + + + + + + ); + })}
diff --git a/modules/Admin/src/Admin/Pages/Admin/UsersCreate.tsx b/modules/Admin/src/Admin/Pages/Admin/UsersCreate.tsx new file mode 100644 index 00000000..df8eac68 --- /dev/null +++ b/modules/Admin/src/Admin/Pages/Admin/UsersCreate.tsx @@ -0,0 +1,104 @@ +import { router } from '@inertiajs/react'; +import { Button, Card, CardContent, Input, Label } from '@simplemodule/ui'; + +interface Role { + id: string; + name: string; + description: string | null; +} + +interface Props { + allRoles: Role[]; +} + +export default function UsersCreate({ allRoles }: Props) { + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + router.post('/admin/users', formData); + } + + return ( +
+
+ + + + + +

Create User

+
+

Add a new user account

+ + + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + {allRoles.length > 0 && ( +
+ +
+ {allRoles.map((role) => ( +
+ + +
+ ))} +
+
+ )} + + + +
+
+
+ ); +} diff --git a/modules/Admin/src/Admin/Pages/Admin/UsersEdit.tsx b/modules/Admin/src/Admin/Pages/Admin/UsersEdit.tsx new file mode 100644 index 00000000..50b911d8 --- /dev/null +++ b/modules/Admin/src/Admin/Pages/Admin/UsersEdit.tsx @@ -0,0 +1,399 @@ +import { router } from '@inertiajs/react'; +import { + Badge, + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Input, + Label, +} from '@simplemodule/ui'; +import { useState } from 'react'; +import { ActivityTimeline } from '../components/ActivityTimeline'; +import { PermissionGroups } from '../components/PermissionGroups'; +import { TabNav } from '../components/TabNav'; + +interface UserDetail { + id: string; + displayName: string; + email: string; + emailConfirmed: boolean; + twoFactorEnabled: boolean; + isLockedOut: boolean; + isDeactivated: boolean; + accessFailedCount: number; + createdAt: string; + lastLoginAt: string | null; +} + +interface Role { + id: string; + name: string; + description: string | null; +} + +interface ActivityEntry { + id: number; + action: string; + details: string | null; + performedBy: string; + timestamp: string; +} + +interface Props { + user: UserDetail; + userRoles: string[]; + userPermissions: string[]; + allRoles: Role[]; + permissionsByModule: Record; + activityLog: ActivityEntry[]; + activityTotal: number; + tab: string; +} + +const tabs = [ + { id: 'details', label: 'Details' }, + { id: 'roles', label: 'Roles & Permissions' }, + { id: 'security', label: 'Security' }, + { id: 'activity', label: 'Activity' }, +]; + +export default function UsersEdit({ + user, + userRoles, + userPermissions, + allRoles, + permissionsByModule, + activityLog, + activityTotal, + tab, +}: Props) { + const [activityEntries, setActivityEntries] = useState(activityLog); + const [activityPage, setActivityPage] = useState(1); + + async function loadMoreActivity() { + const nextPage = activityPage + 1; + const res = await fetch(`/admin/users/${user.id}/activity?page=${nextPage}`); + const data = await res.json(); + setActivityEntries((prev) => [...prev, ...data.entries]); + setActivityPage(nextPage); + } + + return ( +
+
+ + + + + +

Edit User

+ {user.isDeactivated && Deactivated} +
+

+ Created: {new Date(user.createdAt).toLocaleString()} + {user.lastLoginAt && ( + Last login: {new Date(user.lastLoginAt).toLocaleString()} + )} +

+ + + + {tab === 'details' && ( + <> + + + Details + + +
{ + e.preventDefault(); + router.post(`/admin/users/${user.id}`, new FormData(e.currentTarget)); + }} + className="space-y-4" + > +
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + + + Account Status + + + {user.isDeactivated ? ( +
+

This account has been deactivated.

+ +
+ ) : ( +
+

+ Deactivating will lock the account and prevent login. +

+ +
+ )} +
+
+ + )} + + {tab === 'roles' && ( + <> + + + Roles + + +
{ + e.preventDefault(); + router.post(`/admin/users/${user.id}/roles`, new FormData(e.currentTarget)); + }} + > +
+ {allRoles.map((role) => ( +
+ + +
+ ))} + {allRoles.length === 0 && ( +

No roles defined.

+ )} +
+ +
+
+
+ + + + Direct Permissions + + +

+ These permissions are granted directly to this user, bypassing role assignments. +

+
{ + e.preventDefault(); + router.post(`/admin/users/${user.id}/permissions`, new FormData(e.currentTarget)); + }} + > + + + +
+
+ + )} + + {tab === 'security' && ( + <> + + + Reset Password + + +
{ + e.preventDefault(); + const formData = new FormData(e.currentTarget); + if (formData.get('newPassword') !== formData.get('confirmPassword')) { + alert('Passwords do not match.'); + return; + } + router.post(`/admin/users/${user.id}/reset-password`, formData); + }} + className="space-y-4" + > +
+ + +
+
+ + +
+ +
+
+
+ + + + Account Lock + + + {user.isLockedOut ? ( +
+

This account is locked.

+ +
+ ) : ( +
+

This account is active.

+ +
+ )} +
+
+ + + + Email Verification + + +

+ Status: {user.emailConfirmed ? 'Verified' : 'Not verified'} +

+ {user.emailConfirmed && ( + + )} +
+
+ + + + Two-Factor Authentication + + +

+ Status: {user.twoFactorEnabled ? 'Enabled' : 'Not enabled'} +

+ {user.twoFactorEnabled && ( + + )} +
+
+ + + + Login Info + + +
+
+ Failed login attempts: + {user.accessFailedCount} +
+
+ Last login: + + {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : 'Never'} + +
+
+
+
+ + )} + + {tab === 'activity' && ( + + + Activity Log + + + + + + )} +
+ ); +} diff --git a/modules/Admin/src/Admin/Pages/components/ActivityTimeline.tsx b/modules/Admin/src/Admin/Pages/components/ActivityTimeline.tsx new file mode 100644 index 00000000..055b7dc8 --- /dev/null +++ b/modules/Admin/src/Admin/Pages/components/ActivityTimeline.tsx @@ -0,0 +1,78 @@ +interface ActivityEntry { + id: number; + action: string; + details: string | null; + performedBy: string; + timestamp: string; +} + +interface ActivityTimelineProps { + entries: ActivityEntry[]; + total: number; + onLoadMore?: () => void; +} + +const actionLabels: Record = { + UserCreated: 'User created', + UserUpdated: 'Details updated', + UserDeactivated: 'Account deactivated', + UserReactivated: 'Account reactivated', + RoleAdded: 'Role added', + RoleRemoved: 'Role removed', + PermissionGranted: 'Permission granted', + PermissionRevoked: 'Permission revoked', + PasswordReset: 'Password reset', + AccountLocked: 'Account locked', + AccountUnlocked: 'Account unlocked', + EmailReverified: 'Email re-verification required', + TwoFactorDisabled: '2FA disabled', +}; + +function timeAgo(timestamp: string): string { + const seconds = Math.floor((Date.now() - new Date(timestamp).getTime()) / 1000); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + return new Date(timestamp).toLocaleDateString(); +} + +export function ActivityTimeline({ entries, total, onLoadMore }: ActivityTimelineProps) { + if (entries.length === 0) { + return

No activity recorded.

; + } + + return ( +
+
+ {entries.map((entry) => ( +
+
+

+ {actionLabels[entry.action] ?? entry.action} + {entry.details && ( + — {entry.details} + )} +

+

+ by {entry.performedBy} · {timeAgo(entry.timestamp)} +

+
+
+ ))} +
+ {entries.length < total && onLoadMore && ( + + )} +
+ ); +} diff --git a/modules/Admin/src/Admin/Pages/components/PermissionGroups.tsx b/modules/Admin/src/Admin/Pages/components/PermissionGroups.tsx new file mode 100644 index 00000000..591dcc88 --- /dev/null +++ b/modules/Admin/src/Admin/Pages/components/PermissionGroups.tsx @@ -0,0 +1,66 @@ +interface PermissionGroupsProps { + permissionsByModule: Record; + selected: string[]; + namePrefix: string; +} + +export function PermissionGroups({ + permissionsByModule, + selected, + namePrefix, +}: PermissionGroupsProps) { + const modules = Object.entries(permissionsByModule); + + if (modules.length === 0) { + return

No permissions registered.

; + } + + return ( +
+ {modules.map(([moduleName, permissions]) => { + const allSelected = permissions.every((p) => selected.includes(p)); + + return ( +
+
+

{moduleName}

+ +
+
+ {permissions.map((perm) => { + const shortName = perm.includes('.') ? perm.split('.').pop() : perm; + return ( + + ); + })} +
+
+ ); + })} +
+ ); +} diff --git a/modules/Admin/src/Admin/Pages/components/TabNav.tsx b/modules/Admin/src/Admin/Pages/components/TabNav.tsx new file mode 100644 index 00000000..08476a27 --- /dev/null +++ b/modules/Admin/src/Admin/Pages/components/TabNav.tsx @@ -0,0 +1,35 @@ +import { router } from '@inertiajs/react'; + +interface Tab { + id: string; + label: string; +} + +interface TabNavProps { + tabs: Tab[]; + activeTab: string; + baseUrl: string; +} + +export function TabNav({ tabs, activeTab, baseUrl }: TabNavProps) { + return ( +
+ +
+ ); +} diff --git a/modules/Admin/src/Admin/Pages/index.ts b/modules/Admin/src/Admin/Pages/index.ts new file mode 100644 index 00000000..b337e440 --- /dev/null +++ b/modules/Admin/src/Admin/Pages/index.ts @@ -0,0 +1,15 @@ +import Roles from './Admin/Roles'; +import RolesCreate from './Admin/RolesCreate'; +import RolesEdit from './Admin/RolesEdit'; +import Users from './Admin/Users'; +import UsersCreate from './Admin/UsersCreate'; +import UsersEdit from './Admin/UsersEdit'; + +export const pages: Record = { + 'Admin/Admin/Users': Users, + 'Admin/Admin/UsersCreate': UsersCreate, + 'Admin/Admin/UsersEdit': UsersEdit, + 'Admin/Admin/Roles': Roles, + 'Admin/Admin/RolesCreate': RolesCreate, + 'Admin/Admin/RolesEdit': RolesEdit, +}; diff --git a/modules/Admin/src/Admin/Services/AuditService.cs b/modules/Admin/src/Admin/Services/AuditService.cs new file mode 100644 index 00000000..6a3cbd5b --- /dev/null +++ b/modules/Admin/src/Admin/Services/AuditService.cs @@ -0,0 +1,26 @@ +using SimpleModule.Admin.Entities; + +namespace SimpleModule.Admin.Services; + +public class AuditService(AdminDbContext db) +{ + public async Task LogAsync( + string userId, + string performedByUserId, + string action, + string? details = null + ) + { + db.AuditLogEntries.Add( + new AuditLogEntry + { + UserId = userId, + PerformedByUserId = performedByUserId, + Action = action, + Details = details, + Timestamp = DateTimeOffset.UtcNow, + } + ); + await db.SaveChangesAsync(); + } +} diff --git a/modules/Admin/src/Admin/Views/Admin/RolesCreateEndpoint.cs b/modules/Admin/src/Admin/Views/Admin/RolesCreateEndpoint.cs new file mode 100644 index 00000000..f0ef1ef1 --- /dev/null +++ b/modules/Admin/src/Admin/Views/Admin/RolesCreateEndpoint.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Inertia; + +namespace SimpleModule.Admin.Views.Admin; + +public class RolesCreateEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/admin/roles/create", + (PermissionRegistry permissionRegistry) => + { + var permissionsByModule = permissionRegistry.ByModule.ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value.ToList() + ); + + return Inertia.Render("Admin/Admin/RolesCreate", new { permissionsByModule }); + } + ) + .RequireAuthorization(policy => policy.RequireRole("Admin")); + } +} diff --git a/modules/Admin/src/Admin/Views/Admin/RolesEditEndpoint.cs b/modules/Admin/src/Admin/Views/Admin/RolesEditEndpoint.cs new file mode 100644 index 00000000..f9496454 --- /dev/null +++ b/modules/Admin/src/Admin/Views/Admin/RolesEditEndpoint.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Inertia; +using SimpleModule.Permissions.Contracts; +using SimpleModule.Users; +using SimpleModule.Users.Entities; + +namespace SimpleModule.Admin.Views.Admin; + +public class RolesEditEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/admin/roles/{id}/edit", + async ( + string id, + RoleManager roleManager, + UserManager userManager, + IPermissionContracts permissionContracts, + PermissionRegistry permissionRegistry, + string? tab + ) => + { + var role = await roleManager.FindByIdAsync(id); + if (role is null) + return Results.NotFound(); + + var usersInRole = await userManager.GetUsersInRoleAsync(role.Name!); + var users = usersInRole + .Select(u => new + { + id = u.Id, + displayName = u.DisplayName, + email = u.Email, + }) + .ToList(); + + var rolePermissions = ( + await permissionContracts.GetPermissionsForRoleAsync(RoleId.From(id)) + ).ToList(); + + var permissionsByModule = permissionRegistry.ByModule.ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value.ToList() + ); + + return Inertia.Render( + "Admin/Admin/RolesEdit", + new + { + role = new + { + id = role.Id, + name = role.Name, + description = role.Description, + createdAt = role.CreatedAt.ToString("O"), + }, + users, + rolePermissions, + permissionsByModule, + tab = tab ?? "details", + } + ); + } + ) + .RequireAuthorization(policy => policy.RequireRole("Admin")); + } +} diff --git a/modules/Users/src/Users/Views/Admin/RolesEndpoint.cs b/modules/Admin/src/Admin/Views/Admin/RolesEndpoint.cs similarity index 66% rename from modules/Users/src/Users/Views/Admin/RolesEndpoint.cs rename to modules/Admin/src/Admin/Views/Admin/RolesEndpoint.cs index 6e8c9c60..d8b30255 100644 --- a/modules/Users/src/Users/Views/Admin/RolesEndpoint.cs +++ b/modules/Admin/src/Admin/Views/Admin/RolesEndpoint.cs @@ -4,9 +4,11 @@ using Microsoft.EntityFrameworkCore; using SimpleModule.Core; using SimpleModule.Core.Inertia; +using SimpleModule.Permissions.Contracts; +using SimpleModule.Users; using SimpleModule.Users.Entities; -namespace SimpleModule.Users.Views.Admin; +namespace SimpleModule.Admin.Views.Admin; public class RolesEndpoint : IViewEndpoint { @@ -16,7 +18,8 @@ public void Map(IEndpointRouteBuilder app) "/admin/roles", async ( RoleManager roleManager, - UserManager userManager + UserManager userManager, + IPermissionContracts permissionContracts ) => { var roles = await roleManager.Roles.OrderBy(r => r.Name).ToListAsync(); @@ -24,9 +27,12 @@ UserManager userManager var roleList = new List(); foreach (var role in roles) { - var usersInRole = role.Name is not null - ? await userManager.GetUsersInRoleAsync(role.Name) - : []; + var usersInRole = await userManager.GetUsersInRoleAsync(role.Name!); + var rolePermissions = await permissionContracts.GetPermissionsForRoleAsync( + RoleId.From(role.Id) + ); + var permissionCount = rolePermissions.Count; + roleList.Add( new { @@ -34,12 +40,13 @@ UserManager userManager name = role.Name, description = role.Description, userCount = usersInRole.Count, + permissionCount, createdAt = role.CreatedAt.ToString("O"), } ); } - return Inertia.Render("Users/Admin/Roles", new { roles = roleList }); + return Inertia.Render("Admin/Admin/Roles", new { roles = roleList }); } ) .RequireAuthorization(policy => policy.RequireRole("Admin")); diff --git a/modules/Admin/src/Admin/Views/Admin/UsersActivityEndpoint.cs b/modules/Admin/src/Admin/Views/Admin/UsersActivityEndpoint.cs new file mode 100644 index 00000000..34d7a0bf --- /dev/null +++ b/modules/Admin/src/Admin/Views/Admin/UsersActivityEndpoint.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using SimpleModule.Core; +using SimpleModule.Users.Entities; + +namespace SimpleModule.Admin.Views.Admin; + +public class UsersActivityEndpoint : IEndpoint +{ + private const int PageSize = 20; + + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/admin/users/{id}/activity", + async ( + string id, + AdminDbContext adminDb, + UserManager userManager, + int page = 1 + ) => + { + var entries = await adminDb + .AuditLogEntries.Where(e => e.UserId == id) + .OrderByDescending(e => e.Timestamp) + .Skip((page - 1) * PageSize) + .Take(PageSize) + .Select(e => new + { + e.Id, + e.Action, + e.Details, + e.PerformedByUserId, + e.Timestamp, + }) + .ToListAsync(); + + var performerIds = entries.Select(e => e.PerformedByUserId).Distinct().ToList(); + var performers = await userManager + .Users.Where(u => performerIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, u => u.DisplayName); + + var total = await adminDb.AuditLogEntries.CountAsync(e => e.UserId == id); + + return Results.Ok( + new + { + entries = entries.Select(e => new + { + e.Id, + e.Action, + e.Details, + performedBy = performers.GetValueOrDefault( + e.PerformedByUserId, + "Unknown" + ), + timestamp = e.Timestamp.ToString("O"), + }), + total, + page, + totalPages = (int)Math.Ceiling((double)total / PageSize), + } + ); + } + ) + .RequireAuthorization(policy => policy.RequireRole("Admin")); + } +} diff --git a/modules/Admin/src/Admin/Views/Admin/UsersCreateEndpoint.cs b/modules/Admin/src/Admin/Views/Admin/UsersCreateEndpoint.cs new file mode 100644 index 00000000..119487a8 --- /dev/null +++ b/modules/Admin/src/Admin/Views/Admin/UsersCreateEndpoint.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using SimpleModule.Core; +using SimpleModule.Core.Inertia; +using SimpleModule.Users.Entities; + +namespace SimpleModule.Admin.Views.Admin; + +public class UsersCreateEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/admin/users/create", + async (RoleManager roleManager) => + { + var allRoles = await roleManager + .Roles.OrderBy(r => r.Name) + .Select(r => new + { + id = r.Id, + name = r.Name, + description = r.Description, + }) + .ToListAsync(); + + return Inertia.Render("Admin/Admin/UsersCreate", new { allRoles }); + } + ) + .RequireAuthorization(policy => policy.RequireRole("Admin")); + } +} diff --git a/modules/Admin/src/Admin/Views/Admin/UsersEditEndpoint.cs b/modules/Admin/src/Admin/Views/Admin/UsersEditEndpoint.cs new file mode 100644 index 00000000..6d3e3358 --- /dev/null +++ b/modules/Admin/src/Admin/Views/Admin/UsersEditEndpoint.cs @@ -0,0 +1,127 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Inertia; +using SimpleModule.Permissions.Contracts; +using SimpleModule.Users; +using SimpleModule.Users.Contracts; +using SimpleModule.Users.Entities; + +namespace SimpleModule.Admin.Views.Admin; + +public class UsersEditEndpoint : IViewEndpoint +{ + private const int ActivityPageSize = 20; + + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/admin/users/{id}/edit", + async ( + string id, + UserManager userManager, + RoleManager roleManager, + IPermissionContracts permissionContracts, + AdminDbContext adminDb, + PermissionRegistry permissionRegistry, + string? tab + ) => + { + var user = await userManager.FindByIdAsync(id); + if (user is null) + return Results.NotFound(); + + var userRoles = await userManager.GetRolesAsync(user); + var allRoles = await roleManager.Roles.OrderBy(r => r.Name).ToListAsync(); + + // User direct permissions + var userPermissions = ( + await permissionContracts.GetPermissionsForUserAsync(UserId.From(id)) + ).ToList(); + + // Permission registry grouped by module + var permissionsByModule = permissionRegistry.ByModule.ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value.ToList() + ); + + // Activity log (first page) + var activityLog = await adminDb + .AuditLogEntries.Where(e => e.UserId == id) + .OrderByDescending(e => e.Timestamp) + .Take(ActivityPageSize) + .Select(e => new + { + e.Id, + e.Action, + e.Details, + e.PerformedByUserId, + e.Timestamp, + }) + .ToListAsync(); + + // Resolve performer names for activity entries + var performerIds = activityLog + .Select(e => e.PerformedByUserId) + .Distinct() + .ToList(); + var performers = await userManager + .Users.Where(u => performerIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, u => u.DisplayName); + + var activityWithNames = activityLog.Select(e => new + { + e.Id, + e.Action, + e.Details, + performedBy = performers.GetValueOrDefault(e.PerformedByUserId, "Unknown"), + timestamp = e.Timestamp.ToString("O"), + }); + + var activityTotal = await adminDb.AuditLogEntries.CountAsync(e => + e.UserId == id + ); + + return Inertia.Render( + "Admin/Admin/UsersEdit", + new + { + user = new + { + id = user.Id, + displayName = user.DisplayName, + email = user.Email, + emailConfirmed = user.EmailConfirmed, + twoFactorEnabled = user.TwoFactorEnabled, + isLockedOut = user.LockoutEnd.HasValue + && user.LockoutEnd > DateTimeOffset.UtcNow, + isDeactivated = user.DeactivatedAt.HasValue, + accessFailedCount = user.AccessFailedCount, + createdAt = user.CreatedAt.ToString("O"), + lastLoginAt = user.LastLoginAt?.ToString("O"), + }, + userRoles = userRoles.ToList(), + userPermissions, + allRoles = allRoles + .Select(r => new + { + id = r.Id, + name = r.Name, + description = r.Description, + }) + .ToList(), + permissionsByModule, + activityLog = activityWithNames, + activityTotal, + tab = tab ?? "details", + } + ); + } + ) + .RequireAuthorization(policy => policy.RequireRole("Admin")); + } +} diff --git a/modules/Users/src/Users/Views/Admin/UsersEndpoint.cs b/modules/Admin/src/Admin/Views/Admin/UsersEndpoint.cs similarity index 89% rename from modules/Users/src/Users/Views/Admin/UsersEndpoint.cs rename to modules/Admin/src/Admin/Views/Admin/UsersEndpoint.cs index 49274ef9..301c66eb 100644 --- a/modules/Users/src/Users/Views/Admin/UsersEndpoint.cs +++ b/modules/Admin/src/Admin/Views/Admin/UsersEndpoint.cs @@ -6,7 +6,7 @@ using SimpleModule.Core.Inertia; using SimpleModule.Users.Entities; -namespace SimpleModule.Users.Views.Admin; +namespace SimpleModule.Admin.Views.Admin; public class UsersEndpoint : IViewEndpoint { @@ -16,12 +16,7 @@ public void Map(IEndpointRouteBuilder app) { app.MapGet( "/admin/users", - async ( - UserManager userManager, - RoleManager roleManager, - string? search, - int page = 1 - ) => + async (UserManager userManager, string? search, int page = 1) => { var query = userManager.Users.AsQueryable(); @@ -59,13 +54,14 @@ public void Map(IEndpointRouteBuilder app) roles = roles.ToList(), isLockedOut = user.LockoutEnd.HasValue && user.LockoutEnd > DateTimeOffset.UtcNow, + isDeactivated = user.DeactivatedAt.HasValue, createdAt = user.CreatedAt.ToString("O"), } ); } return Inertia.Render( - "Users/Admin/Users", + "Admin/Admin/Users", new { users = userList, diff --git a/modules/Admin/src/Admin/package.json b/modules/Admin/src/Admin/package.json new file mode 100644 index 00000000..a35a18ba --- /dev/null +++ b/modules/Admin/src/Admin/package.json @@ -0,0 +1,13 @@ +{ + "private": true, + "name": "@simplemodule/admin", + "version": "0.0.0", + "scripts": { + "build": "vite build", + "watch": "vite build --watch" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} diff --git a/modules/Admin/src/Admin/types.ts b/modules/Admin/src/Admin/types.ts new file mode 100644 index 00000000..45d04e69 --- /dev/null +++ b/modules/Admin/src/Admin/types.ts @@ -0,0 +1,11 @@ +// Auto-generated from [Dto] types — do not edit +export interface AuditLogEntryDto { + id: number; + userId: string; + performedByUserId: string; + performedByName: string; + action: string; + details: string; + timestamp: string; +} + diff --git a/modules/Admin/src/Admin/vite.config.ts b/modules/Admin/src/Admin/vite.config.ts new file mode 100644 index 00000000..02591fcb --- /dev/null +++ b/modules/Admin/src/Admin/vite.config.ts @@ -0,0 +1,22 @@ +import { resolve } from 'node:path'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react()], + define: { 'process.env.NODE_ENV': JSON.stringify('production') }, + build: { + lib: { + entry: { + 'Admin.pages': resolve(__dirname, 'Pages/index.ts'), + }, + formats: ['es'], + fileName: (_format, entryName) => `${entryName}.js`, + }, + outDir: 'wwwroot', + emptyOutDir: false, + rollupOptions: { + external: ['react', 'react-dom', 'react/jsx-runtime', '@inertiajs/react'], + }, + }, +}); diff --git a/modules/Admin/tests/Admin.Tests/Admin.Tests.csproj b/modules/Admin/tests/Admin.Tests/Admin.Tests.csproj new file mode 100644 index 00000000..cb49abf6 --- /dev/null +++ b/modules/Admin/tests/Admin.Tests/Admin.Tests.csproj @@ -0,0 +1,23 @@ + + + net10.0 + false + Exe + + + + + + + + + + + + + + + + + + diff --git a/modules/Admin/tests/Admin.Tests/GlobalUsings.cs b/modules/Admin/tests/Admin.Tests/GlobalUsings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/modules/Admin/tests/Admin.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/modules/Admin/tests/Admin.Tests/Integration/AdminPermissionsTests.cs b/modules/Admin/tests/Admin.Tests/Integration/AdminPermissionsTests.cs new file mode 100644 index 00000000..ecee536c --- /dev/null +++ b/modules/Admin/tests/Admin.Tests/Integration/AdminPermissionsTests.cs @@ -0,0 +1,173 @@ +using System.Net; +using System.Security.Claims; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Permissions.Contracts; +using SimpleModule.Tests.Shared.Fixtures; +using SimpleModule.Users.Contracts; +using SimpleModule.Users.Entities; + +namespace Admin.Tests.Integration; + +public class AdminPermissionsTests : IClassFixture +{ + private readonly SimpleModuleWebApplicationFactory _factory; + + public AdminPermissionsTests(SimpleModuleWebApplicationFactory factory) => _factory = factory; + + private HttpClient CreateAdminClient() + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions { AllowAutoRedirect = false } + ); + + var claims = new List + { + new(ClaimTypes.Role, "Admin"), + new(ClaimTypes.NameIdentifier, "admin-test-id"), + }; + var claimsValue = string.Join(";", claims.Select(c => $"{c.Type}={c.Value}")); + client.DefaultRequestHeaders.Add("X-Test-Claims", claimsValue); + client.DefaultRequestHeaders.Add("Authorization", "Bearer test-token"); + + return client; + } + + [Fact] + public async Task SetUserPermissions_ValidData_Redirects() + { + using var scope = _factory.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = new ApplicationUser + { + UserName = $"perm-test-{Guid.NewGuid():N}@test.com", + Email = $"perm-test-{Guid.NewGuid():N}@test.com", + DisplayName = "Permission Test User", + }; + await userManager.CreateAsync(user, "TestPass123!"); + + var client = CreateAdminClient(); + using var content = new FormUrlEncodedContent( + new Dictionary { { "permissions", "Admin.ManageUsers" } } + ); + + var response = await client.PostAsync($"/admin/users/{user.Id}/permissions", content); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + } + + [Fact] + public async Task SetRolePermissions_ValidData_Redirects() + { + using var scope = _factory.Services.CreateScope(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + var role = new ApplicationRole + { + Name = $"PermTestRole-{Guid.NewGuid().ToString()[..8]}", + Description = "Test role for permissions", + }; + await roleManager.CreateAsync(role); + + var client = CreateAdminClient(); + using var content = new FormUrlEncodedContent( + new Dictionary { { "permissions", "Admin.ManageUsers" } } + ); + + var response = await client.PostAsync($"/admin/roles/{role.Id}/permissions", content); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + } + + [Fact] + public async Task CreateRoleWithPermissions_AssignsPermissions() + { + var client = CreateAdminClient(); + var roleName = $"WithPerms-{Guid.NewGuid().ToString()[..8]}"; + + using var content = new FormUrlEncodedContent( + new[] + { + new KeyValuePair("name", roleName), + new KeyValuePair("description", "Test"), + new KeyValuePair("permissions", "Admin.ManageUsers"), + new KeyValuePair("permissions", "Admin.ViewAuditLog"), + } + ); + + var response = await client.PostAsync("/admin/roles", content); + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + + using var scope = _factory.Services.CreateScope(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + var role = await roleManager.FindByNameAsync(roleName); + role.Should().NotBeNull(); + + var permContracts = scope.ServiceProvider.GetRequiredService(); + var perms = await permContracts.GetPermissionsForRoleAsync(RoleId.From(role!.Id)); + perms.Should().HaveCount(2); + perms.Should().Contain("Admin.ManageUsers"); + perms.Should().Contain("Admin.ViewAuditLog"); + } + + [Fact] + public async Task DeleteRole_ClearsPermissions() + { + using var scope = _factory.Services.CreateScope(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + var role = new ApplicationRole + { + Name = $"DelPermRole-{Guid.NewGuid().ToString()[..8]}", + Description = "To be deleted", + }; + await roleManager.CreateAsync(role); + + var permContracts = scope.ServiceProvider.GetRequiredService(); + await permContracts.SetPermissionsForRoleAsync( + RoleId.From(role.Id), + ["Admin.ManageUsers", "Admin.ManageRoles"] + ); + + var client = CreateAdminClient(); + var response = await client.DeleteAsync($"/admin/roles/{role.Id}"); + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + + using var scope2 = _factory.Services.CreateScope(); + var permContracts2 = scope2.ServiceProvider.GetRequiredService(); + var perms = await permContracts2.GetPermissionsForRoleAsync(RoleId.From(role.Id)); + perms.Should().BeEmpty(); + } + + [Fact] + public async Task SetUserPermissions_CanBeVerifiedViaContracts() + { + using var scope = _factory.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = new ApplicationUser + { + UserName = $"verify-perm-{Guid.NewGuid():N}@test.com", + Email = $"verify-perm-{Guid.NewGuid():N}@test.com", + DisplayName = "Verify Perm User", + }; + await userManager.CreateAsync(user, "TestPass123!"); + + var client = CreateAdminClient(); + using var content = new FormUrlEncodedContent( + new[] + { + new KeyValuePair("permissions", "Admin.ManageUsers"), + new KeyValuePair("permissions", "Admin.ManageRoles"), + } + ); + + await client.PostAsync($"/admin/users/{user.Id}/permissions", content); + + using var scope2 = _factory.Services.CreateScope(); + var permContracts = scope2.ServiceProvider.GetRequiredService(); + var perms = await permContracts.GetPermissionsForUserAsync(UserId.From(user.Id)); + perms.Should().HaveCount(2); + perms.Should().Contain("Admin.ManageUsers"); + perms.Should().Contain("Admin.ManageRoles"); + } +} diff --git a/modules/Admin/tests/Admin.Tests/Integration/AdminRolesEndpointTests.cs b/modules/Admin/tests/Admin.Tests/Integration/AdminRolesEndpointTests.cs new file mode 100644 index 00000000..49999370 --- /dev/null +++ b/modules/Admin/tests/Admin.Tests/Integration/AdminRolesEndpointTests.cs @@ -0,0 +1,204 @@ +using System.Net; +using System.Security.Claims; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Tests.Shared.Fixtures; +using SimpleModule.Users.Entities; + +namespace Admin.Tests.Integration; + +public class AdminRolesEndpointTests : IClassFixture +{ + private readonly SimpleModuleWebApplicationFactory _factory; + + public AdminRolesEndpointTests(SimpleModuleWebApplicationFactory factory) + { + _factory = factory; + } + + private HttpClient CreateAdminClient() + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions { AllowAutoRedirect = false } + ); + + var claims = new List + { + new(ClaimTypes.Role, "Admin"), + new(ClaimTypes.NameIdentifier, "admin-test-id"), + }; + var claimsValue = string.Join(";", claims.Select(c => $"{c.Type}={c.Value}")); + client.DefaultRequestHeaders.Add("X-Test-Claims", claimsValue); + client.DefaultRequestHeaders.Add("Authorization", "Bearer test-token"); + + return client; + } + + private async Task SeedTestRoleAsync(string? name = null) + { + using var scope = _factory.Services.CreateScope(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + + var roleName = name ?? $"TestRole-{Guid.NewGuid().ToString()[..8]}"; + var role = new ApplicationRole + { + Name = roleName, + Description = "Test role for integration tests", + }; + await roleManager.CreateAsync(role); + + return role.Id; + } + + [Fact] + public async Task GetRoles_AsAdmin_Returns200() + { + var client = CreateAdminClient(); + + var response = await client.GetAsync("/admin/roles"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task GetRoles_Unauthenticated_Returns401() + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions { AllowAutoRedirect = false } + ); + + var response = await client.GetAsync("/admin/roles"); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GetRolesCreate_AsAdmin_Returns200() + { + var client = CreateAdminClient(); + + var response = await client.GetAsync("/admin/roles/create"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task CreateRole_ValidData_Redirects() + { + var client = CreateAdminClient(); + + using var content = new FormUrlEncodedContent( + new Dictionary + { + ["name"] = $"NewRole-{Guid.NewGuid().ToString()[..8]}", + ["description"] = "A new test role", + } + ); + + var response = await client.PostAsync("/admin/roles", content); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + } + + [Fact] + public async Task CreateRole_EmptyName_RedirectsToCreate() + { + var client = CreateAdminClient(); + + using var content = new FormUrlEncodedContent( + new Dictionary { ["name"] = "" } + ); + + var response = await client.PostAsync("/admin/roles", content); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location?.ToString().Should().Contain("/admin/roles/create"); + } + + [Fact] + public async Task GetRolesEdit_ExistingRole_Returns200() + { + var roleId = await SeedTestRoleAsync(); + var client = CreateAdminClient(); + + var response = await client.GetAsync($"/admin/roles/{roleId}/edit"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task GetRolesEdit_NonExistentRole_Returns404() + { + var client = CreateAdminClient(); + + var response = await client.GetAsync("/admin/roles/nonexistent/edit"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task UpdateRole_ValidData_Redirects() + { + var roleId = await SeedTestRoleAsync(); + var client = CreateAdminClient(); + + using var content = new FormUrlEncodedContent( + new Dictionary + { + ["name"] = $"UpdatedRole-{Guid.NewGuid().ToString()[..8]}", + ["description"] = "Updated description", + } + ); + + var response = await client.PostAsync($"/admin/roles/{roleId}", content); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + } + + [Fact] + public async Task DeleteRole_NoUsers_Redirects() + { + var roleId = await SeedTestRoleAsync(); + var client = CreateAdminClient(); + + var response = await client.DeleteAsync($"/admin/roles/{roleId}"); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + } + + [Fact] + public async Task DeleteRole_WithUsers_ReturnsBadRequest() + { + var roleName = $"RoleWithUser-{Guid.NewGuid().ToString()[..8]}"; + var roleId = await SeedTestRoleAsync(roleName); + + using var scope = _factory.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = new ApplicationUser + { + UserName = $"roleuser-{Guid.NewGuid().ToString()[..8]}@example.com", + Email = $"roleuser-{Guid.NewGuid().ToString()[..8]}@example.com", + DisplayName = "Role Test User", + }; + await userManager.CreateAsync(user, "TestPass123!"); + await userManager.AddToRoleAsync(user, roleName); + + var client = CreateAdminClient(); + + var response = await client.DeleteAsync($"/admin/roles/{roleId}"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task DeleteRole_NonExistent_Returns404() + { + var client = CreateAdminClient(); + + var response = await client.DeleteAsync("/admin/roles/nonexistent"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} diff --git a/modules/Admin/tests/Admin.Tests/Integration/AdminUsersEndpointTests.cs b/modules/Admin/tests/Admin.Tests/Integration/AdminUsersEndpointTests.cs new file mode 100644 index 00000000..739e17f6 --- /dev/null +++ b/modules/Admin/tests/Admin.Tests/Integration/AdminUsersEndpointTests.cs @@ -0,0 +1,205 @@ +using System.Net; +using System.Security.Claims; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Tests.Shared.Fixtures; +using SimpleModule.Users.Entities; + +namespace Admin.Tests.Integration; + +public class AdminUsersEndpointTests : IClassFixture +{ + private readonly SimpleModuleWebApplicationFactory _factory; + + public AdminUsersEndpointTests(SimpleModuleWebApplicationFactory factory) + { + _factory = factory; + } + + private HttpClient CreateAdminClient() + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions { AllowAutoRedirect = false } + ); + + var claims = new List + { + new(ClaimTypes.Role, "Admin"), + new(ClaimTypes.NameIdentifier, "admin-test-id"), + }; + var claimsValue = string.Join(";", claims.Select(c => $"{c.Type}={c.Value}")); + client.DefaultRequestHeaders.Add("X-Test-Claims", claimsValue); + client.DefaultRequestHeaders.Add("Authorization", "Bearer test-token"); + + return client; + } + + private async Task SeedTestUserAsync() + { + using var scope = _factory.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + + var userId = Guid.NewGuid().ToString(); + var user = new ApplicationUser + { + Id = userId, + UserName = $"testuser-{userId[..8]}@example.com", + Email = $"testuser-{userId[..8]}@example.com", + DisplayName = "Test User", + EmailConfirmed = true, + }; + await userManager.CreateAsync(user, "TestPass123!"); + + return userId; + } + + [Fact] + public async Task GetUsers_AsAdmin_Returns200() + { + var client = CreateAdminClient(); + + var response = await client.GetAsync("/admin/users"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task GetUsers_Unauthenticated_Returns401() + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions { AllowAutoRedirect = false } + ); + + var response = await client.GetAsync("/admin/users"); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GetUsersCreate_AsAdmin_Returns200() + { + var client = CreateAdminClient(); + + var response = await client.GetAsync("/admin/users/create"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact( + Skip = "UsersEditEndpoint queries AdminDbContext which requires matching DatabaseOptions in test setup" + )] + public async Task GetUsersEdit_ExistingUser_Returns200() + { + var userId = await SeedTestUserAsync(); + var client = CreateAdminClient(); + + var response = await client.GetAsync($"/admin/users/{userId}/edit"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task GetUsersEdit_NonExistentUser_Returns404() + { + var client = CreateAdminClient(); + + var response = await client.GetAsync("/admin/users/nonexistent/edit"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task UpdateUser_ValidData_Redirects() + { + var userId = await SeedTestUserAsync(); + var client = CreateAdminClient(); + + using var content = new FormUrlEncodedContent( + new Dictionary + { + ["displayName"] = "Updated Name", + ["email"] = $"updated-{userId[..8]}@example.com", + } + ); + + var response = await client.PostAsync($"/admin/users/{userId}", content); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + } + + [Fact] + public async Task LockUser_ValidUser_Redirects() + { + var userId = await SeedTestUserAsync(); + var client = CreateAdminClient(); + + var response = await client.PostAsync($"/admin/users/{userId}/lock", null); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + } + + [Fact] + public async Task UnlockUser_ValidUser_Redirects() + { + var userId = await SeedTestUserAsync(); + var client = CreateAdminClient(); + + var response = await client.PostAsync($"/admin/users/{userId}/unlock", null); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + } + + [Fact] + public async Task DeactivateUser_ValidUser_Redirects() + { + var userId = await SeedTestUserAsync(); + var client = CreateAdminClient(); + + var response = await client.PostAsync($"/admin/users/{userId}/deactivate", null); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + } + + [Fact] + public async Task ReactivateUser_ValidUser_Redirects() + { + var userId = await SeedTestUserAsync(); + var client = CreateAdminClient(); + + await client.PostAsync($"/admin/users/{userId}/deactivate", null); + + var response = await client.PostAsync($"/admin/users/{userId}/reactivate", null); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + } + + [Fact] + public async Task ResetPassword_ValidData_Redirects() + { + var userId = await SeedTestUserAsync(); + var client = CreateAdminClient(); + + using var content = new FormUrlEncodedContent( + new Dictionary { ["newPassword"] = "NewTestPass456!" } + ); + + var response = await client.PostAsync($"/admin/users/{userId}/reset-password", content); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + } + + [Fact( + Skip = "UsersActivityEndpoint queries AdminDbContext which requires matching DatabaseOptions in test setup" + )] + public async Task GetActivity_ValidUser_Returns200() + { + var userId = await SeedTestUserAsync(); + var client = CreateAdminClient(); + + var response = await client.GetAsync($"/admin/users/{userId}/activity"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } +} diff --git a/modules/Dashboard/src/Dashboard/DashboardModule.cs b/modules/Dashboard/src/Dashboard/DashboardModule.cs index 53fa68fa..17efc293 100644 --- a/modules/Dashboard/src/Dashboard/DashboardModule.cs +++ b/modules/Dashboard/src/Dashboard/DashboardModule.cs @@ -10,5 +10,18 @@ public class DashboardModule : IModule { public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { } - public void ConfigureMenu(IMenuBuilder menus) { } + public void ConfigureMenu(IMenuBuilder menus) + { + menus.Add( + new MenuItem + { + Label = "Dashboard", + Url = "/", + Icon = + """""", + Order = 10, + Section = MenuSection.AppSidebar, + } + ); + } } diff --git a/modules/Dashboard/src/Dashboard/Pages/Home.tsx b/modules/Dashboard/src/Dashboard/Pages/Home.tsx index 9003f87a..17c142f2 100644 --- a/modules/Dashboard/src/Dashboard/Pages/Home.tsx +++ b/modules/Dashboard/src/Dashboard/Pages/Home.tsx @@ -500,7 +500,7 @@ function LandingView({ isDevelopment }: { isDevelopment: boolean }) {

SimpleModule

- Modular monolith framework for .NET — AOT‑compatible, zero reflection + Modular monolith framework for .NET with compile‑time module discovery

diff --git a/modules/Dashboard/src/Dashboard/Views/HomeEndpoint.cs b/modules/Dashboard/src/Dashboard/Views/HomeEndpoint.cs index 483eb0f3..2a608c1a 100644 --- a/modules/Dashboard/src/Dashboard/Views/HomeEndpoint.cs +++ b/modules/Dashboard/src/Dashboard/Views/HomeEndpoint.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -31,6 +32,7 @@ public void Map(IEndpointRouteBuilder app) ); } ) - .ExcludeFromDescription(); + .ExcludeFromDescription() + .AllowAnonymous(); } } diff --git a/modules/Users/src/Users.Contracts/Constants/AuthConstants.cs b/modules/OpenIddict/src/OpenIddict.Contracts/AuthConstants.cs similarity index 88% rename from modules/Users/src/Users.Contracts/Constants/AuthConstants.cs rename to modules/OpenIddict/src/OpenIddict.Contracts/AuthConstants.cs index 2cff6fad..cfb17935 100644 --- a/modules/Users/src/Users.Contracts/Constants/AuthConstants.cs +++ b/modules/OpenIddict/src/OpenIddict.Contracts/AuthConstants.cs @@ -1,4 +1,4 @@ -namespace SimpleModule.Users.Constants; +namespace SimpleModule.OpenIddict.Contracts; public static class AuthConstants { diff --git a/modules/Users/src/Users.Contracts/Constants/AuthErrorMessages.cs b/modules/OpenIddict/src/OpenIddict.Contracts/AuthErrorMessages.cs similarity index 84% rename from modules/Users/src/Users.Contracts/Constants/AuthErrorMessages.cs rename to modules/OpenIddict/src/OpenIddict.Contracts/AuthErrorMessages.cs index d9e281eb..0e22760a 100644 --- a/modules/Users/src/Users.Contracts/Constants/AuthErrorMessages.cs +++ b/modules/OpenIddict/src/OpenIddict.Contracts/AuthErrorMessages.cs @@ -1,4 +1,4 @@ -namespace SimpleModule.Users.Constants; +namespace SimpleModule.OpenIddict.Contracts; public static class AuthErrorMessages { diff --git a/modules/Users/src/Users.Contracts/Constants/ClientConstants.cs b/modules/OpenIddict/src/OpenIddict.Contracts/ClientConstants.cs similarity index 90% rename from modules/Users/src/Users.Contracts/Constants/ClientConstants.cs rename to modules/OpenIddict/src/OpenIddict.Contracts/ClientConstants.cs index 92752360..a8e3ec53 100644 --- a/modules/Users/src/Users.Contracts/Constants/ClientConstants.cs +++ b/modules/OpenIddict/src/OpenIddict.Contracts/ClientConstants.cs @@ -1,4 +1,4 @@ -namespace SimpleModule.Users.Constants; +namespace SimpleModule.OpenIddict.Contracts; public static class ClientConstants { diff --git a/modules/OpenIddict/src/OpenIddict.Contracts/ConfigKeys.cs b/modules/OpenIddict/src/OpenIddict.Contracts/ConfigKeys.cs new file mode 100644 index 00000000..3a8598e0 --- /dev/null +++ b/modules/OpenIddict/src/OpenIddict.Contracts/ConfigKeys.cs @@ -0,0 +1,10 @@ +namespace SimpleModule.OpenIddict.Contracts; + +public static class ConfigKeys +{ + public const string OpenIddictBaseUrl = "OpenIddict:BaseUrl"; + public const string OpenIddictEncryptionCertPath = "OpenIddict:EncryptionCertificatePath"; + public const string OpenIddictSigningCertPath = "OpenIddict:SigningCertificatePath"; + public const string OpenIddictCertPassword = "OpenIddict:CertificatePassword"; + public const string OpenIddictAdditionalRedirectUris = "OpenIddict:AdditionalRedirectUris"; +} diff --git a/modules/Users/src/Users.Contracts/Constants/ConnectRouteConstants.cs b/modules/OpenIddict/src/OpenIddict.Contracts/ConnectRouteConstants.cs similarity index 86% rename from modules/Users/src/Users.Contracts/Constants/ConnectRouteConstants.cs rename to modules/OpenIddict/src/OpenIddict.Contracts/ConnectRouteConstants.cs index 41db2b7f..b28152f8 100644 --- a/modules/Users/src/Users.Contracts/Constants/ConnectRouteConstants.cs +++ b/modules/OpenIddict/src/OpenIddict.Contracts/ConnectRouteConstants.cs @@ -1,4 +1,4 @@ -namespace SimpleModule.Users.Constants; +namespace SimpleModule.OpenIddict.Contracts; public static class ConnectRouteConstants { diff --git a/modules/OpenIddict/src/OpenIddict.Contracts/OpenIddict.Contracts.csproj b/modules/OpenIddict/src/OpenIddict.Contracts/OpenIddict.Contracts.csproj new file mode 100644 index 00000000..b33451b0 --- /dev/null +++ b/modules/OpenIddict/src/OpenIddict.Contracts/OpenIddict.Contracts.csproj @@ -0,0 +1,9 @@ + + + net10.0 + Library + + + + + diff --git a/modules/OpenIddict/src/OpenIddict.Contracts/OpenIddictModuleConstants.cs b/modules/OpenIddict/src/OpenIddict.Contracts/OpenIddictModuleConstants.cs new file mode 100644 index 00000000..29e8462e --- /dev/null +++ b/modules/OpenIddict/src/OpenIddict.Contracts/OpenIddictModuleConstants.cs @@ -0,0 +1,6 @@ +namespace SimpleModule.OpenIddict.Contracts; + +public static class OpenIddictModuleConstants +{ + public const string ModuleName = "OpenIddict"; +} diff --git a/modules/OpenIddict/src/OpenIddict.Contracts/OpenIddictPermissions.cs b/modules/OpenIddict/src/OpenIddict.Contracts/OpenIddictPermissions.cs new file mode 100644 index 00000000..fe8217a8 --- /dev/null +++ b/modules/OpenIddict/src/OpenIddict.Contracts/OpenIddictPermissions.cs @@ -0,0 +1,6 @@ +namespace SimpleModule.OpenIddict.Contracts; + +public sealed class OpenIddictPermissions +{ + public const string ManageClients = "OpenIddict.ManageClients"; +} diff --git a/modules/Users/src/Users/Endpoints/Connect/AuthorizationEndpoint.cs b/modules/OpenIddict/src/OpenIddict/Endpoints/Connect/AuthorizationEndpoint.cs similarity index 78% rename from modules/Users/src/Users/Endpoints/Connect/AuthorizationEndpoint.cs rename to modules/OpenIddict/src/OpenIddict/Endpoints/Connect/AuthorizationEndpoint.cs index 2f755bc9..ae3d98c4 100644 --- a/modules/Users/src/Users/Endpoints/Connect/AuthorizationEndpoint.cs +++ b/modules/OpenIddict/src/OpenIddict/Endpoints/Connect/AuthorizationEndpoint.cs @@ -5,15 +5,19 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using OpenIddict.Abstractions; using OpenIddict.Server.AspNetCore; using SimpleModule.Core; -using SimpleModule.Users.Constants; +using SimpleModule.OpenIddict.Contracts; +using SimpleModule.Permissions.Contracts; +using SimpleModule.Users; +using SimpleModule.Users.Contracts; using SimpleModule.Users.Entities; using static OpenIddict.Abstractions.OpenIddictConstants; -namespace SimpleModule.Users.Endpoints.Connect; +namespace SimpleModule.OpenIddict.Endpoints.Connect; public class AuthorizationEndpoint : IEndpoint { @@ -24,7 +28,8 @@ public void Map(IEndpointRouteBuilder app) [HttpMethods.Get, HttpMethods.Post], (Delegate)HandleAsync ) - .ExcludeFromDescription(); + .ExcludeFromDescription() + .AllowAnonymous(); } private static async Task HandleAsync(HttpContext context) @@ -79,6 +84,28 @@ await userManager.GetUserAsync(result.Principal) identity.AddClaim(Claims.Role, role); } + // Load permissions via IPermissionContracts + var permissionContracts = + context.RequestServices.GetRequiredService(); + var userId = UserId.From(await userManager.GetUserIdAsync(user)); + + // Get role IDs for permission lookup + var usersDb = context.RequestServices.GetRequiredService(); + var roleIds = await usersDb + .Roles.Where(r => roles.Contains(r.Name!)) + .Select(r => r.Id) + .ToListAsync(); + + var allPermissions = await permissionContracts.GetAllPermissionsForUserAsync( + userId, + roleIds.Select(id => RoleId.From(id)) + ); + + foreach (var permission in allPermissions) + { + identity.AddClaim("permission", permission); + } + identity.SetScopes(request.GetScopes()); foreach (var claim in identity.Claims) @@ -121,6 +148,10 @@ private static IEnumerable GetDestinations(Claim claim, ClaimsIdentity i yield return Destinations.IdentityToken; yield break; + case "permission": + yield return Destinations.AccessToken; + yield break; + default: yield return Destinations.AccessToken; yield break; diff --git a/modules/Users/src/Users/Endpoints/Connect/LogoutEndpoint.cs b/modules/OpenIddict/src/OpenIddict/Endpoints/Connect/LogoutEndpoint.cs similarity index 86% rename from modules/Users/src/Users/Endpoints/Connect/LogoutEndpoint.cs rename to modules/OpenIddict/src/OpenIddict/Endpoints/Connect/LogoutEndpoint.cs index c44342b2..9a9325e6 100644 --- a/modules/Users/src/Users/Endpoints/Connect/LogoutEndpoint.cs +++ b/modules/OpenIddict/src/OpenIddict/Endpoints/Connect/LogoutEndpoint.cs @@ -5,10 +5,10 @@ using Microsoft.Extensions.DependencyInjection; using OpenIddict.Server.AspNetCore; using SimpleModule.Core; -using SimpleModule.Users.Constants; +using SimpleModule.OpenIddict.Contracts; using SimpleModule.Users.Entities; -namespace SimpleModule.Users.Endpoints.Connect; +namespace SimpleModule.OpenIddict.Endpoints.Connect; public class LogoutEndpoint : IEndpoint { @@ -19,7 +19,8 @@ public void Map(IEndpointRouteBuilder app) [HttpMethods.Get, HttpMethods.Post], (Delegate)HandleAsync ) - .ExcludeFromDescription(); + .ExcludeFromDescription() + .AllowAnonymous(); } private static async Task HandleAsync(HttpContext context) diff --git a/modules/Users/src/Users/Endpoints/Connect/UserinfoEndpoint.cs b/modules/OpenIddict/src/OpenIddict/Endpoints/Connect/UserinfoEndpoint.cs similarity index 95% rename from modules/Users/src/Users/Endpoints/Connect/UserinfoEndpoint.cs rename to modules/OpenIddict/src/OpenIddict/Endpoints/Connect/UserinfoEndpoint.cs index 6eca74ba..e3df5bcc 100644 --- a/modules/Users/src/Users/Endpoints/Connect/UserinfoEndpoint.cs +++ b/modules/OpenIddict/src/OpenIddict/Endpoints/Connect/UserinfoEndpoint.cs @@ -6,11 +6,11 @@ using OpenIddict.Abstractions; using OpenIddict.Server.AspNetCore; using SimpleModule.Core; -using SimpleModule.Users.Constants; +using SimpleModule.OpenIddict.Contracts; using SimpleModule.Users.Entities; using static OpenIddict.Abstractions.OpenIddictConstants; -namespace SimpleModule.Users.Endpoints.Connect; +namespace SimpleModule.OpenIddict.Endpoints.Connect; public class UserinfoEndpoint : IEndpoint { diff --git a/modules/OpenIddict/src/OpenIddict/Endpoints/OpenIddict/ClientsActionEndpoint.cs b/modules/OpenIddict/src/OpenIddict/Endpoints/OpenIddict/ClientsActionEndpoint.cs new file mode 100644 index 00000000..d35bdfb2 --- /dev/null +++ b/modules/OpenIddict/src/OpenIddict/Endpoints/OpenIddict/ClientsActionEndpoint.cs @@ -0,0 +1,172 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using OpenIddict.Abstractions; +using SimpleModule.Core; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace SimpleModule.OpenIddict.Endpoints.OpenIddict; + +public class ClientsActionEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + var group = app.MapGroup("/openiddict/clients") + .RequireAuthorization(policy => policy.RequireRole("Admin")) + .DisableAntiforgery(); + + // POST / — Create client + group.MapPost( + "/", + async ( + [FromForm] string clientId, + [FromForm] string displayName, + [FromForm] string clientType, + [FromForm] string? clientSecret, + HttpContext context, + IOpenIddictApplicationManager manager + ) => + { + var descriptor = new OpenIddictApplicationDescriptor + { + ClientId = clientId.Trim(), + DisplayName = displayName.Trim(), + ClientType = clientType, + }; + + if ( + clientType == ClientTypes.Confidential + && !string.IsNullOrWhiteSpace(clientSecret) + ) + { + descriptor.ClientSecret = clientSecret; + } + + var form = await context.Request.ReadFormAsync(); + foreach (var uri in form["redirectUris"].Where(u => !string.IsNullOrWhiteSpace(u))) + { + descriptor.RedirectUris.Add(new Uri(uri!)); + } + + foreach ( + var uri in form["postLogoutUris"].Where(u => !string.IsNullOrWhiteSpace(u)) + ) + { + descriptor.PostLogoutRedirectUris.Add(new Uri(uri!)); + } + + foreach (var perm in form["permissions"].Where(p => !string.IsNullOrWhiteSpace(p))) + { + descriptor.Permissions.Add(perm!); + } + + var application = await manager.CreateAsync(descriptor); + var id = await manager.GetIdAsync(application); + + return Results.Redirect($"/openiddict/clients/{id}/edit"); + } + ); + + // POST /{id} — Update client details + group.MapPost( + "/{id}", + async ( + string id, + [FromForm] string displayName, + [FromForm] string clientType, + IOpenIddictApplicationManager manager + ) => + { + var application = await manager.FindByIdAsync(id); + if (application is null) + return Results.NotFound(); + + var descriptor = new OpenIddictApplicationDescriptor(); + await manager.PopulateAsync(descriptor, application); + + descriptor.DisplayName = displayName.Trim(); + descriptor.ClientType = clientType; + + await manager.UpdateAsync(application, descriptor); + + return Results.Redirect($"/openiddict/clients/{id}/edit?tab=details"); + } + ); + + // POST /{id}/uris — Update redirect URIs + group.MapPost( + "/{id}/uris", + async (string id, HttpContext context, IOpenIddictApplicationManager manager) => + { + var application = await manager.FindByIdAsync(id); + if (application is null) + return Results.NotFound(); + + var descriptor = new OpenIddictApplicationDescriptor(); + await manager.PopulateAsync(descriptor, application); + + descriptor.RedirectUris.Clear(); + descriptor.PostLogoutRedirectUris.Clear(); + + var form = await context.Request.ReadFormAsync(); + foreach (var uri in form["redirectUris"].Where(u => !string.IsNullOrWhiteSpace(u))) + { + descriptor.RedirectUris.Add(new Uri(uri!)); + } + + foreach ( + var uri in form["postLogoutUris"].Where(u => !string.IsNullOrWhiteSpace(u)) + ) + { + descriptor.PostLogoutRedirectUris.Add(new Uri(uri!)); + } + + await manager.UpdateAsync(application, descriptor); + + return Results.Redirect($"/openiddict/clients/{id}/edit?tab=uris"); + } + ); + + // POST /{id}/permissions — Update permissions + group.MapPost( + "/{id}/permissions", + async (string id, HttpContext context, IOpenIddictApplicationManager manager) => + { + var application = await manager.FindByIdAsync(id); + if (application is null) + return Results.NotFound(); + + var descriptor = new OpenIddictApplicationDescriptor(); + await manager.PopulateAsync(descriptor, application); + + descriptor.Permissions.Clear(); + + var form = await context.Request.ReadFormAsync(); + foreach (var perm in form["permissions"].Where(p => !string.IsNullOrWhiteSpace(p))) + { + descriptor.Permissions.Add(perm!); + } + + await manager.UpdateAsync(application, descriptor); + + return Results.Redirect($"/openiddict/clients/{id}/edit?tab=permissions"); + } + ); + + // DELETE /{id} — Delete client + group.MapDelete( + "/{id}", + async (string id, IOpenIddictApplicationManager manager) => + { + var application = await manager.FindByIdAsync(id); + if (application is null) + return Results.NotFound(); + + await manager.DeleteAsync(application); + + return Results.Redirect("/openiddict/clients"); + } + ); + } +} diff --git a/modules/OpenIddict/src/OpenIddict/OpenIddict.csproj b/modules/OpenIddict/src/OpenIddict/OpenIddict.csproj new file mode 100644 index 00000000..ef6ddee7 --- /dev/null +++ b/modules/OpenIddict/src/OpenIddict/OpenIddict.csproj @@ -0,0 +1,24 @@ + + + net10.0 + OpenIddictModule + + + + + + + + + + + + + + + + diff --git a/modules/OpenIddict/src/OpenIddict/OpenIddictAppDbContext.cs b/modules/OpenIddict/src/OpenIddict/OpenIddictAppDbContext.cs new file mode 100644 index 00000000..c32a61f1 --- /dev/null +++ b/modules/OpenIddict/src/OpenIddict/OpenIddictAppDbContext.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using OpenIddict.EntityFrameworkCore.Models; +using SimpleModule.Database; + +namespace SimpleModule.OpenIddict; + +public class OpenIddictAppDbContext( + DbContextOptions options, + IOptions dbOptions +) : DbContext(options) +{ + public DbSet Applications => + Set(); + public DbSet Authorizations => + Set(); + public DbSet Scopes => + Set(); + public DbSet Tokens => + Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.ApplyModuleSchema("OpenIddict", dbOptions.Value); + } +} diff --git a/modules/OpenIddict/src/OpenIddict/OpenIddictModule.cs b/modules/OpenIddict/src/OpenIddict/OpenIddictModule.cs new file mode 100644 index 00000000..14fc520d --- /dev/null +++ b/modules/OpenIddict/src/OpenIddict/OpenIddictModule.cs @@ -0,0 +1,119 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Menu; +using SimpleModule.Database; +using SimpleModule.OpenIddict.Contracts; +using SimpleModule.OpenIddict.Services; + +namespace SimpleModule.OpenIddict; + +[Module(OpenIddictModuleConstants.ModuleName)] +public class OpenIddictModule : IModule +{ + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + // DbContext with OpenIddict EF Core extension + // Note: OpenIddict manages its own tables internally (no public DbSet properties). + // The unified HostDbContext also calls UseOpenIddict() for EF Core migrations. + services.AddModuleDbContext( + configuration, + OpenIddictModuleConstants.ModuleName, + opts => opts.UseOpenIddict() + ); + + // OpenIddict + services + .AddOpenIddict() + .AddCore(options => + { + options.UseEntityFrameworkCore().UseDbContext(); + }) + .AddServer(options => + { + options.AllowAuthorizationCodeFlow().RequireProofKeyForCodeExchange(); + + options.AllowRefreshTokenFlow(); + + options + .SetAuthorizationEndpointUris(ConnectRouteConstants.ConnectAuthorize) + .SetTokenEndpointUris(ConnectRouteConstants.ConnectToken) + .SetEndSessionEndpointUris(ConnectRouteConstants.ConnectEndSession) + .SetUserInfoEndpointUris(ConnectRouteConstants.ConnectUserInfo); + + var encryptionCertPath = configuration[ConfigKeys.OpenIddictEncryptionCertPath]; + var signingCertPath = configuration[ConfigKeys.OpenIddictSigningCertPath]; + + if ( + !string.IsNullOrEmpty(encryptionCertPath) + && !string.IsNullOrEmpty(signingCertPath) + ) + { + // Production: use real certificates + var certPassword = configuration[ConfigKeys.OpenIddictCertPassword]; + options.AddEncryptionCertificate( + System.Security.Cryptography.X509Certificates.X509CertificateLoader.LoadPkcs12FromFile( + encryptionCertPath, + certPassword + ) + ); + options.AddSigningCertificate( + System.Security.Cryptography.X509Certificates.X509CertificateLoader.LoadPkcs12FromFile( + signingCertPath, + certPassword + ) + ); + } + else + { + // Development/Testing: use auto-generated development certificates + options + .AddDevelopmentEncryptionCertificate() + .AddDevelopmentSigningCertificate(); + } + + options.RegisterScopes( + AuthConstants.OpenIdScope, + AuthConstants.ProfileScope, + AuthConstants.EmailScope, + AuthConstants.RolesScope + ); + + options + .UseAspNetCore() + .EnableAuthorizationEndpointPassthrough() + .EnableEndSessionEndpointPassthrough() + .EnableUserInfoEndpointPassthrough(); + }) + .AddValidation(options => + { + options.UseLocalServer(); + options.UseAspNetCore(); + }); + + // Seed service + services.AddHostedService(); + } + + public void ConfigurePermissions(PermissionRegistryBuilder builder) + { + builder.AddPermissions(); + } + + public void ConfigureMenu(IMenuBuilder menus) + { + menus.Add( + new MenuItem + { + Label = "OAuth Clients", + Url = "/openiddict/clients", + Icon = + """""", + Order = 20, + Section = MenuSection.AdminSidebar, + } + ); + } +} diff --git a/modules/OpenIddict/src/OpenIddict/Pages/OpenIddict/Clients.tsx b/modules/OpenIddict/src/OpenIddict/Pages/OpenIddict/Clients.tsx new file mode 100644 index 00000000..3451c529 --- /dev/null +++ b/modules/OpenIddict/src/OpenIddict/Pages/OpenIddict/Clients.tsx @@ -0,0 +1,85 @@ +import { router } from '@inertiajs/react'; +import { + Badge, + Button, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@simplemodule/ui'; + +interface Client { + id: string; + clientId: string; + displayName: string | null; + clientType: string | null; +} + +interface Props { + clients: Client[]; +} + +export default function Clients({ clients }: Props) { + function handleDelete(id: string, clientId: string) { + if (!confirm(`Delete client "${clientId}"?`)) return; + router.delete(`/openiddict/clients/${id}`); + } + + return ( +
+
+
+

Clients

+

+ {clients.length} registered {clients.length === 1 ? 'client' : 'clients'} +

+
+ +
+ + + + + Client ID + Display Name + Type + + + + + {clients.map((client) => ( + + {client.clientId} + {client.displayName || '\u2014'} + + + {client.clientType || 'public'} + + + +
+ + +
+
+
+ ))} +
+
+
+ ); +} diff --git a/modules/OpenIddict/src/OpenIddict/Pages/OpenIddict/ClientsCreate.tsx b/modules/OpenIddict/src/OpenIddict/Pages/OpenIddict/ClientsCreate.tsx new file mode 100644 index 00000000..52519e28 --- /dev/null +++ b/modules/OpenIddict/src/OpenIddict/Pages/OpenIddict/ClientsCreate.tsx @@ -0,0 +1,70 @@ +import { router } from '@inertiajs/react'; +import { Button, Card, CardContent, Input, Label } from '@simplemodule/ui'; +import { useState } from 'react'; + +export default function ClientsCreate() { + const [clientType, setClientType] = useState('public'); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + router.post('/openiddict/clients', new FormData(e.currentTarget)); + } + + return ( +
+
+ + + + + +

Create Client

+
+

Register a new OpenID Connect client

+ + + +
+
+ + +
+
+ + +
+
+ + +
+ {clientType === 'confidential' && ( +
+ + +
+ )} + +
+
+
+
+ ); +} diff --git a/modules/OpenIddict/src/OpenIddict/Pages/OpenIddict/ClientsEdit.tsx b/modules/OpenIddict/src/OpenIddict/Pages/OpenIddict/ClientsEdit.tsx new file mode 100644 index 00000000..0c444ae6 --- /dev/null +++ b/modules/OpenIddict/src/OpenIddict/Pages/OpenIddict/ClientsEdit.tsx @@ -0,0 +1,300 @@ +import { router } from '@inertiajs/react'; +import { + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Checkbox, + Input, + Label, +} from '@simplemodule/ui'; +import { useState } from 'react'; + +interface ClientDetail { + id: string; + clientId: string; + displayName: string | null; + clientType: string | null; +} + +interface Props { + client: ClientDetail; + redirectUris: string[]; + postLogoutUris: string[]; + permissions: string[]; + tab: string; +} + +interface Tab { + id: string; + label: string; +} + +const tabs: Tab[] = [ + { id: 'details', label: 'Details' }, + { id: 'uris', label: 'URIs' }, + { id: 'permissions', label: 'Permissions' }, +]; + +const permissionGroups = [ + { + label: 'Endpoints', + permissions: [ + { value: 'ept:authorization', label: 'Authorization' }, + { value: 'ept:token', label: 'Token' }, + { value: 'ept:end_session', label: 'End Session' }, + { value: 'ept:revocation', label: 'Revocation' }, + { value: 'ept:introspection', label: 'Introspection' }, + ], + }, + { + label: 'Grant Types', + permissions: [ + { value: 'gt:authorization_code', label: 'Authorization Code' }, + { value: 'gt:refresh_token', label: 'Refresh Token' }, + { value: 'gt:client_credentials', label: 'Client Credentials' }, + { value: 'gt:implicit', label: 'Implicit' }, + ], + }, + { + label: 'Response Types', + permissions: [ + { value: 'rst:code', label: 'Code' }, + { value: 'rst:token', label: 'Token' }, + ], + }, + { + label: 'Scopes', + permissions: [ + { value: 'scp:openid', label: 'OpenID' }, + { value: 'scp:profile', label: 'Profile' }, + { value: 'scp:email', label: 'Email' }, + { value: 'scp:roles', label: 'Roles' }, + ], + }, +]; + +function TabNav({ activeTab, baseUrl }: { activeTab: string; baseUrl: string }) { + return ( +
+ +
+ ); +} + +function UriList({ label, name, values }: { label: string; name: string; values: string[] }) { + const [uris, setUris] = useState(values.length > 0 ? values : ['']); + + function addUri() { + setUris([...uris, '']); + } + + function removeUri(index: number) { + setUris(uris.filter((_, i) => i !== index)); + } + + function updateUri(index: number, value: string) { + const updated = [...uris]; + updated[index] = value; + setUris(updated); + } + + return ( +
+ +
+ {uris.map((uri, index) => ( +
+ updateUri(index, e.target.value)} + placeholder="https://..." + /> + +
+ ))} +
+ +
+ ); +} + +export default function ClientsEdit({ + client, + redirectUris, + postLogoutUris, + permissions, + tab, +}: Props) { + const [selectedPermissions, setSelectedPermissions] = useState>(new Set(permissions)); + + function togglePermission(perm: string) { + setSelectedPermissions((prev) => { + const next = new Set(prev); + if (next.has(perm)) { + next.delete(perm); + } else { + next.add(perm); + } + return next; + }); + } + + return ( +
+
+ + + + + +

Edit Client

+
+

+ {client.clientId} +

+ + + + {tab === 'details' && ( + + +
{ + e.preventDefault(); + router.post(`/openiddict/clients/${client.id}`, new FormData(e.currentTarget)); + }} + className="space-y-4" + > +
+ + +
+
+ + +
+ +
+
+
+ )} + + {tab === 'uris' && ( + + + Redirect URIs + + +
{ + e.preventDefault(); + router.post(`/openiddict/clients/${client.id}/uris`, new FormData(e.currentTarget)); + }} + className="space-y-6" + > + + + + +
+
+ )} + + {tab === 'permissions' && ( + + + Permissions + + +
{ + e.preventDefault(); + router.post( + `/openiddict/clients/${client.id}/permissions`, + new FormData(e.currentTarget), + ); + }} + > +
+ {permissionGroups.map((group) => ( +
+

{group.label}

+
+ {group.permissions.map((perm) => ( +
+ togglePermission(perm.value)} + /> + +
+ ))} +
+
+ ))} +
+ +
+
+
+ )} +
+ ); +} diff --git a/modules/OpenIddict/src/OpenIddict/Pages/index.ts b/modules/OpenIddict/src/OpenIddict/Pages/index.ts new file mode 100644 index 00000000..349d9981 --- /dev/null +++ b/modules/OpenIddict/src/OpenIddict/Pages/index.ts @@ -0,0 +1,9 @@ +import Clients from './OpenIddict/Clients'; +import ClientsCreate from './OpenIddict/ClientsCreate'; +import ClientsEdit from './OpenIddict/ClientsEdit'; + +export const pages: Record = { + 'OpenIddictModule/OpenIddict/Clients': Clients, + 'OpenIddictModule/OpenIddict/ClientsCreate': ClientsCreate, + 'OpenIddictModule/OpenIddict/ClientsEdit': ClientsEdit, +}; diff --git a/modules/OpenIddict/src/OpenIddict/Services/OpenIddictSeedService.cs b/modules/OpenIddict/src/OpenIddict/Services/OpenIddictSeedService.cs new file mode 100644 index 00000000..df725417 --- /dev/null +++ b/modules/OpenIddict/src/OpenIddict/Services/OpenIddictSeedService.cs @@ -0,0 +1,108 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenIddict.Abstractions; +using SimpleModule.OpenIddict.Contracts; + +namespace SimpleModule.OpenIddict.Services; + +public partial class OpenIddictSeedService( + IServiceProvider serviceProvider, + IConfiguration configuration, + ILogger logger +) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + try + { + using var scope = serviceProvider.CreateScope(); + + await SeedClientApplicationAsync(scope, cancellationToken); + } +#pragma warning disable CA1031 // Seed service must not crash the host on database errors + catch (Exception ex) +#pragma warning restore CA1031 + { + LogSeedError(logger, ex.Message); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + private async Task SeedClientApplicationAsync( + IServiceScope scope, + CancellationToken cancellationToken + ) + { + var manager = scope.ServiceProvider.GetRequiredService(); + + if ( + await manager.FindByClientIdAsync(ClientConstants.ClientId, cancellationToken) + is not null + ) + { + return; + } + + LogSeedingClient(logger); + + var baseUrl = configuration[ConfigKeys.OpenIddictBaseUrl] ?? ClientConstants.DefaultBaseUrl; + + var descriptor = new OpenIddictApplicationDescriptor + { + ClientId = ClientConstants.ClientId, + DisplayName = ClientConstants.ClientDisplayName, + ClientType = OpenIddictConstants.ClientTypes.Public, + RedirectUris = + { + new Uri($"{baseUrl}{ClientConstants.SwaggerCallbackPath}"), + new Uri($"{baseUrl}{ClientConstants.OAuthCallbackPath}"), + }, + PostLogoutRedirectUris = + { + new Uri($"{baseUrl}{ClientConstants.PostLogoutRedirectPath}"), + }, + Permissions = + { + OpenIddictConstants.Permissions.Endpoints.Authorization, + OpenIddictConstants.Permissions.Endpoints.Token, + OpenIddictConstants.Permissions.Endpoints.EndSession, + OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode, + OpenIddictConstants.Permissions.GrantTypes.RefreshToken, + OpenIddictConstants.Permissions.ResponseTypes.Code, + OpenIddictConstants.Permissions.Scopes.Email, + OpenIddictConstants.Permissions.Scopes.Profile, + OpenIddictConstants.Permissions.Prefixes.Scope + AuthConstants.RolesScope, + }, + Requirements = { OpenIddictConstants.Requirements.Features.ProofKeyForCodeExchange }, + }; + + // Allow additional redirect URIs from configuration + var additionalRedirects = configuration + .GetSection(ConfigKeys.OpenIddictAdditionalRedirectUris) + .Get(); + if (additionalRedirects is not null) + { + foreach (var uri in additionalRedirects) + { + descriptor.RedirectUris.Add(new Uri(uri)); + } + } + + await manager.CreateAsync(descriptor, cancellationToken); + } + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Seeding OpenIddict client application..." + )] + private static partial void LogSeedingClient(ILogger logger); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "OpenIddict seeding skipped due to error: {ErrorMessage}" + )] + private static partial void LogSeedError(ILogger logger, string errorMessage); +} diff --git a/modules/OpenIddict/src/OpenIddict/Views/OpenIddict/ClientsCreateEndpoint.cs b/modules/OpenIddict/src/OpenIddict/Views/OpenIddict/ClientsCreateEndpoint.cs new file mode 100644 index 00000000..4a30e484 --- /dev/null +++ b/modules/OpenIddict/src/OpenIddict/Views/OpenIddict/ClientsCreateEndpoint.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Inertia; + +namespace SimpleModule.OpenIddict.Views.OpenIddict; + +public class ClientsCreateEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/openiddict/clients/create", + () => Inertia.Render("OpenIddictModule/OpenIddict/ClientsCreate", new { }) + ) + .RequireAuthorization(policy => policy.RequireRole("Admin")); + } +} diff --git a/modules/OpenIddict/src/OpenIddict/Views/OpenIddict/ClientsEditEndpoint.cs b/modules/OpenIddict/src/OpenIddict/Views/OpenIddict/ClientsEditEndpoint.cs new file mode 100644 index 00000000..dbb945d4 --- /dev/null +++ b/modules/OpenIddict/src/OpenIddict/Views/OpenIddict/ClientsEditEndpoint.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using OpenIddict.Abstractions; +using SimpleModule.Core; +using SimpleModule.Core.Inertia; + +namespace SimpleModule.OpenIddict.Views.OpenIddict; + +public class ClientsEditEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/openiddict/clients/{id}/edit", + async (string id, IOpenIddictApplicationManager manager, string? tab) => + { + var application = await manager.FindByIdAsync(id); + if (application is null) + return Results.NotFound(); + + var descriptor = new OpenIddictApplicationDescriptor(); + await manager.PopulateAsync(descriptor, application); + + return Inertia.Render( + "OpenIddictModule/OpenIddict/ClientsEdit", + new + { + client = new + { + id = await manager.GetIdAsync(application), + clientId = await manager.GetClientIdAsync(application), + displayName = await manager.GetDisplayNameAsync(application), + clientType = await manager.GetClientTypeAsync(application), + }, + redirectUris = descriptor + .RedirectUris.Select(u => u.ToString()) + .ToList(), + postLogoutUris = descriptor + .PostLogoutRedirectUris.Select(u => u.ToString()) + .ToList(), + permissions = descriptor.Permissions.ToList(), + tab = tab ?? "details", + } + ); + } + ) + .RequireAuthorization(policy => policy.RequireRole("Admin")); + } +} diff --git a/modules/OpenIddict/src/OpenIddict/Views/OpenIddict/ClientsEndpoint.cs b/modules/OpenIddict/src/OpenIddict/Views/OpenIddict/ClientsEndpoint.cs new file mode 100644 index 00000000..56c0c694 --- /dev/null +++ b/modules/OpenIddict/src/OpenIddict/Views/OpenIddict/ClientsEndpoint.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using OpenIddict.Abstractions; +using SimpleModule.Core; +using SimpleModule.Core.Inertia; + +namespace SimpleModule.OpenIddict.Views.OpenIddict; + +public class ClientsEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/openiddict/clients", + async (IOpenIddictApplicationManager manager) => + { + var clients = new List(); + + await foreach (var application in manager.ListAsync()) + { + clients.Add( + new + { + id = await manager.GetIdAsync(application), + clientId = await manager.GetClientIdAsync(application), + displayName = await manager.GetDisplayNameAsync(application), + clientType = await manager.GetClientTypeAsync(application), + } + ); + } + + return Inertia.Render("OpenIddictModule/OpenIddict/Clients", new { clients }); + } + ) + .RequireAuthorization(policy => policy.RequireRole("Admin")); + } +} diff --git a/modules/OpenIddict/src/OpenIddict/package.json b/modules/OpenIddict/src/OpenIddict/package.json new file mode 100644 index 00000000..45838edb --- /dev/null +++ b/modules/OpenIddict/src/OpenIddict/package.json @@ -0,0 +1,13 @@ +{ + "private": true, + "name": "@simplemodule/openiddict", + "version": "0.0.0", + "scripts": { + "build": "vite build", + "watch": "vite build --watch" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} diff --git a/modules/OpenIddict/src/OpenIddict/vite.config.ts b/modules/OpenIddict/src/OpenIddict/vite.config.ts new file mode 100644 index 00000000..32de0a6d --- /dev/null +++ b/modules/OpenIddict/src/OpenIddict/vite.config.ts @@ -0,0 +1,22 @@ +import { resolve } from 'node:path'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react()], + define: { 'process.env.NODE_ENV': JSON.stringify('production') }, + build: { + lib: { + entry: { + 'OpenIddictModule.pages': resolve(__dirname, 'Pages/index.ts'), + }, + formats: ['es'], + fileName: (_format, entryName) => `${entryName}.js`, + }, + outDir: 'wwwroot', + emptyOutDir: false, + rollupOptions: { + external: ['react', 'react-dom', 'react/jsx-runtime', '@inertiajs/react'], + }, + }, +}); diff --git a/modules/OpenIddict/tests/OpenIddict.Tests/GlobalUsings.cs b/modules/OpenIddict/tests/OpenIddict.Tests/GlobalUsings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/modules/OpenIddict/tests/OpenIddict.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/modules/OpenIddict/tests/OpenIddict.Tests/Integration/ClientManagementTests.cs b/modules/OpenIddict/tests/OpenIddict.Tests/Integration/ClientManagementTests.cs new file mode 100644 index 00000000..10fa1523 --- /dev/null +++ b/modules/OpenIddict/tests/OpenIddict.Tests/Integration/ClientManagementTests.cs @@ -0,0 +1,286 @@ +using System.Net; +using System.Security.Claims; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using OpenIddict.Abstractions; +using SimpleModule.Tests.Shared.Fixtures; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace OpenIddict.Tests.Integration; + +public class ClientManagementTests : IClassFixture +{ + private readonly SimpleModuleWebApplicationFactory _factory; + + public ClientManagementTests(SimpleModuleWebApplicationFactory factory) => _factory = factory; + + private HttpClient CreateAdminClient() + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions { AllowAutoRedirect = false } + ); + + var claims = new List + { + new(ClaimTypes.Role, "Admin"), + new(ClaimTypes.NameIdentifier, "admin-test-id"), + }; + var claimsValue = string.Join(";", claims.Select(c => $"{c.Type}={c.Value}")); + client.DefaultRequestHeaders.Add("X-Test-Claims", claimsValue); + client.DefaultRequestHeaders.Add("Authorization", "Bearer test-token"); + + return client; + } + + private async Task SeedTestClientAsync(string? clientId = null) + { + using var scope = _factory.Services.CreateScope(); + var manager = scope.ServiceProvider.GetRequiredService(); + + var id = clientId ?? $"test-client-{Guid.NewGuid():N}"; + var descriptor = new OpenIddictApplicationDescriptor + { + ClientId = id, + DisplayName = $"Test Client {id}", + ClientType = ClientTypes.Public, + }; + + var app = await manager.CreateAsync(descriptor); + return (await manager.GetIdAsync(app))!; + } + + // ── View endpoint tests ────────────────────────────────────────── + + [Fact] + public async Task GetClients_AsAdmin_Returns200() + { + var client = CreateAdminClient(); + + var response = await client.GetAsync("/openiddict/clients"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task GetClients_Unauthenticated_Returns401() + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions { AllowAutoRedirect = false } + ); + + var response = await client.GetAsync("/openiddict/clients"); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GetClientsCreate_AsAdmin_Returns200() + { + var client = CreateAdminClient(); + + var response = await client.GetAsync("/openiddict/clients/create"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task GetClientsEdit_ExistingClient_Returns200() + { + var appId = await SeedTestClientAsync(); + var client = CreateAdminClient(); + + var response = await client.GetAsync($"/openiddict/clients/{appId}/edit"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task GetClientsEdit_NonExistentClient_Returns404() + { + var client = CreateAdminClient(); + + var response = await client.GetAsync("/openiddict/clients/nonexistent-id/edit"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // ── Create tests ───────────────────────────────────────────────── + + [Fact] + public async Task CreateClient_ValidData_Redirects() + { + var client = CreateAdminClient(); + var newClientId = $"public-{Guid.NewGuid():N}"; + + using var content = new FormUrlEncodedContent( + new Dictionary + { + ["clientId"] = newClientId, + ["displayName"] = "Public Test Client", + ["clientType"] = ClientTypes.Public, + } + ); + + var response = await client.PostAsync("/openiddict/clients", content); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location?.ToString().Should().Contain("/openiddict/clients/"); + response.Headers.Location?.ToString().Should().Contain("/edit"); + } + + [Fact] + public async Task CreateClient_ConfidentialType_Redirects() + { + var client = CreateAdminClient(); + var newClientId = $"confidential-{Guid.NewGuid():N}"; + + using var content = new FormUrlEncodedContent( + new Dictionary + { + ["clientId"] = newClientId, + ["displayName"] = "Confidential Test Client", + ["clientType"] = ClientTypes.Confidential, + ["clientSecret"] = "super-secret-value-123", + } + ); + + var response = await client.PostAsync("/openiddict/clients", content); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location?.ToString().Should().Contain("/openiddict/clients/"); + response.Headers.Location?.ToString().Should().Contain("/edit"); + } + + [Fact] + public async Task CreateClient_CanBeRetrievedAfterCreation() + { + var client = CreateAdminClient(); + var newClientId = $"retrieve-{Guid.NewGuid():N}"; + + using var content = new FormUrlEncodedContent( + new Dictionary + { + ["clientId"] = newClientId, + ["displayName"] = "Retrievable Client", + ["clientType"] = ClientTypes.Public, + } + ); + + await client.PostAsync("/openiddict/clients", content); + + using var scope = _factory.Services.CreateScope(); + var manager = scope.ServiceProvider.GetRequiredService(); + + var application = await manager.FindByClientIdAsync(newClientId); + application.Should().NotBeNull(); + + var displayName = await manager.GetDisplayNameAsync(application!); + displayName.Should().Be("Retrievable Client"); + } + + // ── Update tests ───────────────────────────────────────────────── + + [Fact] + public async Task UpdateClient_Details_Redirects() + { + var appId = await SeedTestClientAsync(); + var client = CreateAdminClient(); + + using var content = new FormUrlEncodedContent( + new Dictionary + { + ["displayName"] = "Updated Display Name", + ["clientType"] = ClientTypes.Public, + } + ); + + var response = await client.PostAsync($"/openiddict/clients/{appId}", content); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location?.ToString().Should().Contain($"/openiddict/clients/{appId}/edit"); + } + + [Fact] + public async Task UpdateClient_URIs_Redirects() + { + var appId = await SeedTestClientAsync(); + var client = CreateAdminClient(); + + using var content = new FormUrlEncodedContent( + new[] + { + new KeyValuePair("redirectUris", "https://example.com/callback"), + new KeyValuePair("postLogoutUris", "https://example.com/logout"), + } + ); + + var response = await client.PostAsync($"/openiddict/clients/{appId}/uris", content); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location?.ToString().Should().Contain($"/openiddict/clients/{appId}/edit"); + } + + [Fact] + public async Task UpdateClient_Permissions_Redirects() + { + var appId = await SeedTestClientAsync(); + var client = CreateAdminClient(); + + using var content = new FormUrlEncodedContent( + new[] + { + new KeyValuePair( + "permissions", + Permissions.Endpoints.Authorization + ), + new KeyValuePair("permissions", Permissions.Endpoints.Token), + } + ); + + var response = await client.PostAsync($"/openiddict/clients/{appId}/permissions", content); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location?.ToString().Should().Contain($"/openiddict/clients/{appId}/edit"); + } + + // ── Delete tests ───────────────────────────────────────────────── + + [Fact] + public async Task DeleteClient_ExistingClient_Redirects() + { + var appId = await SeedTestClientAsync(); + var client = CreateAdminClient(); + + var response = await client.DeleteAsync($"/openiddict/clients/{appId}"); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location?.ToString().Should().Be("/openiddict/clients"); + } + + [Fact] + public async Task DeleteClient_NonExistentClient_Returns404() + { + var client = CreateAdminClient(); + + var response = await client.DeleteAsync("/openiddict/clients/nonexistent-id"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DeleteClient_VerifiesRemoval() + { + var clientId = $"delete-verify-{Guid.NewGuid():N}"; + var appId = await SeedTestClientAsync(clientId); + var client = CreateAdminClient(); + + await client.DeleteAsync($"/openiddict/clients/{appId}"); + + using var scope = _factory.Services.CreateScope(); + var manager = scope.ServiceProvider.GetRequiredService(); + + var application = await manager.FindByClientIdAsync(clientId); + application.Should().BeNull(); + } +} diff --git a/modules/OpenIddict/tests/OpenIddict.Tests/Integration/ConnectEndpointTests.cs b/modules/OpenIddict/tests/OpenIddict.Tests/Integration/ConnectEndpointTests.cs new file mode 100644 index 00000000..481449ce --- /dev/null +++ b/modules/OpenIddict/tests/OpenIddict.Tests/Integration/ConnectEndpointTests.cs @@ -0,0 +1,112 @@ +using System.Net; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using SimpleModule.Tests.Shared.Fixtures; + +namespace OpenIddict.Tests.Integration; + +public class ConnectEndpointTests : IClassFixture +{ + private readonly SimpleModuleWebApplicationFactory _factory; + + public ConnectEndpointTests(SimpleModuleWebApplicationFactory factory) => _factory = factory; + + [Fact] + public async Task Authorize_Unauthenticated_ReturnsNonSuccess() + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions { AllowAutoRedirect = false } + ); + + var response = await client.GetAsync( + "/connect/authorize?response_type=code&client_id=simplemodule-client&scope=openid&redirect_uri=https://localhost:5001/oauth-callback" + ); + + // OpenIddict validates the request via its own middleware pipeline; + // without a registered client, this returns 400. With a valid client + // but no authentication, it would redirect to login. + response + .StatusCode.Should() + .BeOneOf( + HttpStatusCode.BadRequest, + HttpStatusCode.Redirect, + HttpStatusCode.Unauthorized + ); + } + + [Fact] + public async Task Authorize_WithoutRequiredParams_ReturnsBadRequest() + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions { AllowAutoRedirect = false } + ); + + // Missing required OpenID Connect parameters + var response = await client.GetAsync("/connect/authorize"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Userinfo_Unauthenticated_ReturnsBadRequestOrUnauthorized() + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions { AllowAutoRedirect = false } + ); + + var response = await client.GetAsync("/connect/userinfo"); + + // OpenIddict rejects the request at the middleware level when no + // valid access token is provided (returns 400 or 401) + response + .StatusCode.Should() + .BeOneOf(HttpStatusCode.BadRequest, HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task EndSession_WithoutToken_ReturnsBadRequestOrSuccess() + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions { AllowAutoRedirect = false } + ); + + var response = await client.GetAsync("/connect/endsession"); + + // OpenIddict validates the end-session request; without a valid + // id_token_hint it may return 400, 200, or redirect + response + .StatusCode.Should() + .BeOneOf(HttpStatusCode.BadRequest, HttpStatusCode.OK, HttpStatusCode.Redirect); + } + + [Fact] + public async Task Token_WithoutCredentials_ReturnsBadRequest() + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions { AllowAutoRedirect = false } + ); + + // Token endpoint requires grant_type and credentials + var response = await client.PostAsync("/connect/token", null); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task ConnectEndpoints_AreRegistered() + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions { AllowAutoRedirect = false } + ); + + // Verify endpoints exist (they return 400 from OpenIddict validation, + // not 404 which would indicate they aren't registered) + var authorizeResponse = await client.GetAsync("/connect/authorize"); + var userinfoResponse = await client.GetAsync("/connect/userinfo"); + var endsessionResponse = await client.GetAsync("/connect/endsession"); + + authorizeResponse.StatusCode.Should().NotBe(HttpStatusCode.NotFound); + userinfoResponse.StatusCode.Should().NotBe(HttpStatusCode.NotFound); + endsessionResponse.StatusCode.Should().NotBe(HttpStatusCode.NotFound); + } +} diff --git a/modules/OpenIddict/tests/OpenIddict.Tests/OpenIddict.Tests.csproj b/modules/OpenIddict/tests/OpenIddict.Tests/OpenIddict.Tests.csproj new file mode 100644 index 00000000..243f6e60 --- /dev/null +++ b/modules/OpenIddict/tests/OpenIddict.Tests/OpenIddict.Tests.csproj @@ -0,0 +1,20 @@ + + + net10.0 + false + Exe + + + + + + + + + + + + + + + diff --git a/modules/Orders/src/Orders.Contracts/CreateOrderRequest.cs b/modules/Orders/src/Orders.Contracts/CreateOrderRequest.cs index 1188d1e3..9662fdce 100644 --- a/modules/Orders/src/Orders.Contracts/CreateOrderRequest.cs +++ b/modules/Orders/src/Orders.Contracts/CreateOrderRequest.cs @@ -1,10 +1,11 @@ -using SimpleModule.Core; +using SimpleModule.Core; +using SimpleModule.Users.Contracts; namespace SimpleModule.Orders.Contracts; [Dto] public class CreateOrderRequest { - public string UserId { get; set; } = string.Empty; + public UserId UserId { get; set; } public List Items { get; set; } = new(); } diff --git a/modules/Orders/src/Orders.Contracts/Events/OrderCreatedEvent.cs b/modules/Orders/src/Orders.Contracts/Events/OrderCreatedEvent.cs index a3881dc9..ef0fb96e 100644 --- a/modules/Orders/src/Orders.Contracts/Events/OrderCreatedEvent.cs +++ b/modules/Orders/src/Orders.Contracts/Events/OrderCreatedEvent.cs @@ -1,5 +1,6 @@ -using SimpleModule.Core.Events; +using SimpleModule.Core.Events; +using SimpleModule.Users.Contracts; namespace SimpleModule.Orders.Contracts.Events; -public sealed record OrderCreatedEvent(int OrderId, string UserId, decimal Total) : IEvent; +public sealed record OrderCreatedEvent(OrderId OrderId, UserId UserId, decimal Total) : IEvent; diff --git a/modules/Orders/src/Orders.Contracts/IOrderContracts.cs b/modules/Orders/src/Orders.Contracts/IOrderContracts.cs index 4eb1945b..1ddd4907 100644 --- a/modules/Orders/src/Orders.Contracts/IOrderContracts.cs +++ b/modules/Orders/src/Orders.Contracts/IOrderContracts.cs @@ -1,10 +1,10 @@ -namespace SimpleModule.Orders.Contracts; +namespace SimpleModule.Orders.Contracts; public interface IOrderContracts { Task> GetAllOrdersAsync(); - Task GetOrderByIdAsync(int id); + Task GetOrderByIdAsync(OrderId id); Task CreateOrderAsync(CreateOrderRequest request); - Task UpdateOrderAsync(int id, UpdateOrderRequest request); - Task DeleteOrderAsync(int id); + Task UpdateOrderAsync(OrderId id, UpdateOrderRequest request); + Task DeleteOrderAsync(OrderId id); } diff --git a/modules/Orders/src/Orders.Contracts/Order.cs b/modules/Orders/src/Orders.Contracts/Order.cs index 1116bf35..8cf3d945 100644 --- a/modules/Orders/src/Orders.Contracts/Order.cs +++ b/modules/Orders/src/Orders.Contracts/Order.cs @@ -1,12 +1,13 @@ -using SimpleModule.Core; +using SimpleModule.Core; +using SimpleModule.Users.Contracts; namespace SimpleModule.Orders.Contracts; [Dto] public class Order { - public int Id { get; set; } - public string UserId { get; set; } = string.Empty; + public OrderId Id { get; set; } + public UserId UserId { get; set; } public List Items { get; set; } = new(); public decimal Total { get; set; } public DateTime CreatedAt { get; set; } diff --git a/modules/Orders/src/Orders.Contracts/OrderId.cs b/modules/Orders/src/Orders.Contracts/OrderId.cs new file mode 100644 index 00000000..c5ceaacd --- /dev/null +++ b/modules/Orders/src/Orders.Contracts/OrderId.cs @@ -0,0 +1,6 @@ +using Vogen; + +namespace SimpleModule.Orders.Contracts; + +[ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] +public readonly partial struct OrderId; diff --git a/modules/Orders/src/Orders.Contracts/OrderItem.cs b/modules/Orders/src/Orders.Contracts/OrderItem.cs index 8244a44c..625c34f3 100644 --- a/modules/Orders/src/Orders.Contracts/OrderItem.cs +++ b/modules/Orders/src/Orders.Contracts/OrderItem.cs @@ -1,10 +1,11 @@ -using SimpleModule.Core; +using SimpleModule.Core; +using SimpleModule.Products.Contracts; namespace SimpleModule.Orders.Contracts; [Dto] public class OrderItem { - public int ProductId { get; set; } + public ProductId ProductId { get; set; } public int Quantity { get; set; } } diff --git a/modules/Orders/src/Orders.Contracts/Orders.Contracts.csproj b/modules/Orders/src/Orders.Contracts/Orders.Contracts.csproj index b33451b0..10bfd20d 100644 --- a/modules/Orders/src/Orders.Contracts/Orders.Contracts.csproj +++ b/modules/Orders/src/Orders.Contracts/Orders.Contracts.csproj @@ -2,8 +2,13 @@ net10.0 Library + $(DefineConstants);VOGEN_NO_VALIDATION + + + + diff --git a/modules/Orders/src/Orders.Contracts/UpdateOrderRequest.cs b/modules/Orders/src/Orders.Contracts/UpdateOrderRequest.cs index d2c6848e..d5d29910 100644 --- a/modules/Orders/src/Orders.Contracts/UpdateOrderRequest.cs +++ b/modules/Orders/src/Orders.Contracts/UpdateOrderRequest.cs @@ -1,10 +1,11 @@ using SimpleModule.Core; +using SimpleModule.Users.Contracts; namespace SimpleModule.Orders.Contracts; [Dto] public class UpdateOrderRequest { - public string UserId { get; set; } = string.Empty; + public UserId UserId { get; set; } public List Items { get; set; } = new(); } diff --git a/modules/Orders/src/Orders/Endpoints/Orders/CreateEndpoint.cs b/modules/Orders/src/Orders/Endpoints/Orders/CreateEndpoint.cs index 5c7c17ad..09b7ee2e 100644 --- a/modules/Orders/src/Orders/Endpoints/Orders/CreateEndpoint.cs +++ b/modules/Orders/src/Orders/Endpoints/Orders/CreateEndpoint.cs @@ -1,7 +1,8 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Endpoints; using SimpleModule.Core.Exceptions; using SimpleModule.Orders.Contracts; @@ -9,21 +10,22 @@ namespace SimpleModule.Orders.Endpoints.Orders; public class CreateEndpoint : IEndpoint { - public void Map(IEndpointRouteBuilder app) - { + public void Map(IEndpointRouteBuilder app) => app.MapPost( - "/", - async (CreateOrderRequest request, IOrderContracts orderContracts) => - { - var validation = CreateRequestValidator.Validate(request); - if (!validation.IsValid) + "/", + (CreateOrderRequest request, IOrderContracts orderContracts) => { - throw new ValidationException(validation.Errors); - } + var validation = CreateRequestValidator.Validate(request); + if (!validation.IsValid) + { + throw new ValidationException(validation.Errors); + } - var order = await orderContracts.CreateOrderAsync(request); - return TypedResults.Created($"{OrdersConstants.RoutePrefix}/{order.Id}", order); - } - ); - } + return CrudEndpoints.Create( + () => orderContracts.CreateOrderAsync(request), + o => $"{OrdersConstants.RoutePrefix}/{o.Id}" + ); + } + ) + .RequirePermission(OrdersPermissions.Create); } diff --git a/modules/Orders/src/Orders/Endpoints/Orders/CreateRequestValidator.cs b/modules/Orders/src/Orders/Endpoints/Orders/CreateRequestValidator.cs index c2c44103..b36bbca5 100644 --- a/modules/Orders/src/Orders/Endpoints/Orders/CreateRequestValidator.cs +++ b/modules/Orders/src/Orders/Endpoints/Orders/CreateRequestValidator.cs @@ -7,52 +7,36 @@ namespace SimpleModule.Orders.Endpoints.Orders; public static class CreateRequestValidator { - private static readonly CompositeFormat _quantityMustBePositiveFormat = CompositeFormat.Parse( + private static readonly CompositeFormat QuantityMustBePositiveFormat = CompositeFormat.Parse( OrdersConstants.ValidationMessages.QuantityMustBePositiveFormat ); public static ValidationResult Validate(CreateOrderRequest request) { - var errors = new Dictionary(); + var builder = new ValidationBuilder() + .AddErrorIf( + string.IsNullOrWhiteSpace(request.UserId.Value), + OrdersConstants.Fields.UserId, + OrdersConstants.ValidationMessages.UserIdRequired + ) + .AddErrorIf( + request.Items is null || request.Items.Count == 0, + OrdersConstants.Fields.Items, + OrdersConstants.ValidationMessages.AtLeastOneItemRequired + ); - if (string.IsNullOrWhiteSpace(request.UserId)) + if (request.Items is { Count: > 0 }) { - errors[OrdersConstants.Fields.UserId] = - [ - OrdersConstants.ValidationMessages.UserIdRequired, - ]; - } - - if (request.Items is null || request.Items.Count == 0) - { - errors[OrdersConstants.Fields.Items] = - [ - OrdersConstants.ValidationMessages.AtLeastOneItemRequired, - ]; - } - else - { - var itemErrors = new List(); for (var i = 0; i < request.Items.Count; i++) { - if (request.Items[i].Quantity <= 0) - { - itemErrors.Add( - string.Format( - CultureInfo.InvariantCulture, - _quantityMustBePositiveFormat, - i - ) - ); - } - } - - if (itemErrors.Count > 0) - { - errors[OrdersConstants.Fields.Items] = [.. itemErrors]; + builder.AddErrorIf( + request.Items[i].Quantity <= 0, + OrdersConstants.Fields.Items, + string.Format(CultureInfo.InvariantCulture, QuantityMustBePositiveFormat, i) + ); } } - return errors.Count > 0 ? ValidationResult.WithErrors(errors) : ValidationResult.Success; + return builder.Build(); } } diff --git a/modules/Orders/src/Orders/Endpoints/Orders/DeleteEndpoint.cs b/modules/Orders/src/Orders/Endpoints/Orders/DeleteEndpoint.cs index 65bd07e2..c03de2ee 100644 --- a/modules/Orders/src/Orders/Endpoints/Orders/DeleteEndpoint.cs +++ b/modules/Orders/src/Orders/Endpoints/Orders/DeleteEndpoint.cs @@ -1,22 +1,19 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Endpoints; using SimpleModule.Orders.Contracts; namespace SimpleModule.Orders.Endpoints.Orders; public class DeleteEndpoint : IEndpoint { - public void Map(IEndpointRouteBuilder app) - { + public void Map(IEndpointRouteBuilder app) => app.MapDelete( - "/{id}", - async (int id, IOrderContracts orderContracts) => - { - await orderContracts.DeleteOrderAsync(id); - return TypedResults.NoContent(); - } - ); - } + "/{id}", + (OrderId id, IOrderContracts orderContracts) => + CrudEndpoints.Delete(() => orderContracts.DeleteOrderAsync(id)) + ) + .RequirePermission(OrdersPermissions.Delete); } diff --git a/modules/Orders/src/Orders/Endpoints/Orders/GetAllEndpoint.cs b/modules/Orders/src/Orders/Endpoints/Orders/GetAllEndpoint.cs index b9169fea..dd9e41d4 100644 --- a/modules/Orders/src/Orders/Endpoints/Orders/GetAllEndpoint.cs +++ b/modules/Orders/src/Orders/Endpoints/Orders/GetAllEndpoint.cs @@ -1,22 +1,19 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Endpoints; using SimpleModule.Orders.Contracts; namespace SimpleModule.Orders.Endpoints.Orders; public class GetAllEndpoint : IEndpoint { - public void Map(IEndpointRouteBuilder app) - { + public void Map(IEndpointRouteBuilder app) => app.MapGet( - "/", - async (IOrderContracts orderContracts) => - { - var orders = await orderContracts.GetAllOrdersAsync(); - return TypedResults.Ok(orders); - } - ); - } + "/", + (IOrderContracts orderContracts) => + CrudEndpoints.GetAll(orderContracts.GetAllOrdersAsync) + ) + .RequirePermission(OrdersPermissions.View); } diff --git a/modules/Orders/src/Orders/Endpoints/Orders/GetByIdEndpoint.cs b/modules/Orders/src/Orders/Endpoints/Orders/GetByIdEndpoint.cs index 9ae9d607..bc7f52c0 100644 --- a/modules/Orders/src/Orders/Endpoints/Orders/GetByIdEndpoint.cs +++ b/modules/Orders/src/Orders/Endpoints/Orders/GetByIdEndpoint.cs @@ -1,23 +1,19 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Endpoints; using SimpleModule.Orders.Contracts; namespace SimpleModule.Orders.Endpoints.Orders; public class GetByIdEndpoint : IEndpoint { - public void Map(IEndpointRouteBuilder app) - { + public void Map(IEndpointRouteBuilder app) => app.MapGet( - "/{id}", - async Task, NotFound>> (int id, IOrderContracts orderContracts) => - { - var order = await orderContracts.GetOrderByIdAsync(id); - return order is not null ? TypedResults.Ok(order) : TypedResults.NotFound(); - } - ); - } + "/{id}", + (OrderId id, IOrderContracts orderContracts) => + CrudEndpoints.GetById(() => orderContracts.GetOrderByIdAsync(id)) + ) + .RequirePermission(OrdersPermissions.View); } diff --git a/modules/Orders/src/Orders/Endpoints/Orders/UpdateEndpoint.cs b/modules/Orders/src/Orders/Endpoints/Orders/UpdateEndpoint.cs index b7d83f94..01c738a3 100644 --- a/modules/Orders/src/Orders/Endpoints/Orders/UpdateEndpoint.cs +++ b/modules/Orders/src/Orders/Endpoints/Orders/UpdateEndpoint.cs @@ -1,8 +1,8 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Endpoints; using SimpleModule.Core.Exceptions; using SimpleModule.Orders.Contracts; @@ -10,30 +10,24 @@ namespace SimpleModule.Orders.Endpoints.Orders; public class UpdateEndpoint : IEndpoint { - public void Map(IEndpointRouteBuilder app) - { + public void Map(IEndpointRouteBuilder app) => app.MapPut( - "/{id}", - async Task, NotFound, ValidationProblem>> ( - int id, - UpdateOrderRequest request, - IOrderContracts orderContracts - ) => - { - var createRequest = new CreateOrderRequest + "/{id}", + (OrderId id, UpdateOrderRequest request, IOrderContracts orderContracts) => { - UserId = request.UserId, - Items = request.Items, - }; - var validation = CreateRequestValidator.Validate(createRequest); - if (!validation.IsValid) - { - throw new ValidationException(validation.Errors); - } + var createRequest = new CreateOrderRequest + { + UserId = request.UserId, + Items = request.Items, + }; + var validation = CreateRequestValidator.Validate(createRequest); + if (!validation.IsValid) + { + throw new ValidationException(validation.Errors); + } - var order = await orderContracts.UpdateOrderAsync(id, request); - return TypedResults.Ok(order); - } - ); - } + return CrudEndpoints.Update(() => orderContracts.UpdateOrderAsync(id, request)); + } + ) + .RequirePermission(OrdersPermissions.Update); } diff --git a/modules/Orders/src/Orders/EntityConfigurations/OrderConfiguration.cs b/modules/Orders/src/Orders/EntityConfigurations/OrderConfiguration.cs new file mode 100644 index 00000000..22c42f82 --- /dev/null +++ b/modules/Orders/src/Orders/EntityConfigurations/OrderConfiguration.cs @@ -0,0 +1,100 @@ +using Bogus; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimpleModule.Orders.Contracts; +using SimpleModule.Products.Contracts; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Orders.EntityConfigurations; + +public class OrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(o => o.Id); + builder.Property(o => o.Id).ValueGeneratedOnAdd(); + builder.Property(o => o.Total).HasColumnType("decimal(18,2)"); + builder.HasMany(o => o.Items).WithOne().HasForeignKey("OrderId"); + + builder.HasData(GenerateSeedOrders()); + } + + internal static List GenerateSeedOrderItems() + { + var orderFaker = new Faker { Random = new Randomizer(99999) }; + var orderItems = new List(); + + for (var i = 1; i <= 5; i++) + { + var itemCount = orderFaker.Random.Int(1, 3); + var usedProductIds = new HashSet(); + for (var j = 0; j < itemCount; j++) + { + int productId; + do + { + productId = orderFaker.Random.Int(1, 10); + } while (!usedProductIds.Add(productId)); + + var quantity = orderFaker.Random.Int(1, 5); + // consume price random to keep sequence consistent + orderFaker.Random.Decimal(10, 1000); + + orderItems.Add( + new + { + OrderId = OrderId.From(i), + ProductId = ProductId.From(productId), + Quantity = quantity, + } + ); + } + } + + return orderItems; + } + + private static Order[] GenerateSeedOrders() + { + var orderFaker = new Faker { Random = new Randomizer(99999) }; + var orders = new List(); + + for (var i = 1; i <= 5; i++) + { + var itemCount = orderFaker.Random.Int(1, 3); + var usedProductIds = new HashSet(); + decimal total = 0; + for (var j = 0; j < itemCount; j++) + { + int productId; + do + { + productId = orderFaker.Random.Int(1, 10); + } while (!usedProductIds.Add(productId)); + + var quantity = orderFaker.Random.Int(1, 5); + var price = orderFaker.Random.Decimal(10, 1000); + total += price * quantity; + } + + orders.Add( + new Order + { + Id = OrderId.From(i), + UserId = UserId.From( + orderFaker + .Random.Int(1, 10) + .ToString(System.Globalization.CultureInfo.InvariantCulture) + ), + Total = Math.Round(total, 2), + CreatedAt = orderFaker.Date.Between( + new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 1, 31, 23, 59, 59, DateTimeKind.Utc) + ), + } + ); + } + + return orders.ToArray(); + } +} diff --git a/modules/Orders/src/Orders/EntityConfigurations/OrderItemConfiguration.cs b/modules/Orders/src/Orders/EntityConfigurations/OrderItemConfiguration.cs new file mode 100644 index 00000000..a7bf2e95 --- /dev/null +++ b/modules/Orders/src/Orders/EntityConfigurations/OrderItemConfiguration.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimpleModule.Orders.Contracts; + +namespace SimpleModule.Orders.EntityConfigurations; + +public class OrderItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey("OrderId", nameof(OrderItem.ProductId)); + + builder.HasData(OrderConfiguration.GenerateSeedOrderItems()); + } +} diff --git a/modules/Orders/src/Orders/OrderService.cs b/modules/Orders/src/Orders/OrderService.cs index 74285614..61303692 100644 --- a/modules/Orders/src/Orders/OrderService.cs +++ b/modules/Orders/src/Orders/OrderService.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using SimpleModule.Core.Events; using SimpleModule.Core.Exceptions; @@ -20,7 +20,7 @@ ILogger logger public async Task> GetAllOrdersAsync() => await db.Orders.Include(o => o.Items).ToListAsync(); - public async Task GetOrderByIdAsync(int id) + public async Task GetOrderByIdAsync(OrderId id) { var order = await db.Orders.Include(o => o.Items).FirstOrDefaultAsync(o => o.Id == id); if (order is null) @@ -71,7 +71,7 @@ public async Task CreateOrderAsync(CreateOrderRequest request) return order; } - public async Task UpdateOrderAsync(int id, UpdateOrderRequest request) + public async Task UpdateOrderAsync(OrderId id, UpdateOrderRequest request) { var order = await db.Orders.Include(o => o.Items).FirstOrDefaultAsync(o => o.Id == id); if (order is null) @@ -110,7 +110,7 @@ public async Task UpdateOrderAsync(int id, UpdateOrderRequest request) return order; } - public async Task DeleteOrderAsync(int id) + public async Task DeleteOrderAsync(OrderId id) { var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id); if (order is null) @@ -125,7 +125,7 @@ public async Task DeleteOrderAsync(int id) } [LoggerMessage(Level = LogLevel.Warning, Message = "Order with ID {OrderId} not found")] - private static partial void LogOrderNotFound(ILogger logger, int orderId); + private static partial void LogOrderNotFound(ILogger logger, OrderId orderId); [LoggerMessage( Level = LogLevel.Information, @@ -133,8 +133,8 @@ public async Task DeleteOrderAsync(int id) )] private static partial void LogOrderCreated( ILogger logger, - int orderId, - string userId, + OrderId orderId, + UserId userId, decimal total ); @@ -144,11 +144,11 @@ decimal total )] private static partial void LogOrderUpdated( ILogger logger, - int orderId, - string userId, + OrderId orderId, + UserId userId, decimal total ); [LoggerMessage(Level = LogLevel.Information, Message = "Order {OrderId} deleted")] - private static partial void LogOrderDeleted(ILogger logger, int orderId); + private static partial void LogOrderDeleted(ILogger logger, OrderId orderId); } diff --git a/modules/Orders/src/Orders/OrdersDbContext.cs b/modules/Orders/src/Orders/OrdersDbContext.cs index 3629b6e8..0046efd4 100644 --- a/modules/Orders/src/Orders/OrdersDbContext.cs +++ b/modules/Orders/src/Orders/OrdersDbContext.cs @@ -1,8 +1,10 @@ -using Bogus; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using SimpleModule.Database; using SimpleModule.Orders.Contracts; +using SimpleModule.Orders.EntityConfigurations; +using SimpleModule.Products.Contracts; +using SimpleModule.Users.Contracts; namespace SimpleModule.Orders; @@ -16,71 +18,21 @@ IOptions dbOptions protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(entity => - { - entity.HasKey(o => o.Id); - entity.Property(o => o.Total).HasColumnType("decimal(18,2)"); - entity.HasMany(o => o.Items).WithOne().HasForeignKey("OrderId"); - }); - - modelBuilder.Entity(entity => - { - entity.HasKey("OrderId", nameof(OrderItem.ProductId)); - }); - - SeedOrders(modelBuilder); - + modelBuilder.ApplyConfiguration(new OrderConfiguration()); + modelBuilder.ApplyConfiguration(new OrderItemConfiguration()); modelBuilder.ApplyModuleSchema("Orders", dbOptions.Value); } - private static void SeedOrders(ModelBuilder modelBuilder) + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { - var orderFaker = new Faker { Random = new Randomizer(99999) }; - - var orders = new List(); - var orderItems = new List(); - - for (var i = 1; i <= 5; i++) - { - var itemCount = orderFaker.Random.Int(1, 3); - var usedProductIds = new HashSet(); - decimal total = 0; - for (var j = 0; j < itemCount; j++) - { - int productId; - do - { - productId = orderFaker.Random.Int(1, 10); - } while (!usedProductIds.Add(productId)); - - var quantity = orderFaker.Random.Int(1, 5); - var price = orderFaker.Random.Decimal(10, 1000); - total += price * quantity; - - orderItems.Add( - new - { - OrderId = i, - ProductId = productId, - Quantity = quantity, - } - ); - } - - orders.Add( - new Order - { - Id = i, - UserId = orderFaker - .Random.Int(1, 10) - .ToString(System.Globalization.CultureInfo.InvariantCulture), - Total = Math.Round(total, 2), - CreatedAt = orderFaker.Date.Recent(30).ToUniversalTime(), - } - ); - } - - modelBuilder.Entity().HasData(orders); - modelBuilder.Entity().HasData(orderItems); + configurationBuilder + .Properties() + .HaveConversion(); + configurationBuilder + .Properties() + .HaveConversion(); + configurationBuilder + .Properties() + .HaveConversion(); } } diff --git a/modules/Orders/src/Orders/OrdersModule.cs b/modules/Orders/src/Orders/OrdersModule.cs index bd196d71..5366094d 100644 --- a/modules/Orders/src/Orders/OrdersModule.cs +++ b/modules/Orders/src/Orders/OrdersModule.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using SimpleModule.Core; +using SimpleModule.Core.Authorization; using SimpleModule.Core.Menu; using SimpleModule.Database; using SimpleModule.Orders.Contracts; @@ -20,6 +21,11 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); } + public void ConfigurePermissions(PermissionRegistryBuilder builder) + { + builder.AddPermissions(); + } + public void ConfigureMenu(IMenuBuilder menus) { menus.Add( @@ -33,5 +39,16 @@ public void ConfigureMenu(IMenuBuilder menus) Section = MenuSection.Navbar, } ); + menus.Add( + new MenuItem + { + Label = "Orders", + Url = "/orders", + Icon = + """""", + Order = 30, + Section = MenuSection.AppSidebar, + } + ); } } diff --git a/modules/Orders/src/Orders/OrdersPermissions.cs b/modules/Orders/src/Orders/OrdersPermissions.cs new file mode 100644 index 00000000..533662f4 --- /dev/null +++ b/modules/Orders/src/Orders/OrdersPermissions.cs @@ -0,0 +1,9 @@ +namespace SimpleModule.Orders; + +public sealed class OrdersPermissions +{ + public const string View = "Orders.View"; + public const string Create = "Orders.Create"; + public const string Update = "Orders.Update"; + public const string Delete = "Orders.Delete"; +} diff --git a/modules/Orders/src/Orders/Views/CreateEndpoint.cs b/modules/Orders/src/Orders/Views/CreateEndpoint.cs index 44854978..23ea30c2 100644 --- a/modules/Orders/src/Orders/Views/CreateEndpoint.cs +++ b/modules/Orders/src/Orders/Views/CreateEndpoint.cs @@ -5,6 +5,7 @@ using SimpleModule.Core.Inertia; using SimpleModule.Orders.Contracts; using SimpleModule.Products.Contracts; +using SimpleModule.Users.Contracts; namespace SimpleModule.Orders.Views; @@ -27,11 +28,11 @@ public void Map(IEndpointRouteBuilder app) { var request = new CreateOrderRequest { - UserId = body.UserId, + UserId = UserId.From(body.UserId), Items = body .Items.Select(i => new OrderItem { - ProductId = i.ProductId, + ProductId = ProductId.From(i.ProductId), Quantity = i.Quantity, }) .ToList(), diff --git a/modules/Orders/src/Orders/Views/EditEndpoint.cs b/modules/Orders/src/Orders/Views/EditEndpoint.cs index 8634f631..8b8d9e82 100644 --- a/modules/Orders/src/Orders/Views/EditEndpoint.cs +++ b/modules/Orders/src/Orders/Views/EditEndpoint.cs @@ -5,6 +5,7 @@ using SimpleModule.Core.Inertia; using SimpleModule.Orders.Contracts; using SimpleModule.Products.Contracts; +using SimpleModule.Users.Contracts; namespace SimpleModule.Orders.Views; @@ -14,7 +15,7 @@ public void Map(IEndpointRouteBuilder app) { app.MapGet( "/{id}/edit", - async (int id, IOrderContracts orders, IProductContracts products) => + async (OrderId id, IOrderContracts orders, IProductContracts products) => { var order = await orders.GetOrderByIdAsync(id); if (order is null) @@ -26,11 +27,11 @@ public void Map(IEndpointRouteBuilder app) { order = new { - id = order.Id, - userId = order.UserId, + id = order.Id.Value, + userId = order.UserId.Value, items = order.Items.Select(i => new { - productId = i.ProductId, + productId = i.ProductId.Value, quantity = i.Quantity, }), total = order.Total, @@ -44,15 +45,15 @@ public void Map(IEndpointRouteBuilder app) app.MapPost( "/{id}", - async (int id, UpdateOrderPayload body, IOrderContracts orders) => + async (OrderId id, UpdateOrderPayload body, IOrderContracts orders) => { var request = new UpdateOrderRequest { - UserId = body.UserId, + UserId = UserId.From(body.UserId), Items = body .Items.Select(i => new OrderItem { - ProductId = i.ProductId, + ProductId = ProductId.From(i.ProductId), Quantity = i.Quantity, }) .ToList(), @@ -65,7 +66,7 @@ public void Map(IEndpointRouteBuilder app) app.MapDelete( "/{id}", - async (int id, IOrderContracts orders) => + async (OrderId id, IOrderContracts orders) => { await orders.DeleteOrderAsync(id); return Results.Redirect("/orders"); diff --git a/modules/Orders/tests/Orders.Tests/Integration/OrdersEndpointTests.cs b/modules/Orders/tests/Orders.Tests/Integration/OrdersEndpointTests.cs index 4c28bc95..20439fe0 100644 --- a/modules/Orders/tests/Orders.Tests/Integration/OrdersEndpointTests.cs +++ b/modules/Orders/tests/Orders.Tests/Integration/OrdersEndpointTests.cs @@ -1,24 +1,29 @@ -using System.Net; +using System.Net; using System.Net.Http.Json; using FluentAssertions; +using SimpleModule.Orders; using SimpleModule.Orders.Contracts; +using SimpleModule.Products.Contracts; using SimpleModule.Tests.Shared.Fixtures; +using SimpleModule.Users.Contracts; namespace Orders.Tests.Integration; public class OrdersEndpointTests : IClassFixture { - private readonly HttpClient _client; + private readonly SimpleModuleWebApplicationFactory _factory; public OrdersEndpointTests(SimpleModuleWebApplicationFactory factory) { - _client = factory.CreateClient(); + _factory = factory; } [Fact] public async Task GetAllOrders_Returns200() { - var response = await _client.GetAsync("/api/orders"); + var client = _factory.CreateAuthenticatedClient([OrdersPermissions.View]); + + var response = await client.GetAsync("/api/orders"); response.StatusCode.Should().Be(HttpStatusCode.OK); } @@ -26,7 +31,9 @@ public async Task GetAllOrders_Returns200() [Fact] public async Task GetOrderById_WithNonExistentId_Returns404() { - var response = await _client.GetAsync("/api/orders/99999"); + var client = _factory.CreateAuthenticatedClient([OrdersPermissions.View]); + + var response = await client.GetAsync("/api/orders/99999"); response.StatusCode.Should().Be(HttpStatusCode.NotFound); } @@ -34,13 +41,15 @@ public async Task GetOrderById_WithNonExistentId_Returns404() [Fact] public async Task CreateOrder_WithInvalidUserId_Returns404() { + var client = _factory.CreateAuthenticatedClient([OrdersPermissions.Create]); + var request = new CreateOrderRequest { - UserId = "nonexistent-user-id", - Items = [new OrderItem { ProductId = 1, Quantity = 2 }], + UserId = UserId.From("nonexistent-user-id"), + Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 2 }], }; - var response = await _client.PostAsJsonAsync("/api/orders", request); + var response = await client.PostAsJsonAsync("/api/orders", request); // User doesn't exist, so the order service throws NotFoundException response.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -49,13 +58,15 @@ public async Task CreateOrder_WithInvalidUserId_Returns404() [Fact] public async Task UpdateOrder_WithNonExistentId_Returns404() { + var client = _factory.CreateAuthenticatedClient([OrdersPermissions.Update]); + var request = new UpdateOrderRequest { - UserId = "1", - Items = [new OrderItem { ProductId = 1, Quantity = 1 }], + UserId = UserId.From("1"), + Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 1 }], }; - var response = await _client.PutAsJsonAsync("/api/orders/99999", request); + var response = await client.PutAsJsonAsync("/api/orders/99999", request); response.StatusCode.Should().Be(HttpStatusCode.NotFound); } @@ -63,7 +74,9 @@ public async Task UpdateOrder_WithNonExistentId_Returns404() [Fact] public async Task DeleteOrder_WithNonExistentId_Returns404() { - var response = await _client.DeleteAsync("/api/orders/99999"); + var client = _factory.CreateAuthenticatedClient([OrdersPermissions.Delete]); + + var response = await client.DeleteAsync("/api/orders/99999"); response.StatusCode.Should().Be(HttpStatusCode.NotFound); } diff --git a/modules/Orders/tests/Orders.Tests/Unit/CreateOrderRequestValidatorTests.cs b/modules/Orders/tests/Orders.Tests/Unit/CreateOrderRequestValidatorTests.cs index 7e740a9b..b502626d 100644 --- a/modules/Orders/tests/Orders.Tests/Unit/CreateOrderRequestValidatorTests.cs +++ b/modules/Orders/tests/Orders.Tests/Unit/CreateOrderRequestValidatorTests.cs @@ -1,6 +1,8 @@ -using FluentAssertions; +using FluentAssertions; using SimpleModule.Orders.Contracts; using SimpleModule.Orders.Endpoints.Orders; +using SimpleModule.Products.Contracts; +using SimpleModule.Users.Contracts; namespace Orders.Tests.Unit; @@ -11,8 +13,8 @@ public void Validate_WithValidRequest_ReturnsSuccess() { var request = new CreateOrderRequest { - UserId = "1", - Items = [new OrderItem { ProductId = 1, Quantity = 2 }], + UserId = UserId.From("1"), + Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 2 }], }; var result = CreateRequestValidator.Validate(request); @@ -22,27 +24,11 @@ public void Validate_WithValidRequest_ReturnsSuccess() } [Fact] - public void Validate_WithEmptyUserId_ReturnsError() + public void Validate_WithDefaultUserId_ReturnsError() { var request = new CreateOrderRequest { - UserId = "", - Items = [new OrderItem { ProductId = 1, Quantity = 1 }], - }; - - var result = CreateRequestValidator.Validate(request); - - result.IsValid.Should().BeFalse(); - result.Errors.Should().ContainKey("UserId"); - } - - [Fact] - public void Validate_WithWhitespaceUserId_ReturnsError() - { - var request = new CreateOrderRequest - { - UserId = " ", - Items = [new OrderItem { ProductId = 1, Quantity = 1 }], + Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 1 }], }; var result = CreateRequestValidator.Validate(request); @@ -54,7 +40,7 @@ public void Validate_WithWhitespaceUserId_ReturnsError() [Fact] public void Validate_WithEmptyItems_ReturnsError() { - var request = new CreateOrderRequest { UserId = "1", Items = [] }; + var request = new CreateOrderRequest { UserId = UserId.From("1"), Items = [] }; var result = CreateRequestValidator.Validate(request); @@ -67,8 +53,8 @@ public void Validate_WithZeroQuantity_ReturnsError() { var request = new CreateOrderRequest { - UserId = "1", - Items = [new OrderItem { ProductId = 1, Quantity = 0 }], + UserId = UserId.From("1"), + Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 0 }], }; var result = CreateRequestValidator.Validate(request); @@ -80,7 +66,7 @@ public void Validate_WithZeroQuantity_ReturnsError() [Fact] public void Validate_WithMultipleErrors_ReturnsAllErrors() { - var request = new CreateOrderRequest { UserId = "", Items = [] }; + var request = new CreateOrderRequest { Items = [] }; var result = CreateRequestValidator.Validate(request); diff --git a/modules/Orders/tests/Orders.Tests/Unit/OrderIdTests.cs b/modules/Orders/tests/Orders.Tests/Unit/OrderIdTests.cs new file mode 100644 index 00000000..9b877c5b --- /dev/null +++ b/modules/Orders/tests/Orders.Tests/Unit/OrderIdTests.cs @@ -0,0 +1,49 @@ +using FluentAssertions; +using SimpleModule.Orders.Contracts; + +namespace Orders.Tests.Unit; + +public sealed class OrderIdTests +{ + [Fact] + public void From_WithValidInt_CreatesOrderId() + { + var id = OrderId.From(42); + + id.Value.Should().Be(42); + } + + [Fact] + public void From_WithZero_CreatesOrderId() + { + var id = OrderId.From(0); + + id.Value.Should().Be(0); + } + + [Fact] + public void Equality_SameValue_AreEqual() + { + var id1 = OrderId.From(7); + var id2 = OrderId.From(7); + + id1.Should().Be(id2); + } + + [Fact] + public void Equality_DifferentValue_AreNotEqual() + { + var id1 = OrderId.From(1); + var id2 = OrderId.From(2); + + id1.Should().NotBe(id2); + } + + [Fact] + public void ToString_ReturnsValueString() + { + var id = OrderId.From(5); + + id.ToString(System.Globalization.CultureInfo.InvariantCulture).Should().Contain("5"); + } +} diff --git a/modules/Orders/tests/Orders.Tests/Unit/OrderServiceTests.cs b/modules/Orders/tests/Orders.Tests/Unit/OrderServiceTests.cs index e22934a6..5e36dd78 100644 --- a/modules/Orders/tests/Orders.Tests/Unit/OrderServiceTests.cs +++ b/modules/Orders/tests/Orders.Tests/Unit/OrderServiceTests.cs @@ -1,4 +1,4 @@ -using FluentAssertions; +using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -52,18 +52,20 @@ public OrderServiceTests() [Fact] public async Task CreateOrderAsync_WithValidUserAndProduct_CalculatesCorrectTotal() { - _users.GetUserByIdAsync("1").Returns(new UserDto { Id = "1", DisplayName = "Test" }); + _users + .GetUserByIdAsync(UserId.From("1")) + .Returns(new UserDto { Id = UserId.From("1"), DisplayName = "Test" }); var widget = new Product { - Id = 1, + Id = ProductId.From(1), Name = "Widget", Price = 25.00m, }; _products - .GetProductsByIdsAsync(Arg.Any>()) + .GetProductsByIdsAsync(Arg.Any>()) .Returns(callInfo => { - var ids = callInfo.Arg>().ToHashSet(); + var ids = callInfo.Arg>().ToHashSet(); return new List { widget } .Where(p => ids.Contains(p.Id)) .ToList() as IReadOnlyList; @@ -71,26 +73,26 @@ public async Task CreateOrderAsync_WithValidUserAndProduct_CalculatesCorrectTota var request = new CreateOrderRequest { - UserId = "1", - Items = [new OrderItem { ProductId = 1, Quantity = 3 }], + UserId = UserId.From("1"), + Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 3 }], }; var order = await _sut.CreateOrderAsync(request); order.Total.Should().Be(75.00m); - order.UserId.Should().Be("1"); + order.UserId.Should().Be(UserId.From("1")); order.Items.Should().HaveCount(1); } [Fact] public async Task CreateOrderAsync_WithInvalidUser_ThrowsNotFoundException() { - _users.GetUserByIdAsync("999").Returns((UserDto?)null); + _users.GetUserByIdAsync(UserId.From("999")).Returns((UserDto?)null); var request = new CreateOrderRequest { - UserId = "999", - Items = [new OrderItem { ProductId = 1, Quantity = 1 }], + UserId = UserId.From("999"), + Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 1 }], }; var act = () => _sut.CreateOrderAsync(request); @@ -101,15 +103,17 @@ public async Task CreateOrderAsync_WithInvalidUser_ThrowsNotFoundException() [Fact] public async Task CreateOrderAsync_WithInvalidProduct_ThrowsNotFoundException() { - _users.GetUserByIdAsync("1").Returns(new UserDto { Id = "1", DisplayName = "Test" }); + _users + .GetUserByIdAsync(UserId.From("1")) + .Returns(new UserDto { Id = UserId.From("1"), DisplayName = "Test" }); _products - .GetProductsByIdsAsync(Arg.Any>()) + .GetProductsByIdsAsync(Arg.Any>()) .Returns(new List() as IReadOnlyList); var request = new CreateOrderRequest { - UserId = "1", - Items = [new OrderItem { ProductId = 999, Quantity = 1 }], + UserId = UserId.From("1"), + Items = [new OrderItem { ProductId = ProductId.From(999), Quantity = 1 }], }; var act = () => _sut.CreateOrderAsync(request); @@ -128,18 +132,20 @@ public async Task GetAllOrdersAsync_ReturnsOrdersList() [Fact] public async Task GetOrderByIdAsync_ReturnsMatchingOrder() { - _users.GetUserByIdAsync("1").Returns(new UserDto { Id = "1", DisplayName = "Test" }); + _users + .GetUserByIdAsync(UserId.From("1")) + .Returns(new UserDto { Id = UserId.From("1"), DisplayName = "Test" }); var widget = new Product { - Id = 1, + Id = ProductId.From(1), Name = "Widget", Price = 10.00m, }; _products - .GetProductsByIdsAsync(Arg.Any>()) + .GetProductsByIdsAsync(Arg.Any>()) .Returns(callInfo => { - var ids = callInfo.Arg>().ToHashSet(); + var ids = callInfo.Arg>().ToHashSet(); return new List { widget } .Where(p => ids.Contains(p.Id)) .ToList() as IReadOnlyList; @@ -147,8 +153,8 @@ public async Task GetOrderByIdAsync_ReturnsMatchingOrder() var request = new CreateOrderRequest { - UserId = "1", - Items = [new OrderItem { ProductId = 1, Quantity = 1 }], + UserId = UserId.From("1"), + Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 1 }], }; var created = await _sut.CreateOrderAsync(request); @@ -161,18 +167,20 @@ public async Task GetOrderByIdAsync_ReturnsMatchingOrder() [Fact] public async Task UpdateOrderAsync_WithValidData_UpdatesOrder() { - _users.GetUserByIdAsync("1").Returns(new UserDto { Id = "1", DisplayName = "Test" }); + _users + .GetUserByIdAsync(UserId.From("1")) + .Returns(new UserDto { Id = UserId.From("1"), DisplayName = "Test" }); var widget = new Product { - Id = 1, + Id = ProductId.From(1), Name = "Widget", Price = 10.00m, }; _products - .GetProductsByIdsAsync(Arg.Any>()) + .GetProductsByIdsAsync(Arg.Any>()) .Returns(callInfo => { - var ids = callInfo.Arg>().ToHashSet(); + var ids = callInfo.Arg>().ToHashSet(); return new List { widget } .Where(p => ids.Contains(p.Id)) .ToList() as IReadOnlyList; @@ -180,15 +188,15 @@ public async Task UpdateOrderAsync_WithValidData_UpdatesOrder() var createRequest = new CreateOrderRequest { - UserId = "1", - Items = [new OrderItem { ProductId = 1, Quantity = 1 }], + UserId = UserId.From("1"), + Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 1 }], }; var created = await _sut.CreateOrderAsync(createRequest); var updateRequest = new UpdateOrderRequest { - UserId = "1", - Items = [new OrderItem { ProductId = 1, Quantity = 5 }], + UserId = UserId.From("1"), + Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 5 }], }; var updated = await _sut.UpdateOrderAsync(created.Id, updateRequest); @@ -202,11 +210,11 @@ public async Task UpdateOrderAsync_WithNonExistentOrder_ThrowsNotFoundException( { var request = new UpdateOrderRequest { - UserId = "1", - Items = [new OrderItem { ProductId = 1, Quantity = 1 }], + UserId = UserId.From("1"), + Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 1 }], }; - var act = () => _sut.UpdateOrderAsync(99999, request); + var act = () => _sut.UpdateOrderAsync(OrderId.From(99999), request); await act.Should().ThrowAsync().WithMessage("*Order*99999*not found*"); } @@ -214,18 +222,20 @@ public async Task UpdateOrderAsync_WithNonExistentOrder_ThrowsNotFoundException( [Fact] public async Task DeleteOrderAsync_WithExistingOrder_RemovesOrder() { - _users.GetUserByIdAsync("1").Returns(new UserDto { Id = "1", DisplayName = "Test" }); + _users + .GetUserByIdAsync(UserId.From("1")) + .Returns(new UserDto { Id = UserId.From("1"), DisplayName = "Test" }); var widget = new Product { - Id = 1, + Id = ProductId.From(1), Name = "Widget", Price = 10.00m, }; _products - .GetProductsByIdsAsync(Arg.Any>()) + .GetProductsByIdsAsync(Arg.Any>()) .Returns(callInfo => { - var ids = callInfo.Arg>().ToHashSet(); + var ids = callInfo.Arg>().ToHashSet(); return new List { widget } .Where(p => ids.Contains(p.Id)) .ToList() as IReadOnlyList; @@ -233,8 +243,8 @@ public async Task DeleteOrderAsync_WithExistingOrder_RemovesOrder() var createRequest = new CreateOrderRequest { - UserId = "1", - Items = [new OrderItem { ProductId = 1, Quantity = 1 }], + UserId = UserId.From("1"), + Items = [new OrderItem { ProductId = ProductId.From(1), Quantity = 1 }], }; var created = await _sut.CreateOrderAsync(createRequest); @@ -247,7 +257,7 @@ public async Task DeleteOrderAsync_WithExistingOrder_RemovesOrder() [Fact] public async Task DeleteOrderAsync_WithNonExistentOrder_ThrowsNotFoundException() { - var act = () => _sut.DeleteOrderAsync(99999); + var act = () => _sut.DeleteOrderAsync(OrderId.From(99999)); await act.Should().ThrowAsync().WithMessage("*Order*99999*not found*"); } diff --git a/modules/Permissions/src/Permissions.Contracts/IPermissionContracts.cs b/modules/Permissions/src/Permissions.Contracts/IPermissionContracts.cs new file mode 100644 index 00000000..dacbf0f6 --- /dev/null +++ b/modules/Permissions/src/Permissions.Contracts/IPermissionContracts.cs @@ -0,0 +1,15 @@ +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Permissions.Contracts; + +public interface IPermissionContracts +{ + Task> GetPermissionsForUserAsync(UserId userId); + Task> GetPermissionsForRoleAsync(RoleId roleId); + Task> GetAllPermissionsForUserAsync( + UserId userId, + IEnumerable roleIds + ); + Task SetPermissionsForUserAsync(UserId userId, IEnumerable permissions); + Task SetPermissionsForRoleAsync(RoleId roleId, IEnumerable permissions); +} diff --git a/modules/Permissions/src/Permissions.Contracts/Permissions.Contracts.csproj b/modules/Permissions/src/Permissions.Contracts/Permissions.Contracts.csproj new file mode 100644 index 00000000..77527134 --- /dev/null +++ b/modules/Permissions/src/Permissions.Contracts/Permissions.Contracts.csproj @@ -0,0 +1,12 @@ + + + net10.0 + Library + $(DefineConstants);VOGEN_NO_VALIDATION + + + + + + + diff --git a/modules/Permissions/src/Permissions.Contracts/PermissionsConstants.cs b/modules/Permissions/src/Permissions.Contracts/PermissionsConstants.cs new file mode 100644 index 00000000..43d0cd08 --- /dev/null +++ b/modules/Permissions/src/Permissions.Contracts/PermissionsConstants.cs @@ -0,0 +1,6 @@ +namespace SimpleModule.Permissions.Contracts; + +public static class PermissionsConstants +{ + public const string ModuleName = "Permissions"; +} diff --git a/modules/Permissions/src/Permissions.Contracts/RoleId.cs b/modules/Permissions/src/Permissions.Contracts/RoleId.cs new file mode 100644 index 00000000..f0ec50d8 --- /dev/null +++ b/modules/Permissions/src/Permissions.Contracts/RoleId.cs @@ -0,0 +1,12 @@ +using Vogen; + +namespace SimpleModule.Permissions.Contracts; + +[ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] +public readonly partial struct RoleId +{ + private static Vogen.Validation Validate(string value) => + string.IsNullOrWhiteSpace(value) + ? Vogen.Validation.Invalid("RoleId cannot be empty") + : Vogen.Validation.Ok; +} diff --git a/modules/Permissions/src/Permissions/Entities/RolePermission.cs b/modules/Permissions/src/Permissions/Entities/RolePermission.cs new file mode 100644 index 00000000..153eadce --- /dev/null +++ b/modules/Permissions/src/Permissions/Entities/RolePermission.cs @@ -0,0 +1,11 @@ +using SimpleModule.Permissions.Contracts; + +namespace SimpleModule.Permissions.Entities; + +#pragma warning disable CA1711 +public class RolePermission +#pragma warning restore CA1711 +{ + public RoleId RoleId { get; set; } + public string Permission { get; set; } = string.Empty; +} diff --git a/modules/Permissions/src/Permissions/Entities/UserPermission.cs b/modules/Permissions/src/Permissions/Entities/UserPermission.cs new file mode 100644 index 00000000..ce5f3978 --- /dev/null +++ b/modules/Permissions/src/Permissions/Entities/UserPermission.cs @@ -0,0 +1,11 @@ +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Permissions.Entities; + +#pragma warning disable CA1711 +public class UserPermission +#pragma warning restore CA1711 +{ + public UserId UserId { get; set; } + public string Permission { get; set; } = string.Empty; +} diff --git a/modules/Permissions/src/Permissions/EntityConfigurations/RolePermissionConfiguration.cs b/modules/Permissions/src/Permissions/EntityConfigurations/RolePermissionConfiguration.cs new file mode 100644 index 00000000..d52af2b0 --- /dev/null +++ b/modules/Permissions/src/Permissions/EntityConfigurations/RolePermissionConfiguration.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimpleModule.Permissions.Contracts; +using SimpleModule.Permissions.Entities; + +namespace SimpleModule.Permissions.EntityConfigurations; + +public class RolePermissionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => new { e.RoleId, e.Permission }); + builder.Property(e => e.RoleId).HasConversion(); + builder.ToTable("RolePermissions"); + } +} diff --git a/modules/Permissions/src/Permissions/EntityConfigurations/UserPermissionConfiguration.cs b/modules/Permissions/src/Permissions/EntityConfigurations/UserPermissionConfiguration.cs new file mode 100644 index 00000000..04d50879 --- /dev/null +++ b/modules/Permissions/src/Permissions/EntityConfigurations/UserPermissionConfiguration.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimpleModule.Permissions.Entities; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Permissions.EntityConfigurations; + +public class UserPermissionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => new { e.UserId, e.Permission }); + builder.Property(e => e.UserId).HasConversion(); + builder.ToTable("UserPermissions"); + } +} diff --git a/modules/Permissions/src/Permissions/PermissionService.cs b/modules/Permissions/src/Permissions/PermissionService.cs new file mode 100644 index 00000000..61c02fe5 --- /dev/null +++ b/modules/Permissions/src/Permissions/PermissionService.cs @@ -0,0 +1,83 @@ +using Microsoft.EntityFrameworkCore; +using SimpleModule.Permissions.Contracts; +using SimpleModule.Permissions.Entities; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Permissions; + +public class PermissionService(PermissionsDbContext db) : IPermissionContracts +{ + public async Task> GetPermissionsForUserAsync(UserId userId) + { + var perms = await db + .UserPermissions.Where(p => p.UserId == userId) + .Select(p => p.Permission) + .ToListAsync(); + + return new HashSet(perms); + } + + public async Task> GetPermissionsForRoleAsync(RoleId roleId) + { + var perms = await db + .RolePermissions.Where(p => p.RoleId == roleId) + .Select(p => p.Permission) + .ToListAsync(); + + return new HashSet(perms); + } + + public async Task> GetAllPermissionsForUserAsync( + UserId userId, + IEnumerable roleIds + ) + { + var roleIdList = roleIds.ToList(); + + var rolePerms = await db + .RolePermissions.Where(p => roleIdList.Contains(p.RoleId)) + .Select(p => p.Permission) + .ToListAsync(); + + var userPerms = await db + .UserPermissions.Where(p => p.UserId == userId) + .Select(p => p.Permission) + .ToListAsync(); + + var result = new HashSet(rolePerms); + foreach (var p in userPerms) + { + result.Add(p); + } + + return result; + } + + public async Task SetPermissionsForUserAsync(UserId userId, IEnumerable permissions) + { + var existing = await db.UserPermissions.Where(p => p.UserId == userId).ToListAsync(); + + db.UserPermissions.RemoveRange(existing); + + foreach (var permission in permissions) + { + db.UserPermissions.Add(new UserPermission { UserId = userId, Permission = permission }); + } + + await db.SaveChangesAsync(); + } + + public async Task SetPermissionsForRoleAsync(RoleId roleId, IEnumerable permissions) + { + var existing = await db.RolePermissions.Where(p => p.RoleId == roleId).ToListAsync(); + + db.RolePermissions.RemoveRange(existing); + + foreach (var permission in permissions) + { + db.RolePermissions.Add(new RolePermission { RoleId = roleId, Permission = permission }); + } + + await db.SaveChangesAsync(); + } +} diff --git a/modules/Permissions/src/Permissions/Permissions.csproj b/modules/Permissions/src/Permissions/Permissions.csproj new file mode 100644 index 00000000..754ba88a --- /dev/null +++ b/modules/Permissions/src/Permissions/Permissions.csproj @@ -0,0 +1,12 @@ + + + net10.0 + + + + + + + + + diff --git a/modules/Permissions/src/Permissions/PermissionsDbContext.cs b/modules/Permissions/src/Permissions/PermissionsDbContext.cs new file mode 100644 index 00000000..7e679670 --- /dev/null +++ b/modules/Permissions/src/Permissions/PermissionsDbContext.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SimpleModule.Database; +using SimpleModule.Permissions.Entities; +using SimpleModule.Permissions.EntityConfigurations; + +namespace SimpleModule.Permissions; + +public class PermissionsDbContext( + DbContextOptions options, + IOptions dbOptions +) : DbContext(options) +{ + public DbSet UserPermissions => Set(); + public DbSet RolePermissions => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.ApplyConfiguration(new UserPermissionConfiguration()); + modelBuilder.ApplyConfiguration(new RolePermissionConfiguration()); + + modelBuilder.ApplyModuleSchema("Permissions", dbOptions.Value); + } +} diff --git a/modules/Permissions/src/Permissions/PermissionsModule.cs b/modules/Permissions/src/Permissions/PermissionsModule.cs new file mode 100644 index 00000000..3ba2085e --- /dev/null +++ b/modules/Permissions/src/Permissions/PermissionsModule.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core; +using SimpleModule.Database; +using SimpleModule.Permissions.Contracts; +using SimpleModule.Permissions.Services; + +namespace SimpleModule.Permissions; + +[Module(PermissionsConstants.ModuleName)] +public class PermissionsModule : IModule +{ + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddModuleDbContext( + configuration, + PermissionsConstants.ModuleName + ); + + services.AddScoped(); + services.AddHostedService(); + } +} diff --git a/modules/Permissions/src/Permissions/Services/PermissionSeedService.cs b/modules/Permissions/src/Permissions/Services/PermissionSeedService.cs new file mode 100644 index 00000000..d3bc46da --- /dev/null +++ b/modules/Permissions/src/Permissions/Services/PermissionSeedService.cs @@ -0,0 +1,85 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SimpleModule.Core.Authorization; +using SimpleModule.Permissions.Contracts; +using SimpleModule.Users.Entities; +using RolePermission = SimpleModule.Permissions.Entities.RolePermission; + +namespace SimpleModule.Permissions.Services; + +public partial class PermissionSeedService( + IServiceProvider serviceProvider, + PermissionRegistry permissionRegistry, + ILogger logger +) : IHostedService +{ + private const string AdminRole = "Admin"; + + public async Task StartAsync(CancellationToken cancellationToken) + { + try + { + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var roleManager = scope.ServiceProvider.GetRequiredService< + RoleManager + >(); + + var adminRole = await roleManager.FindByNameAsync(AdminRole); + if (adminRole is null) + { + return; + } + + var adminRoleId = RoleId.From(adminRole.Id); + + var existingPermissions = await dbContext + .RolePermissions.Where(rp => rp.RoleId == adminRoleId) + .Select(rp => rp.Permission) + .ToListAsync(cancellationToken); + + var existingSet = new HashSet(existingPermissions); + var newPermissions = new List(); + + foreach (var permission in permissionRegistry.AllPermissions) + { + if (!existingSet.Contains(permission)) + { + newPermissions.Add( + new RolePermission { RoleId = adminRoleId, Permission = permission } + ); + } + } + + if (newPermissions.Count > 0) + { + LogSeedingPermissions(logger, newPermissions.Count); + dbContext.RolePermissions.AddRange(newPermissions); + await dbContext.SaveChangesAsync(cancellationToken); + } + } +#pragma warning disable CA1031 // Seed service must not crash the host on database errors + catch (Exception ex) +#pragma warning restore CA1031 + { + LogSeedError(logger, ex.Message); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Seeding {Count} permissions for Admin role..." + )] + private static partial void LogSeedingPermissions(ILogger logger, int count); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Permission seeding skipped due to error: {ErrorMessage}" + )] + private static partial void LogSeedError(ILogger logger, string errorMessage); +} diff --git a/modules/Permissions/tests/Permissions.Tests/GlobalUsings.cs b/modules/Permissions/tests/Permissions.Tests/GlobalUsings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/modules/Permissions/tests/Permissions.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/modules/Permissions/tests/Permissions.Tests/Helpers/TestDbContextFactory.cs b/modules/Permissions/tests/Permissions.Tests/Helpers/TestDbContextFactory.cs new file mode 100644 index 00000000..31ec4614 --- /dev/null +++ b/modules/Permissions/tests/Permissions.Tests/Helpers/TestDbContextFactory.cs @@ -0,0 +1,32 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SimpleModule.Database; +using SimpleModule.Permissions; + +namespace Permissions.Tests.Helpers; + +public sealed class TestDbContextFactory : IDisposable +{ + private readonly SqliteConnection _connection; + + public TestDbContextFactory() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + } + + public PermissionsDbContext Create() + { + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + var dbOptions = Options.Create(new DatabaseOptions()); + var context = new PermissionsDbContext(options, dbOptions); + context.Database.EnsureCreated(); + return context; + } + + public void Dispose() => _connection.Dispose(); +} diff --git a/modules/Permissions/tests/Permissions.Tests/Permissions.Tests.csproj b/modules/Permissions/tests/Permissions.Tests/Permissions.Tests.csproj new file mode 100644 index 00000000..9e452558 --- /dev/null +++ b/modules/Permissions/tests/Permissions.Tests/Permissions.Tests.csproj @@ -0,0 +1,17 @@ + + + net10.0 + false + true + + + + + + + + + + + + diff --git a/modules/Permissions/tests/Permissions.Tests/Unit/PermissionServiceCombinedTests.cs b/modules/Permissions/tests/Permissions.Tests/Unit/PermissionServiceCombinedTests.cs new file mode 100644 index 00000000..2f555511 --- /dev/null +++ b/modules/Permissions/tests/Permissions.Tests/Unit/PermissionServiceCombinedTests.cs @@ -0,0 +1,99 @@ +using FluentAssertions; +using Permissions.Tests.Helpers; +using SimpleModule.Permissions; +using SimpleModule.Permissions.Contracts; +using SimpleModule.Users.Contracts; + +namespace Permissions.Tests.Unit; + +public sealed class PermissionServiceCombinedTests : IDisposable +{ + private readonly TestDbContextFactory _factory = new(); + + [Fact] + public async Task GetAllPermissionsForUser_CombinesUserAndRolePermissions() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + var userId = UserId.From("user-1"); + var roleId = RoleId.From("role-1"); + + await sut.SetPermissionsForUserAsync(userId, ["user-perm"]); + await sut.SetPermissionsForRoleAsync(roleId, ["role-perm"]); + + var result = await sut.GetAllPermissionsForUserAsync(userId, [roleId]); + + result.Should().HaveCount(2); + result.Should().Contain("user-perm"); + result.Should().Contain("role-perm"); + } + + [Fact] + public async Task GetAllPermissionsForUser_DeduplicatesOverlapping() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + var userId = UserId.From("user-1"); + var roleId = RoleId.From("role-1"); + + await sut.SetPermissionsForUserAsync(userId, ["shared", "user-only"]); + await sut.SetPermissionsForRoleAsync(roleId, ["shared", "role-only"]); + + var result = await sut.GetAllPermissionsForUserAsync(userId, [roleId]); + + result.Should().HaveCount(3); + result.Should().Contain("shared"); + result.Should().Contain("user-only"); + result.Should().Contain("role-only"); + } + + [Fact] + public async Task GetAllPermissionsForUser_MultipleRoles_CombinesAll() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + var userId = UserId.From("user-1"); + var role1 = RoleId.From("role-1"); + var role2 = RoleId.From("role-2"); + + await sut.SetPermissionsForUserAsync(userId, ["user-perm"]); + await sut.SetPermissionsForRoleAsync(role1, ["role1-perm"]); + await sut.SetPermissionsForRoleAsync(role2, ["role2-perm"]); + + var result = await sut.GetAllPermissionsForUserAsync(userId, [role1, role2]); + + result.Should().HaveCount(3); + result.Should().Contain("user-perm"); + result.Should().Contain("role1-perm"); + result.Should().Contain("role2-perm"); + } + + [Fact] + public async Task GetAllPermissionsForUser_NoRoles_ReturnsOnlyUserPermissions() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + var userId = UserId.From("user-1"); + + await sut.SetPermissionsForUserAsync(userId, ["user-perm"]); + + var result = await sut.GetAllPermissionsForUserAsync(userId, []); + + result.Should().HaveCount(1); + result.Should().Contain("user-perm"); + } + + [Fact] + public async Task GetAllPermissionsForUser_NoPermissionsAnywhere_ReturnsEmpty() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + var userId = UserId.From("user-1"); + + var result = await sut.GetAllPermissionsForUserAsync(userId, []); + + result.Should().BeEmpty(); + } + + public void Dispose() => _factory.Dispose(); +} diff --git a/modules/Permissions/tests/Permissions.Tests/Unit/PermissionServiceRoleTests.cs b/modules/Permissions/tests/Permissions.Tests/Unit/PermissionServiceRoleTests.cs new file mode 100644 index 00000000..1e053ca7 --- /dev/null +++ b/modules/Permissions/tests/Permissions.Tests/Unit/PermissionServiceRoleTests.cs @@ -0,0 +1,90 @@ +using FluentAssertions; +using Permissions.Tests.Helpers; +using SimpleModule.Permissions; +using SimpleModule.Permissions.Contracts; + +namespace Permissions.Tests.Unit; + +public sealed class PermissionServiceRoleTests : IDisposable +{ + private readonly TestDbContextFactory _factory = new(); + + [Fact] + public async Task GetPermissionsForRole_NoPermissions_ReturnsEmpty() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + + var result = await sut.GetPermissionsForRoleAsync(RoleId.From("role-1")); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task SetPermissionsForRole_ThenGet_ReturnsSetPermissions() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + var roleId = RoleId.From("role-1"); + + await sut.SetPermissionsForRoleAsync(roleId, ["read", "write"]); + var result = await sut.GetPermissionsForRoleAsync(roleId); + + result.Should().HaveCount(2); + result.Should().Contain("read"); + result.Should().Contain("write"); + } + + [Fact] + public async Task SetPermissionsForRole_ReplacesExisting() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + var roleId = RoleId.From("role-1"); + + await sut.SetPermissionsForRoleAsync(roleId, ["read", "write"]); + await sut.SetPermissionsForRoleAsync(roleId, ["delete"]); + var result = await sut.GetPermissionsForRoleAsync(roleId); + + result.Should().HaveCount(1); + result.Should().Contain("delete"); + result.Should().NotContain("read"); + result.Should().NotContain("write"); + } + + [Fact] + public async Task SetPermissionsForRole_EmptyList_ClearsAll() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + var roleId = RoleId.From("role-1"); + + await sut.SetPermissionsForRoleAsync(roleId, ["read", "write"]); + await sut.SetPermissionsForRoleAsync(roleId, []); + var result = await sut.GetPermissionsForRoleAsync(roleId); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetPermissionsForRole_IsolatedBetweenRoles() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + var role1 = RoleId.From("role-1"); + var role2 = RoleId.From("role-2"); + + await sut.SetPermissionsForRoleAsync(role1, ["read"]); + await sut.SetPermissionsForRoleAsync(role2, ["write"]); + + var result1 = await sut.GetPermissionsForRoleAsync(role1); + var result2 = await sut.GetPermissionsForRoleAsync(role2); + + result1.Should().HaveCount(1); + result1.Should().Contain("read"); + result2.Should().HaveCount(1); + result2.Should().Contain("write"); + } + + public void Dispose() => _factory.Dispose(); +} diff --git a/modules/Permissions/tests/Permissions.Tests/Unit/PermissionServiceTests.cs b/modules/Permissions/tests/Permissions.Tests/Unit/PermissionServiceTests.cs new file mode 100644 index 00000000..5646da4c --- /dev/null +++ b/modules/Permissions/tests/Permissions.Tests/Unit/PermissionServiceTests.cs @@ -0,0 +1,90 @@ +using FluentAssertions; +using Permissions.Tests.Helpers; +using SimpleModule.Permissions; +using SimpleModule.Users.Contracts; + +namespace Permissions.Tests.Unit; + +public sealed class PermissionServiceTests : IDisposable +{ + private readonly TestDbContextFactory _factory = new(); + + [Fact] + public async Task GetPermissionsForUser_NoPermissions_ReturnsEmpty() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + + var result = await sut.GetPermissionsForUserAsync(UserId.From("user-1")); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task SetPermissionsForUser_ThenGet_ReturnsSetPermissions() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + var userId = UserId.From("user-1"); + + await sut.SetPermissionsForUserAsync(userId, ["read", "write"]); + var result = await sut.GetPermissionsForUserAsync(userId); + + result.Should().HaveCount(2); + result.Should().Contain("read"); + result.Should().Contain("write"); + } + + [Fact] + public async Task SetPermissionsForUser_ReplacesExisting() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + var userId = UserId.From("user-1"); + + await sut.SetPermissionsForUserAsync(userId, ["read", "write"]); + await sut.SetPermissionsForUserAsync(userId, ["delete"]); + var result = await sut.GetPermissionsForUserAsync(userId); + + result.Should().HaveCount(1); + result.Should().Contain("delete"); + result.Should().NotContain("read"); + result.Should().NotContain("write"); + } + + [Fact] + public async Task SetPermissionsForUser_EmptyList_ClearsAll() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + var userId = UserId.From("user-1"); + + await sut.SetPermissionsForUserAsync(userId, ["read", "write"]); + await sut.SetPermissionsForUserAsync(userId, []); + var result = await sut.GetPermissionsForUserAsync(userId); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetPermissionsForUser_IsolatedBetweenUsers() + { + await using var db = _factory.Create(); + var sut = new PermissionService(db); + var user1 = UserId.From("user-1"); + var user2 = UserId.From("user-2"); + + await sut.SetPermissionsForUserAsync(user1, ["read"]); + await sut.SetPermissionsForUserAsync(user2, ["write"]); + + var result1 = await sut.GetPermissionsForUserAsync(user1); + var result2 = await sut.GetPermissionsForUserAsync(user2); + + result1.Should().HaveCount(1); + result1.Should().Contain("read"); + result2.Should().HaveCount(1); + result2.Should().Contain("write"); + } + + public void Dispose() => _factory.Dispose(); +} diff --git a/modules/Products/src/Products.Contracts/IProductContracts.cs b/modules/Products/src/Products.Contracts/IProductContracts.cs index 1e097a0c..550e3a9e 100644 --- a/modules/Products/src/Products.Contracts/IProductContracts.cs +++ b/modules/Products/src/Products.Contracts/IProductContracts.cs @@ -1,11 +1,11 @@ -namespace SimpleModule.Products.Contracts; +namespace SimpleModule.Products.Contracts; public interface IProductContracts { Task> GetAllProductsAsync(); - Task GetProductByIdAsync(int id); - Task> GetProductsByIdsAsync(IEnumerable ids); + Task GetProductByIdAsync(ProductId id); + Task> GetProductsByIdsAsync(IEnumerable ids); Task CreateProductAsync(CreateProductRequest request); - Task UpdateProductAsync(int id, UpdateProductRequest request); - Task DeleteProductAsync(int id); + Task UpdateProductAsync(ProductId id, UpdateProductRequest request); + Task DeleteProductAsync(ProductId id); } diff --git a/modules/Products/src/Products.Contracts/Product.cs b/modules/Products/src/Products.Contracts/Product.cs index b58938df..fb746ced 100644 --- a/modules/Products/src/Products.Contracts/Product.cs +++ b/modules/Products/src/Products.Contracts/Product.cs @@ -5,7 +5,7 @@ namespace SimpleModule.Products.Contracts; [Dto] public class Product { - public int Id { get; set; } + public ProductId Id { get; set; } public string Name { get; set; } = string.Empty; public decimal Price { get; set; } } diff --git a/modules/Products/src/Products.Contracts/ProductId.cs b/modules/Products/src/Products.Contracts/ProductId.cs new file mode 100644 index 00000000..60402289 --- /dev/null +++ b/modules/Products/src/Products.Contracts/ProductId.cs @@ -0,0 +1,6 @@ +using Vogen; + +namespace SimpleModule.Products.Contracts; + +[ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] +public readonly partial struct ProductId; diff --git a/modules/Products/src/Products.Contracts/Products.Contracts.csproj b/modules/Products/src/Products.Contracts/Products.Contracts.csproj index b33451b0..97fdfa07 100644 --- a/modules/Products/src/Products.Contracts/Products.Contracts.csproj +++ b/modules/Products/src/Products.Contracts/Products.Contracts.csproj @@ -2,8 +2,11 @@ net10.0 Library + $(DefineConstants);VOGEN_NO_VALIDATION + + diff --git a/modules/Products/src/Products/Endpoints/Products/CreateEndpoint.cs b/modules/Products/src/Products/Endpoints/Products/CreateEndpoint.cs index 0f7b9c66..aedcb8a6 100644 --- a/modules/Products/src/Products/Endpoints/Products/CreateEndpoint.cs +++ b/modules/Products/src/Products/Endpoints/Products/CreateEndpoint.cs @@ -1,25 +1,31 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Endpoints; +using SimpleModule.Core.Exceptions; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.Endpoints.Products; public class CreateEndpoint : IEndpoint { - public void Map(IEndpointRouteBuilder app) - { + public void Map(IEndpointRouteBuilder app) => app.MapPost( - "/", - async (CreateProductRequest request, IProductContracts productContracts) => - { - var product = await productContracts.CreateProductAsync(request); - return TypedResults.Created( - $"{ProductsConstants.RoutePrefix}/{product.Id}", - product - ); - } - ); - } + "/", + (CreateProductRequest request, IProductContracts productContracts) => + { + var validation = CreateRequestValidator.Validate(request); + if (!validation.IsValid) + { + throw new ValidationException(validation.Errors); + } + + return CrudEndpoints.Create( + () => productContracts.CreateProductAsync(request), + p => $"{ProductsConstants.RoutePrefix}/{p.Id}" + ); + } + ) + .RequirePermission(ProductsPermissions.Create); } diff --git a/modules/Products/src/Products/Endpoints/Products/CreateRequestValidator.cs b/modules/Products/src/Products/Endpoints/Products/CreateRequestValidator.cs new file mode 100644 index 00000000..bdf485e2 --- /dev/null +++ b/modules/Products/src/Products/Endpoints/Products/CreateRequestValidator.cs @@ -0,0 +1,17 @@ +using SimpleModule.Core.Validation; +using SimpleModule.Products.Contracts; + +namespace SimpleModule.Products.Endpoints.Products; + +public static class CreateRequestValidator +{ + public static ValidationResult Validate(CreateProductRequest request) => + new ValidationBuilder() + .AddErrorIf( + string.IsNullOrWhiteSpace(request.Name), + "Name", + "Product name is required." + ) + .AddErrorIf(request.Price <= 0, "Price", "Price must be greater than zero.") + .Build(); +} diff --git a/modules/Products/src/Products/Endpoints/Products/DeleteEndpoint.cs b/modules/Products/src/Products/Endpoints/Products/DeleteEndpoint.cs index 3f75fdaa..8bade501 100644 --- a/modules/Products/src/Products/Endpoints/Products/DeleteEndpoint.cs +++ b/modules/Products/src/Products/Endpoints/Products/DeleteEndpoint.cs @@ -1,22 +1,19 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Endpoints; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.Endpoints.Products; public class DeleteEndpoint : IEndpoint { - public void Map(IEndpointRouteBuilder app) - { + public void Map(IEndpointRouteBuilder app) => app.MapDelete( - "/{id}", - async (int id, IProductContracts productContracts) => - { - await productContracts.DeleteProductAsync(id); - return TypedResults.NoContent(); - } - ); - } + "/{id}", + (ProductId id, IProductContracts productContracts) => + CrudEndpoints.Delete(() => productContracts.DeleteProductAsync(id)) + ) + .RequirePermission(ProductsPermissions.Delete); } diff --git a/modules/Products/src/Products/Endpoints/Products/GetAllEndpoint.cs b/modules/Products/src/Products/Endpoints/Products/GetAllEndpoint.cs index 02fe7aaf..50c67c85 100644 --- a/modules/Products/src/Products/Endpoints/Products/GetAllEndpoint.cs +++ b/modules/Products/src/Products/Endpoints/Products/GetAllEndpoint.cs @@ -1,22 +1,19 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Endpoints; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.Endpoints.Products; public class GetAllEndpoint : IEndpoint { - public void Map(IEndpointRouteBuilder app) - { + public void Map(IEndpointRouteBuilder app) => app.MapGet( - "/", - async (IProductContracts productContracts) => - { - var products = await productContracts.GetAllProductsAsync(); - return TypedResults.Ok(products); - } - ); - } + "/", + (IProductContracts productContracts) => + CrudEndpoints.GetAll(productContracts.GetAllProductsAsync) + ) + .RequirePermission(ProductsPermissions.View); } diff --git a/modules/Products/src/Products/Endpoints/Products/GetByIdEndpoint.cs b/modules/Products/src/Products/Endpoints/Products/GetByIdEndpoint.cs index 67342d99..b012f4c2 100644 --- a/modules/Products/src/Products/Endpoints/Products/GetByIdEndpoint.cs +++ b/modules/Products/src/Products/Endpoints/Products/GetByIdEndpoint.cs @@ -1,26 +1,19 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Endpoints; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.Endpoints.Products; public class GetByIdEndpoint : IEndpoint { - public void Map(IEndpointRouteBuilder app) - { + public void Map(IEndpointRouteBuilder app) => app.MapGet( - "/{id}", - async Task, NotFound>> ( - int id, - IProductContracts productContracts - ) => - { - var product = await productContracts.GetProductByIdAsync(id); - return product is not null ? TypedResults.Ok(product) : TypedResults.NotFound(); - } - ); - } + "/{id}", + (ProductId id, IProductContracts productContracts) => + CrudEndpoints.GetById(() => productContracts.GetProductByIdAsync(id)) + ) + .RequirePermission(ProductsPermissions.View); } diff --git a/modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs b/modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs index a0d9e23f..bf2ab53b 100644 --- a/modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs +++ b/modules/Products/src/Products/Endpoints/Products/UpdateEndpoint.cs @@ -1,27 +1,30 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Endpoints; +using SimpleModule.Core.Exceptions; using SimpleModule.Products.Contracts; namespace SimpleModule.Products.Endpoints.Products; public class UpdateEndpoint : IEndpoint { - public void Map(IEndpointRouteBuilder app) - { + public void Map(IEndpointRouteBuilder app) => app.MapPut( - "/{id}", - async Task, NotFound>> ( - int id, - UpdateProductRequest request, - IProductContracts productContracts - ) => - { - var product = await productContracts.UpdateProductAsync(id, request); - return TypedResults.Ok(product); - } - ); - } + "/{id}", + (ProductId id, UpdateProductRequest request, IProductContracts productContracts) => + { + var validation = UpdateRequestValidator.Validate(request); + if (!validation.IsValid) + { + throw new ValidationException(validation.Errors); + } + + return CrudEndpoints.Update(() => + productContracts.UpdateProductAsync(id, request) + ); + } + ) + .RequirePermission(ProductsPermissions.Update); } diff --git a/modules/Products/src/Products/Endpoints/Products/UpdateRequestValidator.cs b/modules/Products/src/Products/Endpoints/Products/UpdateRequestValidator.cs new file mode 100644 index 00000000..45667d27 --- /dev/null +++ b/modules/Products/src/Products/Endpoints/Products/UpdateRequestValidator.cs @@ -0,0 +1,17 @@ +using SimpleModule.Core.Validation; +using SimpleModule.Products.Contracts; + +namespace SimpleModule.Products.Endpoints.Products; + +public static class UpdateRequestValidator +{ + public static ValidationResult Validate(UpdateProductRequest request) => + new ValidationBuilder() + .AddErrorIf( + string.IsNullOrWhiteSpace(request.Name), + "Name", + "Product name is required." + ) + .AddErrorIf(request.Price <= 0, "Price", "Price must be greater than zero.") + .Build(); +} diff --git a/modules/Products/src/Products/EntityConfigurations/ProductConfiguration.cs b/modules/Products/src/Products/EntityConfigurations/ProductConfiguration.cs new file mode 100644 index 00000000..79cdb80b --- /dev/null +++ b/modules/Products/src/Products/EntityConfigurations/ProductConfiguration.cs @@ -0,0 +1,38 @@ +using Bogus; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimpleModule.Products.Contracts; + +namespace SimpleModule.Products.EntityConfigurations; + +public class ProductConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(p => p.Id); + builder.Property(p => p.Id).ValueGeneratedOnAdd(); + builder.Property(p => p.Name).IsRequired(); + builder.Property(p => p.Price).HasColumnType("decimal(18,2)"); + + builder.HasData(GenerateSeedProducts()); + } + + private static Product[] GenerateSeedProducts() + { + var id = 0; + var faker = new Faker() + .UseSeed(54321) + .RuleFor(p => p.Id, _ => ProductId.From(++id)) + .RuleFor(p => p.Name, f => f.Commerce.ProductName()) + .RuleFor( + p => p.Price, + f => + decimal.Parse( + f.Commerce.Price(10, 1000), + System.Globalization.CultureInfo.InvariantCulture + ) + ); + + return faker.Generate(10).ToArray(); + } +} diff --git a/modules/Products/src/Products/ProductService.cs b/modules/Products/src/Products/ProductService.cs index 5f3494c5..453818db 100644 --- a/modules/Products/src/Products/ProductService.cs +++ b/modules/Products/src/Products/ProductService.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using SimpleModule.Products.Contracts; @@ -10,7 +10,7 @@ public partial class ProductService(ProductsDbContext db, ILogger> GetAllProductsAsync() => await db.Products.ToListAsync(); - public async Task GetProductByIdAsync(int id) + public async Task GetProductByIdAsync(ProductId id) { var product = await db.Products.FindAsync(id); if (product is null) @@ -21,7 +21,7 @@ public async Task> GetAllProductsAsync() => return product; } - public async Task> GetProductsByIdsAsync(IEnumerable ids) + public async Task> GetProductsByIdsAsync(IEnumerable ids) { var idList = ids.ToList(); return await db.Products.Where(p => idList.Contains(p.Id)).ToListAsync(); @@ -39,7 +39,7 @@ public async Task CreateProductAsync(CreateProductRequest request) return product; } - public async Task UpdateProductAsync(int id, UpdateProductRequest request) + public async Task UpdateProductAsync(ProductId id, UpdateProductRequest request) { var product = await db.Products.FindAsync(id); if (product is null) @@ -57,7 +57,7 @@ public async Task UpdateProductAsync(int id, UpdateProductRequest reque return product; } - public async Task DeleteProductAsync(int id) + public async Task DeleteProductAsync(ProductId id) { var product = await db.Products.FindAsync(id); if (product is null) @@ -72,7 +72,7 @@ public async Task DeleteProductAsync(int id) } [LoggerMessage(Level = LogLevel.Warning, Message = "Product with ID {ProductId} not found")] - private static partial void LogProductNotFound(ILogger logger, int productId); + private static partial void LogProductNotFound(ILogger logger, ProductId productId); [LoggerMessage( Level = LogLevel.Information, @@ -80,7 +80,7 @@ public async Task DeleteProductAsync(int id) )] private static partial void LogProductCreated( ILogger logger, - int productId, + ProductId productId, string productName ); @@ -90,10 +90,10 @@ string productName )] private static partial void LogProductUpdated( ILogger logger, - int productId, + ProductId productId, string productName ); [LoggerMessage(Level = LogLevel.Information, Message = "Product {ProductId} deleted")] - private static partial void LogProductDeleted(ILogger logger, int productId); + private static partial void LogProductDeleted(ILogger logger, ProductId productId); } diff --git a/modules/Products/src/Products/ProductsDbContext.cs b/modules/Products/src/Products/ProductsDbContext.cs index 70a84db8..736f649e 100644 --- a/modules/Products/src/Products/ProductsDbContext.cs +++ b/modules/Products/src/Products/ProductsDbContext.cs @@ -1,8 +1,8 @@ -using Bogus; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using SimpleModule.Database; using SimpleModule.Products.Contracts; +using SimpleModule.Products.EntityConfigurations; namespace SimpleModule.Products; @@ -15,34 +15,14 @@ IOptions dbOptions protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(entity => - { - entity.HasKey(p => p.Id); - entity.Property(p => p.Name).IsRequired(); - entity.Property(p => p.Price).HasColumnType("decimal(18,2)"); - }); - - modelBuilder.Entity().HasData(GenerateSeedProducts()); - + modelBuilder.ApplyConfiguration(new ProductConfiguration()); modelBuilder.ApplyModuleSchema("Products", dbOptions.Value); } - private static Product[] GenerateSeedProducts() + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { - var id = 0; - var faker = new Faker() - .UseSeed(54321) - .RuleFor(p => p.Id, _ => ++id) - .RuleFor(p => p.Name, f => f.Commerce.ProductName()) - .RuleFor( - p => p.Price, - f => - decimal.Parse( - f.Commerce.Price(10, 1000), - System.Globalization.CultureInfo.InvariantCulture - ) - ); - - return faker.Generate(10).ToArray(); + configurationBuilder + .Properties() + .HaveConversion(); } } diff --git a/modules/Products/src/Products/ProductsModule.cs b/modules/Products/src/Products/ProductsModule.cs index 524ef345..9257b798 100644 --- a/modules/Products/src/Products/ProductsModule.cs +++ b/modules/Products/src/Products/ProductsModule.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using SimpleModule.Core; +using SimpleModule.Core.Authorization; using SimpleModule.Core.Menu; using SimpleModule.Database; using SimpleModule.Products.Contracts; @@ -20,6 +21,11 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); } + public void ConfigurePermissions(PermissionRegistryBuilder builder) + { + builder.AddPermissions(); + } + public void ConfigureMenu(IMenuBuilder menus) { menus.Add( @@ -45,5 +51,16 @@ public void ConfigureMenu(IMenuBuilder menus) Section = MenuSection.Navbar, } ); + menus.Add( + new MenuItem + { + Label = "Products", + Url = "/products/browse", + Icon = + """""", + Order = 20, + Section = MenuSection.AppSidebar, + } + ); } } diff --git a/modules/Products/src/Products/ProductsPermissions.cs b/modules/Products/src/Products/ProductsPermissions.cs new file mode 100644 index 00000000..e1b5b71f --- /dev/null +++ b/modules/Products/src/Products/ProductsPermissions.cs @@ -0,0 +1,9 @@ +namespace SimpleModule.Products; + +public sealed class ProductsPermissions +{ + public const string View = "Products.View"; + public const string Create = "Products.Create"; + public const string Update = "Products.Update"; + public const string Delete = "Products.Delete"; +} diff --git a/modules/Products/src/Products/Views/BrowseEndpoint.cs b/modules/Products/src/Products/Views/BrowseEndpoint.cs index 99323e49..1972f7b9 100644 --- a/modules/Products/src/Products/Views/BrowseEndpoint.cs +++ b/modules/Products/src/Products/Views/BrowseEndpoint.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using SimpleModule.Core; @@ -11,12 +12,13 @@ public class BrowseEndpoint : IViewEndpoint public void Map(IEndpointRouteBuilder app) { app.MapGet( - "/browse", - async (IProductContracts products) => - Inertia.Render( - "Products/Browse", - new { products = await products.GetAllProductsAsync() } - ) - ); + "/browse", + async (IProductContracts products) => + Inertia.Render( + "Products/Browse", + new { products = await products.GetAllProductsAsync() } + ) + ) + .AllowAnonymous(); } } diff --git a/modules/Products/src/Products/Views/CreateEndpoint.cs b/modules/Products/src/Products/Views/CreateEndpoint.cs index de152331..ed41626d 100644 --- a/modules/Products/src/Products/Views/CreateEndpoint.cs +++ b/modules/Products/src/Products/Views/CreateEndpoint.cs @@ -15,17 +15,18 @@ public void Map(IEndpointRouteBuilder app) app.MapGet("/create", () => Inertia.Render("Products/Create")); app.MapPost( - "/", - async ( - [FromForm] string name, - [FromForm] decimal price, - IProductContracts products - ) => - { - var request = new CreateProductRequest { Name = name, Price = price }; - await products.CreateProductAsync(request); - return Results.Redirect("/products/manage"); - } - ).DisableAntiforgery(); + "/", + async ( + [FromForm] string name, + [FromForm] decimal price, + IProductContracts products + ) => + { + var request = new CreateProductRequest { Name = name, Price = price }; + await products.CreateProductAsync(request); + return Results.Redirect("/products/manage"); + } + ) + .DisableAntiforgery(); } } diff --git a/modules/Products/src/Products/Views/EditEndpoint.cs b/modules/Products/src/Products/Views/EditEndpoint.cs index 27222eba..aa9cf31b 100644 --- a/modules/Products/src/Products/Views/EditEndpoint.cs +++ b/modules/Products/src/Products/Views/EditEndpoint.cs @@ -14,7 +14,7 @@ public void Map(IEndpointRouteBuilder app) { app.MapGet( "/{id}/edit", - async (int id, IProductContracts products) => + async (ProductId id, IProductContracts products) => { var product = await products.GetProductByIdAsync(id); if (product is null) @@ -25,23 +25,24 @@ public void Map(IEndpointRouteBuilder app) ); app.MapPost( - "/{id}", - async ( - int id, - [FromForm] string name, - [FromForm] decimal price, - IProductContracts products - ) => - { - var request = new UpdateProductRequest { Name = name, Price = price }; - await products.UpdateProductAsync(id, request); - return Results.Redirect($"/products/{id}/edit"); - } - ).DisableAntiforgery(); + "/{id}", + async ( + ProductId id, + [FromForm] string name, + [FromForm] decimal price, + IProductContracts products + ) => + { + var request = new UpdateProductRequest { Name = name, Price = price }; + await products.UpdateProductAsync(id, request); + return Results.Redirect($"/products/{id}/edit"); + } + ) + .DisableAntiforgery(); app.MapDelete( "/{id}", - async (int id, IProductContracts products) => + async (ProductId id, IProductContracts products) => { await products.DeleteProductAsync(id); return Results.Redirect("/products/manage"); diff --git a/modules/Products/tests/Products.Tests/Integration/ProductsEndpointTests.cs b/modules/Products/tests/Products.Tests/Integration/ProductsEndpointTests.cs index 726e6063..90bac50f 100644 --- a/modules/Products/tests/Products.Tests/Integration/ProductsEndpointTests.cs +++ b/modules/Products/tests/Products.Tests/Integration/ProductsEndpointTests.cs @@ -1,6 +1,7 @@ -using System.Net; +using System.Net; using System.Net.Http.Json; using FluentAssertions; +using SimpleModule.Products; using SimpleModule.Products.Contracts; using SimpleModule.Tests.Shared.Fixtures; @@ -8,17 +9,19 @@ namespace Products.Tests.Integration; public class ProductsEndpointTests : IClassFixture { - private readonly HttpClient _client; + private readonly SimpleModuleWebApplicationFactory _factory; public ProductsEndpointTests(SimpleModuleWebApplicationFactory factory) { - _client = factory.CreateClient(); + _factory = factory; } [Fact] - public async Task GetAllProducts_Returns200WithProductList() + public async Task GetAllProducts_WithViewPermission_Returns200WithProductList() { - var response = await _client.GetAsync("/api/products"); + var client = _factory.CreateAuthenticatedClient([ProductsPermissions.View]); + + var response = await client.GetAsync("/api/products"); response.StatusCode.Should().Be(HttpStatusCode.OK); var products = await response.Content.ReadFromJsonAsync>(); @@ -26,22 +29,55 @@ public async Task GetAllProducts_Returns200WithProductList() } [Fact] - public async Task GetProductById_Returns200WithProduct() + public async Task GetAllProducts_Unauthenticated_Returns401() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/api/products"); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GetAllProducts_WithoutPermission_Returns403() + { + var client = _factory.CreateAuthenticatedClient([ProductsPermissions.Create]); + + var response = await client.GetAsync("/api/products"); + + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task GetProductById_WithViewPermission_Returns200WithProduct() { - var response = await _client.GetAsync("/api/products/1"); + var client = _factory.CreateAuthenticatedClient([ProductsPermissions.View]); + + var response = await client.GetAsync("/api/products/1"); response.StatusCode.Should().Be(HttpStatusCode.OK); var product = await response.Content.ReadFromJsonAsync(); product.Should().NotBeNull(); - product!.Id.Should().Be(1); + product!.Id.Should().Be(ProductId.From(1)); } [Fact] - public async Task CreateProduct_Returns201() + public async Task GetProductById_Unauthenticated_Returns401() { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/api/products/1"); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task CreateProduct_WithCreatePermission_Returns201() + { + var client = _factory.CreateAuthenticatedClient([ProductsPermissions.Create]); var request = new CreateProductRequest { Name = "New Product", Price = 29.99m }; - var response = await _client.PostAsJsonAsync("/api/products", request); + var response = await client.PostAsJsonAsync("/api/products", request); response.StatusCode.Should().Be(HttpStatusCode.Created); var product = await response.Content.ReadFromJsonAsync(); @@ -50,25 +86,74 @@ public async Task CreateProduct_Returns201() product.Price.Should().Be(29.99m); } + [Fact] + public async Task CreateProduct_Unauthenticated_Returns401() + { + var client = _factory.CreateClient(); + var request = new CreateProductRequest { Name = "New Product", Price = 29.99m }; + + var response = await client.PostAsJsonAsync("/api/products", request); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task CreateProduct_WithoutPermission_Returns403() + { + var client = _factory.CreateAuthenticatedClient([ProductsPermissions.View]); + var request = new CreateProductRequest { Name = "New Product", Price = 29.99m }; + + var response = await client.PostAsJsonAsync("/api/products", request); + + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + [Fact] public async Task UpdateProduct_WithNonExistentId_Returns404() { + var client = _factory.CreateAuthenticatedClient([ProductsPermissions.Update]); var request = new UpdateProductRequest { Name = "Updated", Price = 10.00m }; - var response = await _client.PutAsJsonAsync("/api/products/99999", request); + var response = await client.PutAsJsonAsync("/api/products/99999", request); response.StatusCode.Should().Be(HttpStatusCode.NotFound); } + [Fact] + public async Task UpdateProduct_Unauthenticated_Returns401() + { + var client = _factory.CreateClient(); + var request = new UpdateProductRequest { Name = "Updated", Price = 10.00m }; + + var response = await client.PutAsJsonAsync("/api/products/1", request); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task UpdateProduct_WithoutPermission_Returns403() + { + var client = _factory.CreateAuthenticatedClient([ProductsPermissions.View]); + var request = new UpdateProductRequest { Name = "Updated", Price = 10.00m }; + + var response = await client.PutAsJsonAsync("/api/products/1", request); + + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + [Fact] public async Task DeleteProduct_WithExistingId_Returns204() { + var client = _factory.CreateAuthenticatedClient( + [ProductsPermissions.Create, ProductsPermissions.Delete] + ); + // Create a product first var createRequest = new CreateProductRequest { Name = "ToDelete", Price = 5.00m }; - var createResponse = await _client.PostAsJsonAsync("/api/products", createRequest); + var createResponse = await client.PostAsJsonAsync("/api/products", createRequest); var created = await createResponse.Content.ReadFromJsonAsync(); - var response = await _client.DeleteAsync($"/api/products/{created!.Id}"); + var response = await client.DeleteAsync($"/api/products/{created!.Id}"); response.StatusCode.Should().Be(HttpStatusCode.NoContent); } @@ -76,8 +161,30 @@ public async Task DeleteProduct_WithExistingId_Returns204() [Fact] public async Task DeleteProduct_WithNonExistentId_Returns404() { - var response = await _client.DeleteAsync("/api/products/99999"); + var client = _factory.CreateAuthenticatedClient([ProductsPermissions.Delete]); + + var response = await client.DeleteAsync("/api/products/99999"); response.StatusCode.Should().Be(HttpStatusCode.NotFound); } + + [Fact] + public async Task DeleteProduct_Unauthenticated_Returns401() + { + var client = _factory.CreateClient(); + + var response = await client.DeleteAsync("/api/products/1"); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task DeleteProduct_WithoutPermission_Returns403() + { + var client = _factory.CreateAuthenticatedClient([ProductsPermissions.View]); + + var response = await client.DeleteAsync("/api/products/1"); + + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } } diff --git a/modules/Products/tests/Products.Tests/Unit/CreateRequestValidatorTests.cs b/modules/Products/tests/Products.Tests/Unit/CreateRequestValidatorTests.cs new file mode 100644 index 00000000..bb011e0a --- /dev/null +++ b/modules/Products/tests/Products.Tests/Unit/CreateRequestValidatorTests.cs @@ -0,0 +1,40 @@ +using FluentAssertions; +using SimpleModule.Products.Contracts; +using SimpleModule.Products.Endpoints.Products; + +namespace Products.Tests.Unit; + +public class CreateRequestValidatorTests +{ + [Fact] + public void Validate_WithValidRequest_ReturnsSuccess() + { + var request = new CreateProductRequest { Name = "Widget", Price = 9.99m }; + + var result = CreateRequestValidator.Validate(request); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void Validate_WithEmptyName_ReturnsError() + { + var request = new CreateProductRequest { Name = "", Price = 9.99m }; + + var result = CreateRequestValidator.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainKey("Name"); + } + + [Fact] + public void Validate_WithZeroPrice_ReturnsError() + { + var request = new CreateProductRequest { Name = "Widget", Price = 0 }; + + var result = CreateRequestValidator.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainKey("Price"); + } +} diff --git a/modules/Products/tests/Products.Tests/Unit/ProductIdTests.cs b/modules/Products/tests/Products.Tests/Unit/ProductIdTests.cs new file mode 100644 index 00000000..ee7ced68 --- /dev/null +++ b/modules/Products/tests/Products.Tests/Unit/ProductIdTests.cs @@ -0,0 +1,49 @@ +using FluentAssertions; +using SimpleModule.Products.Contracts; + +namespace Products.Tests.Unit; + +public sealed class ProductIdTests +{ + [Fact] + public void From_WithValidInt_CreatesProductId() + { + var id = ProductId.From(42); + + id.Value.Should().Be(42); + } + + [Fact] + public void From_WithZero_CreatesProductId() + { + var id = ProductId.From(0); + + id.Value.Should().Be(0); + } + + [Fact] + public void Equality_SameValue_AreEqual() + { + var id1 = ProductId.From(7); + var id2 = ProductId.From(7); + + id1.Should().Be(id2); + } + + [Fact] + public void Equality_DifferentValue_AreNotEqual() + { + var id1 = ProductId.From(1); + var id2 = ProductId.From(2); + + id1.Should().NotBe(id2); + } + + [Fact] + public void ToString_ReturnsValueString() + { + var id = ProductId.From(5); + + id.ToString(System.Globalization.CultureInfo.InvariantCulture).Should().Contain("5"); + } +} diff --git a/modules/Products/tests/Products.Tests/Unit/ProductServiceTests.cs b/modules/Products/tests/Products.Tests/Unit/ProductServiceTests.cs index e8551802..d6c1be7d 100644 --- a/modules/Products/tests/Products.Tests/Unit/ProductServiceTests.cs +++ b/modules/Products/tests/Products.Tests/Unit/ProductServiceTests.cs @@ -46,7 +46,7 @@ public async Task GetAllProductsAsync_ReturnsAllProducts() .Should() .AllSatisfy(p => { - p.Id.Should().BeGreaterThan(0); + p.Id.Value.Should().BeGreaterThan(0); p.Name.Should().NotBeNullOrWhiteSpace(); p.Price.Should().BeGreaterThan(0); }); @@ -55,16 +55,16 @@ public async Task GetAllProductsAsync_ReturnsAllProducts() [Fact] public async Task GetProductByIdAsync_WithExistingId_ReturnsProduct() { - var product = await _sut.GetProductByIdAsync(1); + var product = await _sut.GetProductByIdAsync(ProductId.From(1)); product.Should().NotBeNull(); - product!.Id.Should().Be(1); + product!.Id.Should().Be(ProductId.From(1)); } [Fact] public async Task GetProductByIdAsync_WithNonExistentId_ReturnsNull() { - var product = await _sut.GetProductByIdAsync(99999); + var product = await _sut.GetProductByIdAsync(ProductId.From(99999)); product.Should().BeNull(); } @@ -79,7 +79,7 @@ public async Task CreateProductAsync_CreatesAndReturnsProduct() product.Should().NotBeNull(); product.Name.Should().Be("Test Widget"); product.Price.Should().Be(19.99m); - product.Id.Should().BeGreaterThan(0); + product.Id.Value.Should().BeGreaterThan(0); } [Fact] @@ -100,7 +100,7 @@ public async Task UpdateProductAsync_WithNonExistentId_ThrowsNotFoundException() { var request = new UpdateProductRequest { Name = "Test", Price = 10.00m }; - var act = () => _sut.UpdateProductAsync(99999, request); + var act = () => _sut.UpdateProductAsync(ProductId.From(99999), request); await act.Should().ThrowAsync().WithMessage("*Product*99999*not found*"); } @@ -120,7 +120,7 @@ public async Task DeleteProductAsync_WithExistingId_RemovesProduct() [Fact] public async Task DeleteProductAsync_WithNonExistentId_ThrowsNotFoundException() { - var act = () => _sut.DeleteProductAsync(99999); + var act = () => _sut.DeleteProductAsync(ProductId.From(99999)); await act.Should().ThrowAsync().WithMessage("*Product*99999*not found*"); } diff --git a/modules/Products/tests/Products.Tests/Unit/UpdateRequestValidatorTests.cs b/modules/Products/tests/Products.Tests/Unit/UpdateRequestValidatorTests.cs new file mode 100644 index 00000000..523bf5bc --- /dev/null +++ b/modules/Products/tests/Products.Tests/Unit/UpdateRequestValidatorTests.cs @@ -0,0 +1,62 @@ +using FluentAssertions; +using SimpleModule.Products.Contracts; +using SimpleModule.Products.Endpoints.Products; + +namespace Products.Tests.Unit; + +public class UpdateRequestValidatorTests +{ + [Fact] + public void Validate_WithValidRequest_ReturnsSuccess() + { + var request = new UpdateProductRequest { Name = "Widget", Price = 9.99m }; + + var result = UpdateRequestValidator.Validate(request); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void Validate_WithEmptyName_ReturnsError() + { + var request = new UpdateProductRequest { Name = "", Price = 9.99m }; + + var result = UpdateRequestValidator.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainKey("Name"); + } + + [Fact] + public void Validate_WithWhitespaceOnlyName_ReturnsError() + { + var request = new UpdateProductRequest { Name = " ", Price = 9.99m }; + + var result = UpdateRequestValidator.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainKey("Name"); + } + + [Fact] + public void Validate_WithZeroPrice_ReturnsError() + { + var request = new UpdateProductRequest { Name = "Widget", Price = 0m }; + + var result = UpdateRequestValidator.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainKey("Price"); + } + + [Fact] + public void Validate_WithNegativePrice_ReturnsError() + { + var request = new UpdateProductRequest { Name = "Widget", Price = -5.00m }; + + var result = UpdateRequestValidator.Validate(request); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainKey("Price"); + } +} diff --git a/modules/Users/src/Users.Contracts/Constants/ConfigKeys.cs b/modules/Users/src/Users.Contracts/Constants/ConfigKeys.cs index bd884f0f..9801e76a 100644 --- a/modules/Users/src/Users.Contracts/Constants/ConfigKeys.cs +++ b/modules/Users/src/Users.Contracts/Constants/ConfigKeys.cs @@ -2,10 +2,5 @@ namespace SimpleModule.Users.Constants; public static class ConfigKeys { - public const string OpenIddictBaseUrl = "OpenIddict:BaseUrl"; - public const string OpenIddictEncryptionCertPath = "OpenIddict:EncryptionCertificatePath"; - public const string OpenIddictSigningCertPath = "OpenIddict:SigningCertificatePath"; - public const string OpenIddictCertPassword = "OpenIddict:CertificatePassword"; - public const string OpenIddictAdditionalRedirectUris = "OpenIddict:AdditionalRedirectUris"; public const string SeedAdminPassword = "Seed:AdminPassword"; } diff --git a/modules/Users/src/Users.Contracts/IUserContracts.cs b/modules/Users/src/Users.Contracts/IUserContracts.cs index aaa8fbcf..e673d078 100644 --- a/modules/Users/src/Users.Contracts/IUserContracts.cs +++ b/modules/Users/src/Users.Contracts/IUserContracts.cs @@ -1,11 +1,11 @@ -namespace SimpleModule.Users.Contracts; +namespace SimpleModule.Users.Contracts; public interface IUserContracts { Task> GetAllUsersAsync(); - Task GetUserByIdAsync(string id); - Task GetCurrentUserAsync(string userId); + Task GetUserByIdAsync(UserId id); + Task GetCurrentUserAsync(UserId userId); Task CreateUserAsync(CreateUserRequest request); - Task UpdateUserAsync(string id, UpdateUserRequest request); - Task DeleteUserAsync(string id); + Task UpdateUserAsync(UserId id, UpdateUserRequest request); + Task DeleteUserAsync(UserId id); } diff --git a/modules/Users/src/Users.Contracts/UserDto.cs b/modules/Users/src/Users.Contracts/UserDto.cs index eaecc6a7..2ead5734 100644 --- a/modules/Users/src/Users.Contracts/UserDto.cs +++ b/modules/Users/src/Users.Contracts/UserDto.cs @@ -1,11 +1,11 @@ -using SimpleModule.Core; +using SimpleModule.Core; namespace SimpleModule.Users.Contracts; [Dto] public class UserDto { - public string Id { get; set; } = string.Empty; + public UserId Id { get; set; } public string Email { get; set; } = string.Empty; public string DisplayName { get; set; } = string.Empty; public bool EmailConfirmed { get; set; } diff --git a/modules/Users/src/Users.Contracts/UserId.cs b/modules/Users/src/Users.Contracts/UserId.cs new file mode 100644 index 00000000..15152613 --- /dev/null +++ b/modules/Users/src/Users.Contracts/UserId.cs @@ -0,0 +1,12 @@ +using Vogen; + +namespace SimpleModule.Users.Contracts; + +[ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] +public readonly partial struct UserId +{ + private static Vogen.Validation Validate(string value) => + string.IsNullOrWhiteSpace(value) + ? Vogen.Validation.Invalid("UserId cannot be empty") + : Vogen.Validation.Ok; +} diff --git a/modules/Users/src/Users.Contracts/Users.Contracts.csproj b/modules/Users/src/Users.Contracts/Users.Contracts.csproj index b33451b0..97fdfa07 100644 --- a/modules/Users/src/Users.Contracts/Users.Contracts.csproj +++ b/modules/Users/src/Users.Contracts/Users.Contracts.csproj @@ -2,8 +2,11 @@ net10.0 Library + $(DefineConstants);VOGEN_NO_VALIDATION + + diff --git a/modules/Users/src/Users/Endpoints/Admin/AdminRolesEndpoint.cs b/modules/Users/src/Users/Endpoints/Admin/AdminRolesEndpoint.cs deleted file mode 100644 index 8ec55dcb..00000000 --- a/modules/Users/src/Users/Endpoints/Admin/AdminRolesEndpoint.cs +++ /dev/null @@ -1,108 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Users.Entities; - -namespace SimpleModule.Users.Endpoints.Admin; - -public class AdminRolesEndpoint : IEndpoint -{ - public void Map(IEndpointRouteBuilder app) - { - var group = app.MapGroup("/admin/roles") - .WithTags(UsersConstants.ModuleName) - .RequireAuthorization(policy => policy.RequireRole("Admin")); - - // Create role - group - .MapPost( - "/", - async ( - [FromForm] string name, - [FromForm] string? description, - RoleManager roleManager - ) => - { - var trimmedName = name.Trim(); - var trimmedDescription = description?.Trim(); - - if (string.IsNullOrEmpty(trimmedName)) - return Results.Redirect("/admin/roles/create"); - - var role = new ApplicationRole - { - Name = trimmedName, - Description = string.IsNullOrEmpty(trimmedDescription) - ? null - : trimmedDescription, - }; - - var result = await roleManager.CreateAsync(role); - if (!result.Succeeded) - return Results.Redirect("/admin/roles/create"); - - return Results.Redirect("/admin/roles"); - } - ) - .DisableAntiforgery(); - - // Update role - group - .MapPost( - "/{id}", - async ( - string id, - [FromForm] string name, - [FromForm] string? description, - RoleManager roleManager - ) => - { - var role = await roleManager.FindByIdAsync(id); - if (role is null) - return Results.NotFound(); - - role.Name = name.Trim(); - var trimmedDescription = description?.Trim(); - role.Description = string.IsNullOrEmpty(trimmedDescription) - ? null - : trimmedDescription; - - await roleManager.UpdateAsync(role); - - return Results.Redirect($"/admin/roles/{id}/edit"); - } - ) - .DisableAntiforgery(); - - // Delete role - group.MapDelete( - "/{id}", - async ( - string id, - RoleManager roleManager, - UserManager userManager - ) => - { - var role = await roleManager.FindByIdAsync(id); - if (role is null) - return Results.NotFound(); - - // Don't delete if users are assigned - var usersInRole = role.Name is not null - ? await userManager.GetUsersInRoleAsync(role.Name) - : []; - if (usersInRole.Count > 0) - return Results.BadRequest( - new { error = "Cannot delete role with assigned users" } - ); - - await roleManager.DeleteAsync(role); - - return Results.Redirect("/admin/roles"); - } - ); - } -} diff --git a/modules/Users/src/Users/Endpoints/Admin/AdminUsersEndpoint.cs b/modules/Users/src/Users/Endpoints/Admin/AdminUsersEndpoint.cs deleted file mode 100644 index 4972132d..00000000 --- a/modules/Users/src/Users/Endpoints/Admin/AdminUsersEndpoint.cs +++ /dev/null @@ -1,108 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Users.Entities; - -namespace SimpleModule.Users.Endpoints.Admin; - -public class AdminUsersEndpoint : IEndpoint -{ - public void Map(IEndpointRouteBuilder app) - { - var group = app.MapGroup("/admin/users") - .WithTags(UsersConstants.ModuleName) - .RequireAuthorization(policy => policy.RequireRole("Admin")); - - // Update user - group - .MapPost( - "/{id}", - async ( - string id, - [FromForm] string displayName, - [FromForm] string email, - [FromForm] string? emailConfirmed, - UserManager userManager - ) => - { - var user = await userManager.FindByIdAsync(id); - if (user is null) - return Results.NotFound(); - - user.DisplayName = displayName; - user.Email = email; - user.EmailConfirmed = emailConfirmed is not null; - - await userManager.UpdateAsync(user); - - return Results.Redirect($"/admin/users/{id}/edit"); - } - ) - .DisableAntiforgery(); - - // Set roles - group - .MapPost( - "/{id}/roles", - async ( - string id, - HttpContext context, - UserManager userManager - ) => - { - var user = await userManager.FindByIdAsync(id); - if (user is null) - return Results.NotFound(); - - var form = await context.Request.ReadFormAsync(); - var newRoles = form["roles"] - .ToArray() - .Where(r => !string.IsNullOrEmpty(r)) - .ToList(); - var currentRoles = await userManager.GetRolesAsync(user); - - await userManager.RemoveFromRolesAsync(user, currentRoles); - if (newRoles.Count > 0) - await userManager.AddToRolesAsync(user, newRoles!); - - return Results.Redirect($"/admin/users/{id}/edit"); - } - ) - .DisableAntiforgery(); - - // Lock account - group.MapPost( - "/{id}/lock", - async (string id, UserManager userManager) => - { - var user = await userManager.FindByIdAsync(id); - if (user is null) - return Results.NotFound(); - - await userManager.SetLockoutEnabledAsync(user, true); - await userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddYears(100)); - - return Results.Redirect($"/admin/users/{id}/edit"); - } - ); - - // Unlock account - group.MapPost( - "/{id}/unlock", - async (string id, UserManager userManager) => - { - var user = await userManager.FindByIdAsync(id); - if (user is null) - return Results.NotFound(); - - await userManager.SetLockoutEndDateAsync(user, null); - await userManager.ResetAccessFailedCountAsync(user); - - return Results.Redirect($"/admin/users/{id}/edit"); - } - ); - } -} diff --git a/modules/Users/src/Users/Endpoints/Users/DeleteEndpoint.cs b/modules/Users/src/Users/Endpoints/Users/DeleteEndpoint.cs index b33c3ec4..9245e7f5 100644 --- a/modules/Users/src/Users/Endpoints/Users/DeleteEndpoint.cs +++ b/modules/Users/src/Users/Endpoints/Users/DeleteEndpoint.cs @@ -12,7 +12,7 @@ public void Map(IEndpointRouteBuilder app) { app.MapDelete( UsersConstants.RoutePrefix + "/{id}", - async (string id, IUserContracts userContracts) => + async (UserId id, IUserContracts userContracts) => { await userContracts.DeleteUserAsync(id); return TypedResults.NoContent(); diff --git a/modules/Users/src/Users/Endpoints/Users/DownloadPersonalDataEndpoint.cs b/modules/Users/src/Users/Endpoints/Users/DownloadPersonalDataEndpoint.cs index e4a9f16e..03e2fe9c 100644 --- a/modules/Users/src/Users/Endpoints/Users/DownloadPersonalDataEndpoint.cs +++ b/modules/Users/src/Users/Endpoints/Users/DownloadPersonalDataEndpoint.cs @@ -31,7 +31,7 @@ ILogger logger logger.LogInformation("User asked for their personal data."); - // Manually enumerate personal data properties (AOT-compatible, no reflection) + // Manually enumerate personal data properties var personalData = new Dictionary { [PersonalDataKeys.Id] = user.Id ?? PersonalDataKeys.NullPlaceholder, diff --git a/modules/Users/src/Users/Endpoints/Users/GetByIdEndpoint.cs b/modules/Users/src/Users/Endpoints/Users/GetByIdEndpoint.cs index 345be546..f80a00dd 100644 --- a/modules/Users/src/Users/Endpoints/Users/GetByIdEndpoint.cs +++ b/modules/Users/src/Users/Endpoints/Users/GetByIdEndpoint.cs @@ -14,7 +14,7 @@ public void Map(IEndpointRouteBuilder app) app.MapGet( UsersConstants.RoutePrefix + "/{id}", async Task, NotFound>> ( - string id, + UserId id, IUserContracts userContracts ) => { diff --git a/modules/Users/src/Users/Endpoints/Users/GetCurrentEndpoint.cs b/modules/Users/src/Users/Endpoints/Users/GetCurrentEndpoint.cs index 4e2d2b54..c24beb48 100644 --- a/modules/Users/src/Users/Endpoints/Users/GetCurrentEndpoint.cs +++ b/modules/Users/src/Users/Endpoints/Users/GetCurrentEndpoint.cs @@ -20,13 +20,13 @@ async Task, NotFound>> ( IUserContracts userContracts ) => { - var userId = principal.GetUserId(); - if (string.IsNullOrEmpty(userId)) + var userIdStr = principal.GetUserId(); + if (string.IsNullOrEmpty(userIdStr)) { return TypedResults.NotFound(); } - var user = await userContracts.GetCurrentUserAsync(userId); + var user = await userContracts.GetCurrentUserAsync(UserId.From(userIdStr)); return user is not null ? TypedResults.Ok(user) : TypedResults.NotFound(); } ) diff --git a/modules/Users/src/Users/Endpoints/Users/UpdateEndpoint.cs b/modules/Users/src/Users/Endpoints/Users/UpdateEndpoint.cs index ed9b9e86..f85650ba 100644 --- a/modules/Users/src/Users/Endpoints/Users/UpdateEndpoint.cs +++ b/modules/Users/src/Users/Endpoints/Users/UpdateEndpoint.cs @@ -14,7 +14,7 @@ public void Map(IEndpointRouteBuilder app) app.MapPut( UsersConstants.RoutePrefix + "/{id}", async Task, NotFound>> ( - string id, + UserId id, UpdateUserRequest request, IUserContracts userContracts ) => diff --git a/modules/Users/src/Users/Entities/ApplicationUser.cs b/modules/Users/src/Users/Entities/ApplicationUser.cs index 23d95661..51e570f2 100644 --- a/modules/Users/src/Users/Entities/ApplicationUser.cs +++ b/modules/Users/src/Users/Entities/ApplicationUser.cs @@ -7,4 +7,5 @@ public class ApplicationUser : IdentityUser public string DisplayName { get; set; } = string.Empty; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? LastLoginAt { get; set; } + public DateTimeOffset? DeactivatedAt { get; set; } } diff --git a/modules/Users/src/Users/Extensions/ClaimsPrincipalExtensions.cs b/modules/Users/src/Users/Extensions/ClaimsPrincipalExtensions.cs index 920c6e7b..66c7cd27 100644 --- a/modules/Users/src/Users/Extensions/ClaimsPrincipalExtensions.cs +++ b/modules/Users/src/Users/Extensions/ClaimsPrincipalExtensions.cs @@ -1,5 +1,4 @@ -using System.Security.Claims; -using OpenIddict.Abstractions; +using System.Security.Claims; namespace SimpleModule.Users.Extensions; @@ -10,7 +9,7 @@ public static class ClaimsPrincipalExtensions /// public static string? GetUserId(this ClaimsPrincipal principal) { - return principal.GetClaim(OpenIddictConstants.Claims.Subject) + return principal.FindFirstValue("sub") ?? principal.FindFirstValue(ClaimTypes.NameIdentifier); } } diff --git a/modules/Users/src/Users/Pages/Account/ManageLayout.tsx b/modules/Users/src/Users/Pages/Account/ManageLayout.tsx index 78a5b703..9399736d 100644 --- a/modules/Users/src/Users/Pages/Account/ManageLayout.tsx +++ b/modules/Users/src/Users/Pages/Account/ManageLayout.tsx @@ -36,21 +36,6 @@ const navItems = [ }, ]; -const adminItems = [ - { - href: '/admin/users', - page: 'users', - label: 'Manage Users', - icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z', - }, - { - href: '/admin/roles', - page: 'roles', - label: 'Manage Roles', - icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z', - }, -]; - function NavLink({ href, active, @@ -123,21 +108,6 @@ export default function ManageLayout({ activePage, children }: ManageLayoutProps {item.label} ))} -
-
- Admin -
- {adminItems.map((item) => ( - - {item.label} - - ))} -
diff --git a/modules/Users/src/Users/Pages/Admin/RolesEdit.tsx b/modules/Users/src/Users/Pages/Admin/RolesEdit.tsx deleted file mode 100644 index afc9ea86..00000000 --- a/modules/Users/src/Users/Pages/Admin/RolesEdit.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { router } from '@inertiajs/react'; -import { - Button, - Card, - CardContent, - CardHeader, - CardTitle, - Input, - Label, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@simplemodule/ui'; - -interface RoleDetail { - id: string; - name: string; - description: string | null; - createdAt: string; -} - -interface UserSummary { - id: string; - displayName: string; - email: string; -} - -interface Props { - role: RoleDetail; - users: UserSummary[]; -} - -export default function RolesEdit({ role, users }: Props) { - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - const formData = new FormData(e.currentTarget); - router.post(`/admin/roles/${role.id}`, formData); - } - - return ( -
-
- - - - - -

Edit Role

-
-

- Created: {new Date(role.createdAt).toLocaleString()} -

- - - -
-
- - -
-
- - -
- -
-
-
- - - - Assigned Users ({users.length}) - - - {users.length === 0 ? ( -

No users assigned to this role.

- ) : ( - - - - Name - Email - - - - - {users.map((user) => ( - - {user.displayName || '\u2014'} - {user.email} - - - - - ))} - -
- )} -
-
-
- ); -} diff --git a/modules/Users/src/Users/Pages/Admin/UsersEdit.tsx b/modules/Users/src/Users/Pages/Admin/UsersEdit.tsx deleted file mode 100644 index 79b55d8f..00000000 --- a/modules/Users/src/Users/Pages/Admin/UsersEdit.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { router } from '@inertiajs/react'; -import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label } from '@simplemodule/ui'; - -interface UserDetail { - id: string; - displayName: string; - email: string; - emailConfirmed: boolean; - isLockedOut: boolean; - createdAt: string; - lastLoginAt: string | null; -} - -interface Role { - id: string; - name: string; - description: string | null; -} - -interface Props { - user: UserDetail; - userRoles: string[]; - allRoles: Role[]; -} - -export default function UsersEdit({ user, userRoles, allRoles }: Props) { - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - const formData = new FormData(e.currentTarget); - router.post(`/admin/users/${user.id}`, formData); - } - - function handleRolesSubmit(e: React.FormEvent) { - e.preventDefault(); - const formData = new FormData(e.currentTarget); - router.post(`/admin/users/${user.id}/roles`, formData); - } - - function handleLock() { - router.post(`/admin/users/${user.id}/lock`); - } - - function handleUnlock() { - router.post(`/admin/users/${user.id}/unlock`); - } - - return ( -
-
- - - - - -

Edit User

-
-

- Created: {new Date(user.createdAt).toLocaleString()} - {user.lastLoginAt && ( - Last login: {new Date(user.lastLoginAt).toLocaleString()} - )} -

- - - - Details - - -
-
- - -
-
- - -
-
- - -
- -
-
-
- - - - Roles - - -
-
- {allRoles.map((role) => ( -
- - -
- ))} - {allRoles.length === 0 && ( -

No roles defined.

- )} -
- -
-
-
- - - - Account Status - - - {user.isLockedOut ? ( -
-

This account is locked.

- -
- ) : ( -
-

This account is active.

- -
- )} -
-
-
- ); -} diff --git a/modules/Users/src/Users/Pages/index.ts b/modules/Users/src/Users/Pages/index.ts index ef9aca57..049d7896 100644 --- a/modules/Users/src/Users/Pages/index.ts +++ b/modules/Users/src/Users/Pages/index.ts @@ -4,11 +4,6 @@ import GenerateRecoveryCodes from './Account/GenerateRecoveryCodes'; import ResetAuthenticator from './Account/ResetAuthenticator'; import ShowRecoveryCodes from './Account/ShowRecoveryCodes'; import TwoFactorAuthentication from './Account/TwoFactorAuthentication'; -import Roles from './Admin/Roles'; -import RolesCreate from './Admin/RolesCreate'; -import RolesEdit from './Admin/RolesEdit'; -import Users from './Admin/Users'; -import UsersEdit from './Admin/UsersEdit'; export const pages: Record = { 'Users/Account/TwoFactorAuthentication': TwoFactorAuthentication, @@ -17,9 +12,4 @@ export const pages: Record = { 'Users/Account/ResetAuthenticator': ResetAuthenticator, 'Users/Account/GenerateRecoveryCodes': GenerateRecoveryCodes, 'Users/Account/ShowRecoveryCodes': ShowRecoveryCodes, - 'Users/Admin/Users': Users, - 'Users/Admin/UsersEdit': UsersEdit, - 'Users/Admin/Roles': Roles, - 'Users/Admin/RolesCreate': RolesCreate, - 'Users/Admin/RolesEdit': RolesEdit, }; diff --git a/modules/Users/src/Users/Services/OpenIddictSeedService.cs b/modules/Users/src/Users/Services/UserSeedService.cs similarity index 52% rename from modules/Users/src/Users/Services/OpenIddictSeedService.cs rename to modules/Users/src/Users/Services/UserSeedService.cs index e97d71ff..1b56f692 100644 --- a/modules/Users/src/Users/Services/OpenIddictSeedService.cs +++ b/modules/Users/src/Users/Services/UserSeedService.cs @@ -1,101 +1,41 @@ -using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using OpenIddict.Abstractions; using SimpleModule.Users.Constants; using SimpleModule.Users.Entities; -using static OpenIddict.Abstractions.OpenIddictConstants; namespace SimpleModule.Users.Services; -public partial class OpenIddictSeedService( +public partial class UserSeedService( IServiceProvider serviceProvider, IConfiguration configuration, IHostEnvironment hostEnvironment, - ILogger logger + ILogger logger ) : IHostedService { public async Task StartAsync(CancellationToken cancellationToken) { - using var scope = serviceProvider.CreateScope(); - - await SeedClientApplicationAsync(scope, cancellationToken); + if (!hostEnvironment.IsDevelopment()) + return; - // Only seed default roles and admin user in Development - if (hostEnvironment.IsDevelopment()) + try { + using var scope = serviceProvider.CreateScope(); await SeedRolesAsync(scope); await SeedAdminUserAsync(scope); } - } - - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - private async Task SeedClientApplicationAsync( - IServiceScope scope, - CancellationToken cancellationToken - ) - { - var manager = scope.ServiceProvider.GetRequiredService(); - - if ( - await manager.FindByClientIdAsync(ClientConstants.ClientId, cancellationToken) - is not null - ) - { - return; - } - - LogSeedingClient(logger); - - var baseUrl = configuration[ConfigKeys.OpenIddictBaseUrl] ?? ClientConstants.DefaultBaseUrl; - - var descriptor = new OpenIddictApplicationDescriptor +#pragma warning disable CA1031 // Seed service must not crash the host on database errors + catch (Exception ex) +#pragma warning restore CA1031 { - ClientId = ClientConstants.ClientId, - DisplayName = ClientConstants.ClientDisplayName, - ClientType = ClientTypes.Public, - RedirectUris = - { - new Uri($"{baseUrl}{ClientConstants.SwaggerCallbackPath}"), - new Uri($"{baseUrl}{ClientConstants.OAuthCallbackPath}"), - }, - PostLogoutRedirectUris = - { - new Uri($"{baseUrl}{ClientConstants.PostLogoutRedirectPath}"), - }, - Permissions = - { - Permissions.Endpoints.Authorization, - Permissions.Endpoints.Token, - Permissions.Endpoints.EndSession, - Permissions.GrantTypes.AuthorizationCode, - Permissions.GrantTypes.RefreshToken, - Permissions.ResponseTypes.Code, - Permissions.Scopes.Email, - Permissions.Scopes.Profile, - Permissions.Prefixes.Scope + AuthConstants.RolesScope, - }, - Requirements = { Requirements.Features.ProofKeyForCodeExchange }, - }; - - // Allow additional redirect URIs from configuration - var additionalRedirects = configuration - .GetSection(ConfigKeys.OpenIddictAdditionalRedirectUris) - .Get(); - if (additionalRedirects is not null) - { - foreach (var uri in additionalRedirects) - { - descriptor.RedirectUris.Add(new Uri(uri)); - } + LogSeedError(logger, ex.Message); } - - await manager.CreateAsync(descriptor, cancellationToken); } + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + private async Task SeedRolesAsync(IServiceScope scope) { var roleManager = scope.ServiceProvider.GetRequiredService>(); @@ -161,12 +101,6 @@ private async Task SeedAdminUserAsync(IServiceScope scope) } } - [LoggerMessage( - Level = LogLevel.Information, - Message = "Seeding OpenIddict client application..." - )] - private static partial void LogSeedingClient(ILogger logger); - [LoggerMessage(Level = LogLevel.Information, Message = "Seeding roles...")] private static partial void LogSeedingRoles(ILogger logger); diff --git a/modules/Users/src/Users/UserService.cs b/modules/Users/src/Users/UserService.cs index bb23bebc..b2c81725 100644 --- a/modules/Users/src/Users/UserService.cs +++ b/modules/Users/src/Users/UserService.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using SimpleModule.Users.Contracts; @@ -17,9 +17,9 @@ public async Task> GetAllUsersAsync() return users.Select(MapToDto); } - public async Task GetUserByIdAsync(string id) + public async Task GetUserByIdAsync(UserId id) { - var user = await userManager.FindByIdAsync(id); + var user = await userManager.FindByIdAsync(id.Value); if (user is null) { LogUserNotFound(logger, id); @@ -29,7 +29,7 @@ public async Task> GetAllUsersAsync() return MapToDto(user); } - public async Task GetCurrentUserAsync(string userId) + public async Task GetCurrentUserAsync(UserId userId) { return await GetUserByIdAsync(userId); } @@ -37,7 +37,7 @@ public async Task> GetAllUsersAsync() private static UserDto MapToDto(ApplicationUser user) => new() { - Id = user.Id, + Id = UserId.From(user.Id), Email = user.Email ?? string.Empty, DisplayName = user.DisplayName, EmailConfirmed = user.EmailConfirmed, @@ -65,9 +65,9 @@ public async Task CreateUserAsync(CreateUserRequest request) return MapToDto(user); } - public async Task UpdateUserAsync(string id, UpdateUserRequest request) + public async Task UpdateUserAsync(UserId id, UpdateUserRequest request) { - var user = await userManager.FindByIdAsync(id); + var user = await userManager.FindByIdAsync(id.Value); if (user is null) { throw new Core.Exceptions.NotFoundException("User", id); @@ -84,9 +84,9 @@ public async Task UpdateUserAsync(string id, UpdateUserRequest request) return MapToDto(user); } - public async Task DeleteUserAsync(string id) + public async Task DeleteUserAsync(UserId id) { - var user = await userManager.FindByIdAsync(id); + var user = await userManager.FindByIdAsync(id.Value); if (user is null) { throw new Core.Exceptions.NotFoundException("User", id); @@ -98,7 +98,7 @@ public async Task DeleteUserAsync(string id) } [LoggerMessage(Level = LogLevel.Warning, Message = "User with ID {UserId} not found")] - private static partial void LogUserNotFound(ILogger logger, string userId); + private static partial void LogUserNotFound(ILogger logger, UserId userId); [LoggerMessage( Level = LogLevel.Information, @@ -110,5 +110,5 @@ public async Task DeleteUserAsync(string id) private static partial void LogUserUpdated(ILogger logger, string userId); [LoggerMessage(Level = LogLevel.Information, Message = "User {UserId} deleted")] - private static partial void LogUserDeleted(ILogger logger, string userId); + private static partial void LogUserDeleted(ILogger logger, UserId userId); } diff --git a/modules/Users/src/Users/Users.csproj b/modules/Users/src/Users/Users.csproj index d0558664..428e7818 100644 --- a/modules/Users/src/Users/Users.csproj +++ b/modules/Users/src/Users/Users.csproj @@ -5,8 +5,6 @@ - - diff --git a/modules/Users/src/Users/UsersDbContext.cs b/modules/Users/src/Users/UsersDbContext.cs index ccbaa883..dfd8cc67 100644 --- a/modules/Users/src/Users/UsersDbContext.cs +++ b/modules/Users/src/Users/UsersDbContext.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using SimpleModule.Database; @@ -14,7 +14,6 @@ IOptions dbOptions protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); - builder.ApplyModuleSchema("Users", dbOptions.Value); } } diff --git a/modules/Users/src/Users/UsersModule.cs b/modules/Users/src/Users/UsersModule.cs index 6e7ff03a..d1cf304b 100644 --- a/modules/Users/src/Users/UsersModule.cs +++ b/modules/Users/src/Users/UsersModule.cs @@ -1,12 +1,9 @@ using Microsoft.AspNetCore.Identity; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using SimpleModule.Core; -using SimpleModule.Core.Constants; using SimpleModule.Core.Menu; using SimpleModule.Database; -using SimpleModule.Users.Constants; using SimpleModule.Users.Contracts; using SimpleModule.Users.Entities; using SimpleModule.Users.Services; @@ -18,14 +15,8 @@ public class UsersModule : IModule { public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { - // DbContext with OpenIddict EF Core extension - services.AddModuleDbContext( - configuration, - UsersConstants.ModuleName, - opts => opts.UseOpenIddict() - ); + services.AddModuleDbContext(configuration, UsersConstants.ModuleName); - // ASP.NET Core Identity services .AddIdentity(options => { @@ -42,111 +33,13 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config .AddEntityFrameworkStores() .AddDefaultTokenProviders(); - // OpenIddict - services - .AddOpenIddict() - .AddCore(options => - { - options.UseEntityFrameworkCore().UseDbContext(); - }) - .AddServer(options => - { - options.AllowAuthorizationCodeFlow().RequireProofKeyForCodeExchange(); - - options.AllowRefreshTokenFlow(); - - options - .SetAuthorizationEndpointUris(ConnectRouteConstants.ConnectAuthorize) - .SetTokenEndpointUris(ConnectRouteConstants.ConnectToken) - .SetEndSessionEndpointUris(ConnectRouteConstants.ConnectEndSession) - .SetUserInfoEndpointUris(ConnectRouteConstants.ConnectUserInfo); - - var encryptionCertPath = configuration[ConfigKeys.OpenIddictEncryptionCertPath]; - var signingCertPath = configuration[ConfigKeys.OpenIddictSigningCertPath]; - - if ( - !string.IsNullOrEmpty(encryptionCertPath) - && !string.IsNullOrEmpty(signingCertPath) - ) - { - // Production: use real certificates - var certPassword = configuration[ConfigKeys.OpenIddictCertPassword]; - options.AddEncryptionCertificate( - System.Security.Cryptography.X509Certificates.X509CertificateLoader.LoadPkcs12FromFile( - encryptionCertPath, - certPassword - ) - ); - options.AddSigningCertificate( - System.Security.Cryptography.X509Certificates.X509CertificateLoader.LoadPkcs12FromFile( - signingCertPath, - certPassword - ) - ); - } - else - { - // Development/Testing: use auto-generated development certificates - options - .AddDevelopmentEncryptionCertificate() - .AddDevelopmentSigningCertificate(); - } - - options.RegisterScopes( - AuthConstants.OpenIdScope, - AuthConstants.ProfileScope, - AuthConstants.EmailScope, - AuthConstants.RolesScope - ); - - options - .UseAspNetCore() - .EnableAuthorizationEndpointPassthrough() - .EnableEndSessionEndpointPassthrough() - .EnableUserInfoEndpointPassthrough(); - }) - .AddValidation(options => - { - options.UseLocalServer(); - options.UseAspNetCore(); - }); - - // Seed service - services.AddHostedService(); - - // Email sender (console-based for development) + services.AddHostedService(); services.AddSingleton, ConsoleEmailSender>(); - - // Services services.AddScoped(); } public void ConfigureMenu(IMenuBuilder menus) { - // Navbar items - menus.Add( - new MenuItem - { - Label = "Users", - Url = "/admin/users", - Icon = - """""", - Order = 20, - Section = MenuSection.Navbar, - } - ); - menus.Add( - new MenuItem - { - Label = "Roles", - Url = "/admin/roles", - Icon = - """""", - Order = 21, - Section = MenuSection.Navbar, - } - ); - // User dropdown items menus.Add( new MenuItem @@ -184,28 +77,17 @@ public void ConfigureMenu(IMenuBuilder menus) Group = "account", } ); + + // App sidebar item menus.Add( new MenuItem { - Label = "Manage Users", - Url = "/admin/users", - Icon = - """""", - Order = 30, - Section = MenuSection.UserDropdown, - Group = "admin", - } - ); - menus.Add( - new MenuItem - { - Label = "Manage Roles", - Url = "/admin/roles", + Label = "Account", + Url = "/Identity/Account/Manage", Icon = - """""", - Order = 31, - Section = MenuSection.UserDropdown, - Group = "admin", + """""", + Order = 80, + Section = MenuSection.AppSidebar, } ); } diff --git a/modules/Users/src/Users/Views/Admin/RolesCreateEndpoint.cs b/modules/Users/src/Users/Views/Admin/RolesCreateEndpoint.cs deleted file mode 100644 index 5cc3c364..00000000 --- a/modules/Users/src/Users/Views/Admin/RolesCreateEndpoint.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; -using SimpleModule.Core; -using SimpleModule.Core.Inertia; - -namespace SimpleModule.Users.Views.Admin; - -public class RolesCreateEndpoint : IViewEndpoint -{ - public void Map(IEndpointRouteBuilder app) - { - app.MapGet("/admin/roles/create", () => Inertia.Render("Users/Admin/RolesCreate")) - .RequireAuthorization(policy => policy.RequireRole("Admin")); - } -} diff --git a/modules/Users/src/Users/Views/Admin/RolesEditEndpoint.cs b/modules/Users/src/Users/Views/Admin/RolesEditEndpoint.cs deleted file mode 100644 index 6cfc7bda..00000000 --- a/modules/Users/src/Users/Views/Admin/RolesEditEndpoint.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Routing; -using Microsoft.EntityFrameworkCore; -using SimpleModule.Core; -using SimpleModule.Core.Inertia; -using SimpleModule.Users.Entities; - -namespace SimpleModule.Users.Views.Admin; - -public class RolesEditEndpoint : IViewEndpoint -{ - public void Map(IEndpointRouteBuilder app) - { - app.MapGet( - "/admin/roles/{id}/edit", - async ( - string id, - RoleManager roleManager, - UserManager userManager - ) => - { - var role = await roleManager.FindByIdAsync(id); - if (role is null) - return Results.NotFound(); - - var usersInRole = role.Name is not null - ? await userManager.GetUsersInRoleAsync(role.Name) - : []; - - return Inertia.Render( - "Users/Admin/RolesEdit", - new - { - role = new - { - id = role.Id, - name = role.Name, - description = role.Description, - createdAt = role.CreatedAt.ToString("O"), - }, - users = usersInRole - .Select(u => new - { - id = u.Id, - displayName = u.DisplayName, - email = u.Email, - }) - .ToList(), - } - ); - } - ) - .RequireAuthorization(policy => policy.RequireRole("Admin")); - } -} diff --git a/modules/Users/src/Users/Views/Admin/UsersEditEndpoint.cs b/modules/Users/src/Users/Views/Admin/UsersEditEndpoint.cs deleted file mode 100644 index e9c8e385..00000000 --- a/modules/Users/src/Users/Views/Admin/UsersEditEndpoint.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Routing; -using Microsoft.EntityFrameworkCore; -using SimpleModule.Core; -using SimpleModule.Core.Inertia; -using SimpleModule.Users.Entities; - -namespace SimpleModule.Users.Views.Admin; - -public class UsersEditEndpoint : IViewEndpoint -{ - public void Map(IEndpointRouteBuilder app) - { - app.MapGet( - "/admin/users/{id}/edit", - async ( - string id, - UserManager userManager, - RoleManager roleManager - ) => - { - var user = await userManager.FindByIdAsync(id); - if (user is null) - return Results.NotFound(); - - var userRoles = await userManager.GetRolesAsync(user); - var allRoles = await roleManager.Roles.OrderBy(r => r.Name).ToListAsync(); - - return Inertia.Render( - "Users/Admin/UsersEdit", - new - { - user = new - { - id = user.Id, - displayName = user.DisplayName, - email = user.Email, - emailConfirmed = user.EmailConfirmed, - isLockedOut = user.LockoutEnd.HasValue - && user.LockoutEnd > DateTimeOffset.UtcNow, - createdAt = user.CreatedAt.ToString("O"), - lastLoginAt = user.LastLoginAt?.ToString("O"), - }, - userRoles = userRoles.ToList(), - allRoles = allRoles - .Select(r => new - { - id = r.Id, - name = r.Name, - description = r.Description, - }) - .ToList(), - } - ); - } - ) - .RequireAuthorization(policy => policy.RequireRole("Admin")); - } -} diff --git a/modules/Users/tests/Users.Tests/Integration/AdminRolesEndpointTests.cs b/modules/Users/tests/Users.Tests/Integration/AdminRolesEndpointTests.cs deleted file mode 100644 index eb7da340..00000000 --- a/modules/Users/tests/Users.Tests/Integration/AdminRolesEndpointTests.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System.Net; -using System.Security.Claims; -using FluentAssertions; -using SimpleModule.Tests.Shared.Fixtures; - -namespace SimpleModule.Users.Tests.Integration; - -public class AdminRolesEndpointTests : IClassFixture -{ - private readonly SimpleModuleWebApplicationFactory _factory; - - public AdminRolesEndpointTests(SimpleModuleWebApplicationFactory factory) - { - _factory = factory; - } - - private HttpClient CreateAdminClient() => - _factory.CreateAuthenticatedClient(new Claim(ClaimTypes.Role, "Admin")); - - [Fact] - public async Task ListRoles_Unauthenticated_ReturnsUnauthorized() - { - var client = _factory.CreateClient(); - var response = await client.GetAsync("/admin/roles"); - response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - } - - [Fact] - public async Task ListRoles_NonAdmin_ReturnsForbidden() - { - var client = _factory.CreateAuthenticatedClient(); - var response = await client.GetAsync("/admin/roles"); - response.StatusCode.Should().Be(HttpStatusCode.Forbidden); - } - - [Fact] - public async Task ListRoles_Admin_ReturnsOk() - { - var client = CreateAdminClient(); - var response = await client.GetAsync("/admin/roles"); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task ListRoles_WithInertiaHeader_ReturnsJson() - { - var client = CreateAdminClient(); - client.DefaultRequestHeaders.Add("X-Inertia", "true"); - client.DefaultRequestHeaders.Add("X-Inertia-Version", "1"); - var response = await client.GetAsync("/admin/roles"); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Content.Headers.ContentType?.MediaType.Should().Be("application/json"); - } - - [Fact] - public async Task CreateRolePage_Admin_ReturnsOk() - { - var client = CreateAdminClient(); - var response = await client.GetAsync("/admin/roles/create"); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task CreateRole_WithEmptyName_Redirects() - { - var client = CreateAdminClient(); - using var content = new FormUrlEncodedContent( - new Dictionary { ["name"] = "", ["description"] = "Test" } - ); - // Disable auto-redirect to check the redirect status - var handler = _factory.Server.CreateHandler(); - using var noRedirectClient = new HttpClient(handler) - { - BaseAddress = _factory.Server.BaseAddress, - }; - noRedirectClient.DefaultRequestHeaders.Add("Authorization", "Bearer test-token"); - var claims = $"{ClaimTypes.Role}=Admin;{ClaimTypes.NameIdentifier}=test-user-id"; - noRedirectClient.DefaultRequestHeaders.Add("X-Test-Claims", claims); - - var response = await noRedirectClient.PostAsync("/admin/roles", content); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); - response.Headers.Location?.ToString().Should().Be("/admin/roles/create"); - } - - [Fact] - public async Task EditRole_NonexistentId_ReturnsNotFound() - { - var client = CreateAdminClient(); - var response = await client.GetAsync("/admin/roles/nonexistent-id/edit"); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task UpdateRole_NonexistentId_ReturnsNotFound() - { - var client = CreateAdminClient(); - using var content = new FormUrlEncodedContent( - new Dictionary - { - ["name"] = "Updated", - ["description"] = "Updated description", - } - ); - var response = await client.PostAsync("/admin/roles/nonexistent-id", content); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task DeleteRole_NonexistentId_ReturnsNotFound() - { - var client = CreateAdminClient(); - var response = await client.DeleteAsync("/admin/roles/nonexistent-id"); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } -} diff --git a/modules/Users/tests/Users.Tests/Integration/AdminUsersEndpointTests.cs b/modules/Users/tests/Users.Tests/Integration/AdminUsersEndpointTests.cs deleted file mode 100644 index 79a666e5..00000000 --- a/modules/Users/tests/Users.Tests/Integration/AdminUsersEndpointTests.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Security.Claims; -using FluentAssertions; -using SimpleModule.Tests.Shared.Fixtures; - -namespace SimpleModule.Users.Tests.Integration; - -public class AdminUsersEndpointTests : IClassFixture -{ - private readonly SimpleModuleWebApplicationFactory _factory; - - public AdminUsersEndpointTests(SimpleModuleWebApplicationFactory factory) - { - _factory = factory; - } - - private HttpClient CreateAdminClient() => - _factory.CreateAuthenticatedClient(new Claim(ClaimTypes.Role, "Admin")); - - [Fact] - public async Task ListUsers_Unauthenticated_ReturnsUnauthorized() - { - var client = _factory.CreateClient(); - var response = await client.GetAsync("/admin/users"); - response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - } - - [Fact] - public async Task ListUsers_NonAdmin_ReturnsForbidden() - { - var client = _factory.CreateAuthenticatedClient(); - var response = await client.GetAsync("/admin/users"); - response.StatusCode.Should().Be(HttpStatusCode.Forbidden); - } - - [Fact] - public async Task ListUsers_Admin_ReturnsOk() - { - var client = CreateAdminClient(); - var response = await client.GetAsync("/admin/users"); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task ListUsers_WithInertiaHeader_ReturnsJson() - { - var client = CreateAdminClient(); - client.DefaultRequestHeaders.Add("X-Inertia", "true"); - client.DefaultRequestHeaders.Add("X-Inertia-Version", "1"); - var response = await client.GetAsync("/admin/users"); - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Content.Headers.ContentType?.MediaType.Should().Be("application/json"); - } - - [Fact] - public async Task ListUsers_WithSearch_ReturnsOk() - { - var client = CreateAdminClient(); - var response = await client.GetAsync("/admin/users?search=admin"); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task EditUser_NonexistentId_ReturnsNotFound() - { - var client = CreateAdminClient(); - var response = await client.GetAsync("/admin/users/nonexistent-id/edit"); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task UpdateUser_NonexistentId_ReturnsNotFound() - { - var client = CreateAdminClient(); - using var content = new FormUrlEncodedContent( - new Dictionary - { - ["displayName"] = "New Name", - ["email"] = "new@example.com", - } - ); - var response = await client.PostAsync("/admin/users/nonexistent-id", content); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task SetRoles_NonexistentId_ReturnsNotFound() - { - var client = CreateAdminClient(); - using var content = new FormUrlEncodedContent( - new Dictionary { ["roles"] = "Admin" } - ); - var response = await client.PostAsync("/admin/users/nonexistent-id/roles", content); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task LockUser_NonexistentId_ReturnsNotFound() - { - var client = CreateAdminClient(); - var response = await client.PostAsync("/admin/users/nonexistent-id/lock", null); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - [Fact] - public async Task UnlockUser_NonexistentId_ReturnsNotFound() - { - var client = CreateAdminClient(); - var response = await client.PostAsync("/admin/users/nonexistent-id/unlock", null); - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } -} diff --git a/modules/Users/tests/Users.Tests/Unit/UserIdTests.cs b/modules/Users/tests/Users.Tests/Unit/UserIdTests.cs new file mode 100644 index 00000000..734fdbcf --- /dev/null +++ b/modules/Users/tests/Users.Tests/Unit/UserIdTests.cs @@ -0,0 +1,49 @@ +using FluentAssertions; +using SimpleModule.Users.Contracts; +using Vogen; + +namespace Users.Tests.Unit; + +public sealed class UserIdTests +{ + [Fact] + public void From_WithValidString_CreatesUserId() + { + var id = UserId.From("user-123"); + + id.Value.Should().Be("user-123"); + } + + [Fact] + public void From_WithEmptyString_ThrowsException() + { + var act = () => UserId.From(string.Empty); + + act.Should().Throw(); + } + + [Fact] + public void From_WithWhitespace_ThrowsException() + { + var act = () => UserId.From(" "); + + act.Should().Throw(); + } + + [Fact] + public void Equality_SameValue_AreEqual() + { + var id1 = UserId.From("user-abc"); + var id2 = UserId.From("user-abc"); + + id1.Should().Be(id2); + } + + [Fact] + public void ToString_ReturnsValueString() + { + var id = UserId.From("user-456"); + + id.ToString(System.Globalization.CultureInfo.InvariantCulture).Should().Contain("user-456"); + } +} diff --git a/modules/Users/tests/Users.Tests/Unit/UserServiceTests.cs b/modules/Users/tests/Users.Tests/Unit/UserServiceTests.cs index 72fa5376..8ffb36a8 100644 --- a/modules/Users/tests/Users.Tests/Unit/UserServiceTests.cs +++ b/modules/Users/tests/Users.Tests/Unit/UserServiceTests.cs @@ -1,4 +1,4 @@ -using FluentAssertions; +using FluentAssertions; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; @@ -41,10 +41,10 @@ public async Task GetUserByIdAsync_WithExistingUser_ReturnsUserDto() }; _userManager.FindByIdAsync("1").Returns(appUser); - var user = await _sut.GetUserByIdAsync("1"); + var user = await _sut.GetUserByIdAsync(UserId.From("1")); user.Should().NotBeNull(); - user!.Id.Should().Be("1"); + user!.Id.Should().Be(UserId.From("1")); user.DisplayName.Should().Be("Test User"); user.Email.Should().Be("test@test.com"); } @@ -54,7 +54,7 @@ public async Task GetUserByIdAsync_WithNonExistingUser_ReturnsNull() { _userManager.FindByIdAsync("999").Returns((ApplicationUser?)null); - var user = await _sut.GetUserByIdAsync("999"); + var user = await _sut.GetUserByIdAsync(UserId.From("999")); user.Should().BeNull(); } @@ -70,22 +70,15 @@ public async Task GetCurrentUserAsync_DelegatesToGetUserByIdAsync() }; _userManager.FindByIdAsync("1").Returns(appUser); - var user = await _sut.GetCurrentUserAsync("1"); + var user = await _sut.GetCurrentUserAsync(UserId.From("1")); user.Should().NotBeNull(); - user!.Id.Should().Be("1"); + user!.Id.Should().Be(UserId.From("1")); } [Fact] public async Task CreateUserAsync_WithValidData_ReturnsUserDto() { - var appUser = new ApplicationUser - { - Id = "new-id", - Email = "new@test.com", - UserName = "new@test.com", - DisplayName = "New User", - }; _userManager .CreateAsync(Arg.Any(), Arg.Any()) .Returns(callInfo => @@ -150,7 +143,7 @@ public async Task UpdateUserAsync_WithValidData_UpdatesUser() var request = new UpdateUserRequest { Email = "new@test.com", DisplayName = "New Name" }; - var user = await _sut.UpdateUserAsync("1", request); + var user = await _sut.UpdateUserAsync(UserId.From("1"), request); user.Should().NotBeNull(); user.Email.Should().Be("new@test.com"); @@ -164,7 +157,7 @@ public async Task UpdateUserAsync_WithNonExistentUser_ThrowsNotFoundException() var request = new UpdateUserRequest { Email = "test@test.com", DisplayName = "Test" }; - var act = () => _sut.UpdateUserAsync("999", request); + var act = () => _sut.UpdateUserAsync(UserId.From("999"), request); await act.Should().ThrowAsync(); } @@ -181,7 +174,7 @@ public async Task DeleteUserAsync_WithExistingUser_DeletesUser() _userManager.FindByIdAsync("1").Returns(appUser); _userManager.DeleteAsync(Arg.Any()).Returns(IdentityResult.Success); - await _sut.DeleteUserAsync("1"); + await _sut.DeleteUserAsync(UserId.From("1")); await _userManager.Received(1).DeleteAsync(appUser); } @@ -191,7 +184,7 @@ public async Task DeleteUserAsync_WithNonExistentUser_ThrowsNotFoundException() { _userManager.FindByIdAsync("999").Returns((ApplicationUser?)null); - var act = () => _sut.DeleteUserAsync("999"); + var act = () => _sut.DeleteUserAsync(UserId.From("999")); await act.Should().ThrowAsync(); } diff --git a/opencode.jsonc b/opencode.jsonc new file mode 100644 index 00000000..2f81d714 --- /dev/null +++ b/opencode.jsonc @@ -0,0 +1,23 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "aspire": { + "type": "local", + "command": [ + "aspire", + "mcp", + "start" + ], + "enabled": true + }, + "playwright": { + "type": "local", + "command": [ + "npx", + "-y", + "@playwright/mcp@latest" + ], + "enabled": true + } + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 99c0f901..ba944ea2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,14 @@ "vite": "^6.2.0" } }, + "modules/Admin/src/Admin": { + "name": "@simplemodule/admin", + "version": "0.0.0", + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "modules/Dashboard/src/Dashboard": { "name": "@simplemodule/dashboard", "version": "0.0.0", @@ -36,6 +44,14 @@ "react-dom": "^19.0.0" } }, + "modules/OpenIddict/src/OpenIddict": { + "name": "@simplemodule/openiddict", + "version": "0.0.0", + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "modules/Orders/src/Orders": { "name": "@simplemodule/orders", "version": "0.0.0", @@ -2687,6 +2703,10 @@ "win32" ] }, + "node_modules/@simplemodule/admin": { + "resolved": "modules/Admin/src/Admin", + "link": true + }, "node_modules/@simplemodule/app": { "resolved": "template/SimpleModule.Host/ClientApp", "link": true @@ -2703,6 +2723,10 @@ "resolved": "tests/e2e", "link": true }, + "node_modules/@simplemodule/openiddict": { + "resolved": "modules/OpenIddict/src/OpenIddict", + "link": true + }, "node_modules/@simplemodule/orders": { "resolved": "modules/Orders/src/Orders", "link": true @@ -4708,6 +4732,7 @@ "version": "0.0.0", "peerDependencies": { "@inertiajs/react": "^2.0.0", + "@simplemodule/ui": "*", "esbuild": "*", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/packages/SimpleModule.Client/package.json b/packages/SimpleModule.Client/package.json index 49a6c885..3aafedb2 100644 --- a/packages/SimpleModule.Client/package.json +++ b/packages/SimpleModule.Client/package.json @@ -11,6 +11,7 @@ }, "peerDependencies": { "@inertiajs/react": "^2.0.0", + "@simplemodule/ui": "*", "esbuild": "*", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/packages/SimpleModule.Theme.Default/theme.css b/packages/SimpleModule.Theme.Default/theme.css index ee064ace..8c000855 100644 --- a/packages/SimpleModule.Theme.Default/theme.css +++ b/packages/SimpleModule.Theme.Default/theme.css @@ -4,16 +4,16 @@ ============================================================ */ @theme { - /* --- Primary Gradient Endpoints --- */ - --color-primary: #0d9488; - --color-primary-hover: #0f766e; - --color-primary-light: #2dd4bf; - --color-primary-subtle: rgba(13, 148, 136, 0.08); - --color-primary-ring: rgba(13, 148, 136, 0.25); + /* --- Primary: Forest Green --- */ + --color-primary: #16a34a; + --color-primary-hover: #15803d; + --color-primary-light: #4ade80; + --color-primary-subtle: rgba(22, 163, 74, 0.08); + --color-primary-ring: rgba(22, 163, 74, 0.25); - /* --- Accent: Emerald --- */ - --color-accent: #059669; - --color-accent-hover: #047857; + /* --- Accent: Deep Evergreen --- */ + --color-accent: #166534; + --color-accent-hover: #14532d; /* --- Semantic: Success --- */ --color-success: #16a34a; @@ -60,11 +60,11 @@ /* --- Misc --- */ --color-dark: #0f172a; --color-muted: #94a3b8; - --color-ring: rgba(13, 148, 136, 0.4); + --color-ring: rgba(22, 163, 74, 0.4); /* --- Shadows (themeable) --- */ - --shadow-primary: 0 4px 14px rgba(13, 148, 136, 0.35); - --shadow-primary-hover: 0 6px 20px rgba(13, 148, 136, 0.5); + --shadow-primary: 0 4px 14px rgba(22, 163, 74, 0.35); + --shadow-primary-hover: 0 6px 20px rgba(22, 163, 74, 0.5); --shadow-danger: 0 4px 14px rgba(225, 29, 72, 0.25); --shadow-danger-hover: 0 6px 20px rgba(225, 29, 72, 0.4); } @@ -73,8 +73,8 @@ Dark Mode Overrides ============================================================ */ .dark { - --color-primary-subtle: rgba(13, 148, 136, 0.15); - --color-primary-ring: rgba(13, 148, 136, 0.35); + --color-primary-subtle: rgba(22, 163, 74, 0.15); + --color-primary-ring: rgba(22, 163, 74, 0.35); --color-success-bg: rgba(74, 222, 128, 0.12); --color-success-text: #86efac; @@ -171,7 +171,7 @@ } ::selection { - background: rgba(13, 148, 136, 0.2); + background: rgba(22, 163, 74, 0.2); } } @@ -211,10 +211,10 @@ .btn-primary { @apply text-white; background: linear-gradient(135deg, var(--color-primary), var(--color-accent)); - box-shadow: 0 4px 14px rgba(13, 148, 136, 0.35); + box-shadow: 0 4px 14px rgba(22, 163, 74, 0.35); } .btn-primary:hover { - box-shadow: 0 6px 20px rgba(13, 148, 136, 0.5); + box-shadow: 0 6px 20px rgba(22, 163, 74, 0.5); transform: translateY(-1px); } @@ -466,6 +466,17 @@ transform: translateY(0) scale(1); pointer-events: auto; } + /* Inside sidebar: open upward */ + .app-sidebar .user-dropdown { + top: auto; + bottom: calc(100% + 6px); + left: 0; + right: auto; + transform: translateY(4px) scale(0.97); + } + .app-sidebar .user-dropdown.open { + transform: translateY(0) scale(1); + } .dark .user-dropdown { box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.06), @@ -512,6 +523,46 @@ .validation-message { @apply text-danger text-xs mt-1.5 font-medium; } + + /* --- App Layout (Sidebar) --- */ + .app-layout { + display: flex; + min-height: 100vh; + } + .app-sidebar { + width: 16rem; + @apply bg-surface border-r border-border shrink-0; + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 40; + transition: width 0.2s ease; + } + .app-sidebar.app-sidebar-collapsed { + width: 4rem; + } + .app-sidebar.app-sidebar-collapsed .sidebar-label { + display: none; + } + .app-sidebar.app-sidebar-collapsed .sidebar-icon { + margin: 0 auto; + } + .app-content { + flex: 1; + margin-left: 16rem; + transition: margin-left 0.2s ease; + } + .app-sidebar-collapsed ~ .app-content { + margin-left: 4rem; + } + .app-sidebar-toggle { + @apply fixed bottom-4 z-50 w-7 h-7 rounded-full bg-surface border border-border flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer transition-all duration-200; + left: 15rem; + } + .app-sidebar-collapsed ~ .app-sidebar-toggle { + left: 3rem; + } } /* end @layer components */ /* ============================================================ diff --git a/template/SimpleModule.Host/ClientApp/app.tsx b/template/SimpleModule.Host/ClientApp/app.tsx index 010b0756..c73ae5b6 100644 --- a/template/SimpleModule.Host/ClientApp/app.tsx +++ b/template/SimpleModule.Host/ClientApp/app.tsx @@ -1,7 +1,39 @@ -import { createInertiaApp } from '@inertiajs/react'; +import { createInertiaApp, router } from '@inertiajs/react'; import { resolvePage } from '@simplemodule/client/resolve-page'; import { createRoot } from 'react-dom/client'; +// Handle non-Inertia error responses (404, 500, etc.) by showing a toast +// instead of the default "must receive a valid Inertia response" error. +router.on('invalid', (event) => { + event.preventDefault(); + + const response = event.detail.response; + const body = response.data as { detail?: string; title?: string } | undefined; + const message = body?.detail ?? body?.title ?? `Server error (${response.status})`; + showErrorToast(message); +}); + +function showErrorToast(message: string) { + const container = document.createElement('div'); + container.role = 'alert'; + container.className = + 'fixed bottom-4 right-4 z-[100] max-w-md rounded-xl border border-danger/20 bg-danger-bg p-4 text-danger-text shadow-lg animate-in slide-in-from-bottom-full'; + container.innerHTML = ` +
+
+

Error

+

${message.replace(/[<>"'&]/g, (c) => `&#${c.charCodeAt(0)};`)}

+
+ +
+ `; + container.querySelector('button')?.addEventListener('click', () => container.remove()); + document.body.appendChild(container); + setTimeout(() => container.remove(), 8000); +} + createInertiaApp({ resolve: resolvePage, setup({ el, App, props }) { diff --git a/template/SimpleModule.Host/Components/InertiaShell.razor b/template/SimpleModule.Host/Components/InertiaShell.razor index f527b613..c5a54a9b 100644 --- a/template/SimpleModule.Host/Components/InertiaShell.razor +++ b/template/SimpleModule.Host/Components/InertiaShell.razor @@ -3,7 +3,9 @@ + Layout="@(HttpContext?.User?.Identity?.IsAuthenticated == true + ? typeof(SimpleModule.Blazor.Components.Layout.AppLayout) + : typeof(SimpleModule.Blazor.Components.Layout.PublicLayout))"> diff --git a/template/SimpleModule.Host/HostDbContext.Conventions.cs b/template/SimpleModule.Host/HostDbContext.Conventions.cs new file mode 100644 index 00000000..dcc4b4d3 --- /dev/null +++ b/template/SimpleModule.Host/HostDbContext.Conventions.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using SimpleModule.Orders.Contracts; +using SimpleModule.Permissions.Contracts; +using SimpleModule.Products.Contracts; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Host; + +public partial class HostDbContext +{ + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + configurationBuilder + .Properties() + .HaveConversion(); + configurationBuilder + .Properties() + .HaveConversion(); + configurationBuilder + .Properties() + .HaveConversion(); + configurationBuilder + .Properties() + .HaveConversion(); + } +} diff --git a/template/SimpleModule.Host/HostDbContextFactory.cs b/template/SimpleModule.Host/HostDbContextFactory.cs new file mode 100644 index 00000000..66c74c9c --- /dev/null +++ b/template/SimpleModule.Host/HostDbContextFactory.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using SimpleModule.Database; + +namespace SimpleModule.Host; + +public class HostDbContextFactory : IDesignTimeDbContextFactory +{ + public HostDbContext CreateDbContext(string[] args) + { + var config = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false) + .AddJsonFile("appsettings.Development.json", optional: true) + .AddEnvironmentVariables() + .Build(); + + var dbOptions = + config.GetSection("Database").Get() ?? new DatabaseOptions(); + + var optionsBuilder = new DbContextOptionsBuilder(); + var provider = DatabaseProviderDetector.Detect( + dbOptions.DefaultConnection, + dbOptions.Provider + ); + + switch (provider) + { + case DatabaseProvider.PostgreSql: + optionsBuilder.UseNpgsql(dbOptions.DefaultConnection); + break; + case DatabaseProvider.SqlServer: + optionsBuilder.UseSqlServer(dbOptions.DefaultConnection); + break; + default: + optionsBuilder.UseSqlite(dbOptions.DefaultConnection); + break; + } + + optionsBuilder.UseOpenIddict(); + + return new HostDbContext(optionsBuilder.Options, Options.Create(dbOptions)); + } +} diff --git a/template/SimpleModule.Host/Migrations/20260319120412_InitialCreate.Designer.cs b/template/SimpleModule.Host/Migrations/20260319120412_InitialCreate.Designer.cs new file mode 100644 index 00000000..02df6ce9 --- /dev/null +++ b/template/SimpleModule.Host/Migrations/20260319120412_InitialCreate.Designer.cs @@ -0,0 +1,816 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SimpleModule.Host; + +#nullable disable + +namespace SimpleModule.Host.Migrations +{ + [DbContext(typeof(HostDbContext))] + [Migration("20260319120412_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("Users_AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Users_AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("Users_AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("Users_AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("Users_AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ClientSecret") + .HasColumnType("TEXT"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("DisplayNames") + .HasColumnType("TEXT"); + + b.Property("JsonWebKeySet") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("RedirectUris") + .HasColumnType("TEXT"); + + b.Property("Requirements") + .HasColumnType("TEXT"); + + b.Property("Settings") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddict_OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationId") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("Scopes") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("TEXT"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddict_OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Descriptions") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("DisplayNames") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("Resources") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddict_OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationId") + .HasColumnType("TEXT"); + + b.Property("AuthorizationId") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Payload") + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("RedemptionDate") + .HasColumnType("TEXT"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("TEXT"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddict_OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Admin.Entities.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Details") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("PerformedByUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.HasIndex("UserId"); + + b.ToTable("Admin_AuditLogEntries", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Orders_Orders", (string)null); + + b.HasData( + new + { + Id = 1, + CreatedAt = new DateTime(2026, 1, 2, 14, 0, 48, 324, DateTimeKind.Utc).AddTicks(4205), + Total = 4205.85m, + UserId = "2" + }, + new + { + Id = 2, + CreatedAt = new DateTime(2026, 1, 9, 10, 43, 3, 902, DateTimeKind.Utc).AddTicks(7351), + Total = 2752.49m, + UserId = "8" + }, + new + { + Id = 3, + CreatedAt = new DateTime(2026, 1, 27, 18, 48, 8, 543, DateTimeKind.Utc).AddTicks(1493), + Total = 192.31m, + UserId = "6" + }, + new + { + Id = 4, + CreatedAt = new DateTime(2026, 1, 19, 0, 29, 15, 501, DateTimeKind.Utc).AddTicks(3898), + Total = 3146.31m, + UserId = "2" + }, + new + { + Id = 5, + CreatedAt = new DateTime(2026, 1, 5, 1, 0, 8, 927, DateTimeKind.Utc).AddTicks(1333), + Total = 4580.92m, + UserId = "10" + }); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.OrderItem", b => + { + b.Property("OrderId") + .HasColumnType("INTEGER"); + + b.Property("ProductId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("OrderId", "ProductId"); + + b.ToTable("Orders_OrderItems", (string)null); + + b.HasData( + new + { + OrderId = 1, + ProductId = 4, + Quantity = 5 + }, + new + { + OrderId = 1, + ProductId = 5, + Quantity = 1 + }, + new + { + OrderId = 1, + ProductId = 6, + Quantity = 1 + }, + new + { + OrderId = 2, + ProductId = 1, + Quantity = 3 + }, + new + { + OrderId = 3, + ProductId = 4, + Quantity = 2 + }, + new + { + OrderId = 3, + ProductId = 5, + Quantity = 4 + }, + new + { + OrderId = 4, + ProductId = 3, + Quantity = 1 + }, + new + { + OrderId = 5, + ProductId = 9, + Quantity = 3 + }, + new + { + OrderId = 5, + ProductId = 6, + Quantity = 5 + }); + }); + + modelBuilder.Entity("SimpleModule.Permissions.Entities.RolePermission", b => + { + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasColumnType("TEXT"); + + b.HasKey("RoleId", "Permission"); + + b.ToTable("Permissions_RolePermissions", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Permissions.Entities.UserPermission", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "Permission"); + + b.ToTable("Permissions_UserPermissions", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Products.Contracts.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.ToTable("Products_Products", (string)null); + + b.HasData( + new + { + Id = 1, + Name = "Fantastic Rubber Shoes", + Price = 99168m + }, + new + { + Id = 2, + Name = "Fantastic Rubber Bacon", + Price = 44622m + }, + new + { + Id = 3, + Name = "Fantastic Concrete Bike", + Price = 66012m + }, + new + { + Id = 4, + Name = "Handcrafted Concrete Keyboard", + Price = 63367m + }, + new + { + Id = 5, + Name = "Intelligent Frozen Mouse", + Price = 67430m + }, + new + { + Id = 6, + Name = "Sleek Soft Hat", + Price = 85163m + }, + new + { + Id = 7, + Name = "Practical Fresh Bike", + Price = 41748m + }, + new + { + Id = 8, + Name = "Handmade Steel Ball", + Price = 97556m + }, + new + { + Id = 9, + Name = "Ergonomic Fresh Pants", + Price = 92809m + }, + new + { + Id = 10, + Name = "Licensed Steel Sausages", + Price = 59260m + }); + }); + + modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Users_AspNetRoles", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeactivatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users_AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.OrderItem", b => + { + b.HasOne("SimpleModule.Orders.Contracts.Order", null) + .WithMany("Items") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.Order", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/template/SimpleModule.Host/Migrations/20260319120412_InitialCreate.cs b/template/SimpleModule.Host/Migrations/20260319120412_InitialCreate.cs new file mode 100644 index 00000000..f506469f --- /dev/null +++ b/template/SimpleModule.Host/Migrations/20260319120412_InitialCreate.cs @@ -0,0 +1,646 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace SimpleModule.Host.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Admin_AuditLogEntries", + columns: table => new + { + Id = table + .Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserId = table.Column(type: "TEXT", nullable: false), + PerformedByUserId = table.Column(type: "TEXT", nullable: false), + Action = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Details = table.Column(type: "TEXT", maxLength: 4000, nullable: true), + Timestamp = table.Column(type: "TEXT", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_Admin_AuditLogEntries", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "OpenIddict_OpenIddictApplications", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ApplicationType = table.Column( + type: "TEXT", + maxLength: 50, + nullable: true + ), + ClientId = table.Column(type: "TEXT", maxLength: 100, nullable: true), + ClientSecret = table.Column(type: "TEXT", nullable: true), + ClientType = table.Column(type: "TEXT", maxLength: 50, nullable: true), + ConcurrencyToken = table.Column( + type: "TEXT", + maxLength: 50, + nullable: true + ), + ConsentType = table.Column(type: "TEXT", maxLength: 50, nullable: true), + DisplayName = table.Column(type: "TEXT", nullable: true), + DisplayNames = table.Column(type: "TEXT", nullable: true), + JsonWebKeySet = table.Column(type: "TEXT", nullable: true), + Permissions = table.Column(type: "TEXT", nullable: true), + PostLogoutRedirectUris = table.Column(type: "TEXT", nullable: true), + Properties = table.Column(type: "TEXT", nullable: true), + RedirectUris = table.Column(type: "TEXT", nullable: true), + Requirements = table.Column(type: "TEXT", nullable: true), + Settings = table.Column(type: "TEXT", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddict_OpenIddictApplications", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "OpenIddict_OpenIddictScopes", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ConcurrencyToken = table.Column( + type: "TEXT", + maxLength: 50, + nullable: true + ), + Description = table.Column(type: "TEXT", nullable: true), + Descriptions = table.Column(type: "TEXT", nullable: true), + DisplayName = table.Column(type: "TEXT", nullable: true), + DisplayNames = table.Column(type: "TEXT", nullable: true), + Name = table.Column(type: "TEXT", maxLength: 200, nullable: true), + Properties = table.Column(type: "TEXT", nullable: true), + Resources = table.Column(type: "TEXT", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddict_OpenIddictScopes", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "Orders_Orders", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + Total = table.Column(type: "decimal(18,2)", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_Orders_Orders", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "Permissions_RolePermissions", + columns: table => new + { + RoleId = table.Column(type: "TEXT", nullable: false), + Permission = table.Column(type: "TEXT", nullable: false), + }, + constraints: table => + { + table.PrimaryKey( + "PK_Permissions_RolePermissions", + x => new { x.RoleId, x.Permission } + ); + } + ); + + migrationBuilder.CreateTable( + name: "Permissions_UserPermissions", + columns: table => new + { + UserId = table.Column(type: "TEXT", nullable: false), + Permission = table.Column(type: "TEXT", nullable: false), + }, + constraints: table => + { + table.PrimaryKey( + "PK_Permissions_UserPermissions", + x => new { x.UserId, x.Permission } + ); + } + ); + + migrationBuilder.CreateTable( + name: "Products_Products", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Price = table.Column(type: "decimal(18,2)", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_Products_Products", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "Users_AspNetRoles", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Description = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedName = table.Column( + type: "TEXT", + maxLength: 256, + nullable: true + ), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_Users_AspNetRoles", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "Users_AspNetUsers", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + DisplayName = table.Column(type: "TEXT", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + LastLoginAt = table.Column(type: "TEXT", nullable: true), + DeactivatedAt = table.Column(type: "TEXT", nullable: true), + UserName = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedUserName = table.Column( + type: "TEXT", + maxLength: 256, + nullable: true + ), + Email = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedEmail = table.Column( + type: "TEXT", + maxLength: 256, + nullable: true + ), + EmailConfirmed = table.Column(type: "INTEGER", 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: "INTEGER", nullable: false), + TwoFactorEnabled = table.Column(type: "INTEGER", nullable: false), + LockoutEnd = table.Column(type: "TEXT", nullable: true), + LockoutEnabled = table.Column(type: "INTEGER", nullable: false), + AccessFailedCount = table.Column(type: "INTEGER", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_Users_AspNetUsers", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "OpenIddict_OpenIddictAuthorizations", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ApplicationId = table.Column(type: "TEXT", nullable: true), + ConcurrencyToken = table.Column( + type: "TEXT", + maxLength: 50, + nullable: true + ), + CreationDate = table.Column(type: "TEXT", nullable: true), + Properties = table.Column(type: "TEXT", nullable: true), + Scopes = table.Column(type: "TEXT", nullable: true), + Status = table.Column(type: "TEXT", maxLength: 50, nullable: true), + Subject = table.Column(type: "TEXT", maxLength: 400, nullable: true), + Type = table.Column(type: "TEXT", maxLength: 50, nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddict_OpenIddictAuthorizations", x => x.Id); + table.ForeignKey( + name: "FK_OpenIddict_OpenIddictAuthorizations_OpenIddict_OpenIddictApplications_ApplicationId", + column: x => x.ApplicationId, + principalTable: "OpenIddict_OpenIddictApplications", + principalColumn: "Id" + ); + } + ); + + migrationBuilder.CreateTable( + name: "Orders_OrderItems", + columns: table => new + { + ProductId = table.Column(type: "INTEGER", nullable: false), + OrderId = table.Column(type: "INTEGER", nullable: false), + Quantity = table.Column(type: "INTEGER", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_Orders_OrderItems", x => new { x.OrderId, x.ProductId }); + table.ForeignKey( + name: "FK_Orders_OrderItems_Orders_Orders_OrderId", + column: x => x.OrderId, + principalTable: "Orders_Orders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "Users_AspNetRoleClaims", + columns: table => new + { + Id = table + .Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + 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_Users_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_Users_AspNetRoleClaims_Users_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "Users_AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "Users_AspNetUserClaims", + columns: table => new + { + Id = table + .Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserId = 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_Users_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_Users_AspNetUserClaims_Users_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "Users_AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "Users_AspNetUserLogins", + 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), + }, + constraints: table => + { + table.PrimaryKey( + "PK_Users_AspNetUserLogins", + x => new { x.LoginProvider, x.ProviderKey } + ); + table.ForeignKey( + name: "FK_Users_AspNetUserLogins_Users_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "Users_AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "Users_AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "TEXT", nullable: false), + RoleId = table.Column(type: "TEXT", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_Users_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_Users_AspNetUserRoles_Users_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "Users_AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "FK_Users_AspNetUserRoles_Users_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "Users_AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "Users_AspNetUserTokens", + 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), + }, + constraints: table => + { + table.PrimaryKey( + "PK_Users_AspNetUserTokens", + x => new + { + x.UserId, + x.LoginProvider, + x.Name, + } + ); + table.ForeignKey( + name: "FK_Users_AspNetUserTokens_Users_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "Users_AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "OpenIddict_OpenIddictTokens", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ApplicationId = table.Column(type: "TEXT", nullable: true), + AuthorizationId = table.Column(type: "TEXT", nullable: true), + ConcurrencyToken = table.Column( + type: "TEXT", + maxLength: 50, + nullable: true + ), + CreationDate = table.Column(type: "TEXT", nullable: true), + ExpirationDate = table.Column(type: "TEXT", nullable: true), + Payload = table.Column(type: "TEXT", nullable: true), + Properties = table.Column(type: "TEXT", nullable: true), + RedemptionDate = table.Column(type: "TEXT", nullable: true), + ReferenceId = table.Column( + type: "TEXT", + maxLength: 100, + nullable: true + ), + Status = table.Column(type: "TEXT", maxLength: 50, nullable: true), + Subject = table.Column(type: "TEXT", maxLength: 400, nullable: true), + Type = table.Column(type: "TEXT", maxLength: 50, nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddict_OpenIddictTokens", x => x.Id); + table.ForeignKey( + name: "FK_OpenIddict_OpenIddictTokens_OpenIddict_OpenIddictApplications_ApplicationId", + column: x => x.ApplicationId, + principalTable: "OpenIddict_OpenIddictApplications", + principalColumn: "Id" + ); + table.ForeignKey( + name: "FK_OpenIddict_OpenIddictTokens_OpenIddict_OpenIddictAuthorizations_AuthorizationId", + column: x => x.AuthorizationId, + principalTable: "OpenIddict_OpenIddictAuthorizations", + principalColumn: "Id" + ); + } + ); + + migrationBuilder.InsertData( + table: "Orders_Orders", + columns: new[] { "Id", "CreatedAt", "Total", "UserId" }, + values: new object[,] + { + { + 1, + new DateTime(2026, 1, 2, 14, 0, 48, 324, DateTimeKind.Utc).AddTicks(4205), + 4205.85m, + "2", + }, + { + 2, + new DateTime(2026, 1, 9, 10, 43, 3, 902, DateTimeKind.Utc).AddTicks(7351), + 2752.49m, + "8", + }, + { + 3, + new DateTime(2026, 1, 27, 18, 48, 8, 543, DateTimeKind.Utc).AddTicks(1493), + 192.31m, + "6", + }, + { + 4, + new DateTime(2026, 1, 19, 0, 29, 15, 501, DateTimeKind.Utc).AddTicks(3898), + 3146.31m, + "2", + }, + { + 5, + new DateTime(2026, 1, 5, 1, 0, 8, 927, DateTimeKind.Utc).AddTicks(1333), + 4580.92m, + "10", + }, + } + ); + + migrationBuilder.InsertData( + table: "Products_Products", + columns: new[] { "Id", "Name", "Price" }, + values: new object[,] + { + { 1, "Fantastic Rubber Shoes", 99168m }, + { 2, "Fantastic Rubber Bacon", 44622m }, + { 3, "Fantastic Concrete Bike", 66012m }, + { 4, "Handcrafted Concrete Keyboard", 63367m }, + { 5, "Intelligent Frozen Mouse", 67430m }, + { 6, "Sleek Soft Hat", 85163m }, + { 7, "Practical Fresh Bike", 41748m }, + { 8, "Handmade Steel Ball", 97556m }, + { 9, "Ergonomic Fresh Pants", 92809m }, + { 10, "Licensed Steel Sausages", 59260m }, + } + ); + + migrationBuilder.InsertData( + table: "Orders_OrderItems", + columns: new[] { "OrderId", "ProductId", "Quantity" }, + values: new object[,] + { + { 1, 4, 5 }, + { 1, 5, 1 }, + { 1, 6, 1 }, + { 2, 1, 3 }, + { 3, 4, 2 }, + { 3, 5, 4 }, + { 4, 3, 1 }, + { 5, 6, 5 }, + { 5, 9, 3 }, + } + ); + + migrationBuilder.CreateIndex( + name: "IX_Admin_AuditLogEntries_Timestamp", + table: "Admin_AuditLogEntries", + column: "Timestamp" + ); + + migrationBuilder.CreateIndex( + name: "IX_Admin_AuditLogEntries_UserId", + table: "Admin_AuditLogEntries", + column: "UserId" + ); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddict_OpenIddictApplications_ClientId", + table: "OpenIddict_OpenIddictApplications", + column: "ClientId", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddict_OpenIddictAuthorizations_ApplicationId_Status_Subject_Type", + table: "OpenIddict_OpenIddictAuthorizations", + columns: new[] { "ApplicationId", "Status", "Subject", "Type" } + ); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddict_OpenIddictScopes_Name", + table: "OpenIddict_OpenIddictScopes", + column: "Name", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddict_OpenIddictTokens_ApplicationId_Status_Subject_Type", + table: "OpenIddict_OpenIddictTokens", + columns: new[] { "ApplicationId", "Status", "Subject", "Type" } + ); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddict_OpenIddictTokens_AuthorizationId", + table: "OpenIddict_OpenIddictTokens", + column: "AuthorizationId" + ); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddict_OpenIddictTokens_ReferenceId", + table: "OpenIddict_OpenIddictTokens", + column: "ReferenceId", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_Users_AspNetRoleClaims_RoleId", + table: "Users_AspNetRoleClaims", + column: "RoleId" + ); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "Users_AspNetRoles", + column: "NormalizedName", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_Users_AspNetUserClaims_UserId", + table: "Users_AspNetUserClaims", + column: "UserId" + ); + + migrationBuilder.CreateIndex( + name: "IX_Users_AspNetUserLogins_UserId", + table: "Users_AspNetUserLogins", + column: "UserId" + ); + + migrationBuilder.CreateIndex( + name: "IX_Users_AspNetUserRoles_RoleId", + table: "Users_AspNetUserRoles", + column: "RoleId" + ); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "Users_AspNetUsers", + column: "NormalizedEmail" + ); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "Users_AspNetUsers", + column: "NormalizedUserName", + unique: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "Admin_AuditLogEntries"); + + migrationBuilder.DropTable(name: "OpenIddict_OpenIddictScopes"); + + migrationBuilder.DropTable(name: "OpenIddict_OpenIddictTokens"); + + migrationBuilder.DropTable(name: "Orders_OrderItems"); + + migrationBuilder.DropTable(name: "Permissions_RolePermissions"); + + migrationBuilder.DropTable(name: "Permissions_UserPermissions"); + + migrationBuilder.DropTable(name: "Products_Products"); + + migrationBuilder.DropTable(name: "Users_AspNetRoleClaims"); + + migrationBuilder.DropTable(name: "Users_AspNetUserClaims"); + + migrationBuilder.DropTable(name: "Users_AspNetUserLogins"); + + migrationBuilder.DropTable(name: "Users_AspNetUserRoles"); + + migrationBuilder.DropTable(name: "Users_AspNetUserTokens"); + + migrationBuilder.DropTable(name: "OpenIddict_OpenIddictAuthorizations"); + + migrationBuilder.DropTable(name: "Orders_Orders"); + + migrationBuilder.DropTable(name: "Users_AspNetRoles"); + + migrationBuilder.DropTable(name: "Users_AspNetUsers"); + + migrationBuilder.DropTable(name: "OpenIddict_OpenIddictApplications"); + } + } +} diff --git a/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs b/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs new file mode 100644 index 00000000..ae66d1e2 --- /dev/null +++ b/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs @@ -0,0 +1,813 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SimpleModule.Host; + +#nullable disable + +namespace SimpleModule.Host.Migrations +{ + [DbContext(typeof(HostDbContext))] + partial class HostDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("Users_AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Users_AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("Users_AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("Users_AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("Users_AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ClientSecret") + .HasColumnType("TEXT"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("DisplayNames") + .HasColumnType("TEXT"); + + b.Property("JsonWebKeySet") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("RedirectUris") + .HasColumnType("TEXT"); + + b.Property("Requirements") + .HasColumnType("TEXT"); + + b.Property("Settings") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddict_OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationId") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("Scopes") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("TEXT"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddict_OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Descriptions") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("DisplayNames") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("Resources") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddict_OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationId") + .HasColumnType("TEXT"); + + b.Property("AuthorizationId") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Payload") + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("RedemptionDate") + .HasColumnType("TEXT"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("TEXT"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddict_OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Admin.Entities.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Details") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("PerformedByUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.HasIndex("UserId"); + + b.ToTable("Admin_AuditLogEntries", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Orders_Orders", (string)null); + + b.HasData( + new + { + Id = 1, + CreatedAt = new DateTime(2026, 1, 2, 14, 0, 48, 324, DateTimeKind.Utc).AddTicks(4205), + Total = 4205.85m, + UserId = "2" + }, + new + { + Id = 2, + CreatedAt = new DateTime(2026, 1, 9, 10, 43, 3, 902, DateTimeKind.Utc).AddTicks(7351), + Total = 2752.49m, + UserId = "8" + }, + new + { + Id = 3, + CreatedAt = new DateTime(2026, 1, 27, 18, 48, 8, 543, DateTimeKind.Utc).AddTicks(1493), + Total = 192.31m, + UserId = "6" + }, + new + { + Id = 4, + CreatedAt = new DateTime(2026, 1, 19, 0, 29, 15, 501, DateTimeKind.Utc).AddTicks(3898), + Total = 3146.31m, + UserId = "2" + }, + new + { + Id = 5, + CreatedAt = new DateTime(2026, 1, 5, 1, 0, 8, 927, DateTimeKind.Utc).AddTicks(1333), + Total = 4580.92m, + UserId = "10" + }); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.OrderItem", b => + { + b.Property("OrderId") + .HasColumnType("INTEGER"); + + b.Property("ProductId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("OrderId", "ProductId"); + + b.ToTable("Orders_OrderItems", (string)null); + + b.HasData( + new + { + OrderId = 1, + ProductId = 4, + Quantity = 5 + }, + new + { + OrderId = 1, + ProductId = 5, + Quantity = 1 + }, + new + { + OrderId = 1, + ProductId = 6, + Quantity = 1 + }, + new + { + OrderId = 2, + ProductId = 1, + Quantity = 3 + }, + new + { + OrderId = 3, + ProductId = 4, + Quantity = 2 + }, + new + { + OrderId = 3, + ProductId = 5, + Quantity = 4 + }, + new + { + OrderId = 4, + ProductId = 3, + Quantity = 1 + }, + new + { + OrderId = 5, + ProductId = 9, + Quantity = 3 + }, + new + { + OrderId = 5, + ProductId = 6, + Quantity = 5 + }); + }); + + modelBuilder.Entity("SimpleModule.Permissions.Entities.RolePermission", b => + { + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasColumnType("TEXT"); + + b.HasKey("RoleId", "Permission"); + + b.ToTable("Permissions_RolePermissions", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Permissions.Entities.UserPermission", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "Permission"); + + b.ToTable("Permissions_UserPermissions", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Products.Contracts.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.ToTable("Products_Products", (string)null); + + b.HasData( + new + { + Id = 1, + Name = "Fantastic Rubber Shoes", + Price = 99168m + }, + new + { + Id = 2, + Name = "Fantastic Rubber Bacon", + Price = 44622m + }, + new + { + Id = 3, + Name = "Fantastic Concrete Bike", + Price = 66012m + }, + new + { + Id = 4, + Name = "Handcrafted Concrete Keyboard", + Price = 63367m + }, + new + { + Id = 5, + Name = "Intelligent Frozen Mouse", + Price = 67430m + }, + new + { + Id = 6, + Name = "Sleek Soft Hat", + Price = 85163m + }, + new + { + Id = 7, + Name = "Practical Fresh Bike", + Price = 41748m + }, + new + { + Id = 8, + Name = "Handmade Steel Ball", + Price = 97556m + }, + new + { + Id = 9, + Name = "Ergonomic Fresh Pants", + Price = 92809m + }, + new + { + Id = 10, + Name = "Licensed Steel Sausages", + Price = 59260m + }); + }); + + modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Users_AspNetRoles", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Users.Entities.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeactivatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users_AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("SimpleModule.Users.Entities.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("SimpleModule.Users.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.OrderItem", b => + { + b.HasOne("SimpleModule.Orders.Contracts.Order", null) + .WithMany("Items") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.Order", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/template/SimpleModule.Host/Program.cs b/template/SimpleModule.Host/Program.cs index b3bc9fed..136994b2 100644 --- a/template/SimpleModule.Host/Program.cs +++ b/template/SimpleModule.Host/Program.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using Microsoft.OpenApi; using OpenIddict.Validation.AspNetCore; using SimpleModule.Blazor; @@ -10,11 +11,22 @@ using SimpleModule.Core.Inertia; using SimpleModule.Database; using SimpleModule.Database.Health; +using SimpleModule.Host; using SimpleModule.Host.Components; -using SimpleModule.Users.Constants; +using SimpleModule.OpenIddict.Contracts; var builder = WebApplication.CreateBuilder(args); +// Aspire service defaults (OpenTelemetry, resilience, service discovery) +builder.AddServiceDefaults(); + +// Bridge Aspire-managed connection string to Database options +var aspireConnectionString = builder.Configuration.GetConnectionString("simplemoduledb"); +if (!string.IsNullOrEmpty(aspireConnectionString)) +{ + builder.Configuration["Database:DefaultConnection"] = aspireConnectionString; +} + // Add services to the container. builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(options => @@ -75,9 +87,19 @@ // Register event bus builder.Services.AddScoped(); +// Inertia shared data (per-request bag for props shared across all Inertia responses) +builder.Services.AddScoped(); + // Register all modules builder.Services.AddModules(builder.Configuration); +// Register unified HostDbContext for EF Core migrations +builder.Services.AddModuleDbContext( + builder.Configuration, + "Host", + opts => opts.UseOpenIddict() +); + // Collect module menu items into a singleton registry builder.Services.CollectModuleMenuItems(); @@ -104,7 +126,6 @@ options.DefaultAuthenticateScheme = AuthConstants.SmartAuthPolicy; options.DefaultChallengeScheme = AuthConstants.SmartAuthPolicy; }); -builder.Services.AddAuthorization(); // Health checks builder @@ -116,8 +137,13 @@ var app = builder.Build(); -// Ensure databases are created with seed data -app.EnsureModuleDatabases(); +// Apply database schema (production should use explicit migration tooling) +if (!app.Environment.IsProduction()) +{ + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); +} app.UseExceptionHandler(); @@ -141,8 +167,8 @@ { var path = context.Request.Path.Value; bool hasVersionParam = context.Request.Query.ContainsKey("v"); - bool isVendorJs = path is not null - && path.StartsWith("/js/vendor/", StringComparison.OrdinalIgnoreCase); + bool isVendorJs = + path is not null && path.StartsWith("/js/vendor/", StringComparison.OrdinalIgnoreCase); if (hasVersionParam || isVendorJs) { @@ -165,19 +191,21 @@ // Health endpoints — liveness (no checks) and readiness (database checks) app.MapHealthChecks( - RouteConstants.HealthLive, - new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions - { - Predicate = _ => false, // No checks — just confirms the process is running - } -); + RouteConstants.HealthLive, + new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions + { + Predicate = _ => false, // No checks — just confirms the process is running + } + ) + .AllowAnonymous(); app.MapHealthChecks( - RouteConstants.HealthReady, - new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions - { - Predicate = check => check.Tags.Contains(HealthCheckConstants.ReadyTag), - } -); + RouteConstants.HealthReady, + new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions + { + Predicate = check => check.Tags.Contains(HealthCheckConstants.ReadyTag), + } + ) + .AllowAnonymous(); // Blazor SSR app.MapRazorComponents().AddModuleAssemblies(); @@ -185,4 +213,7 @@ // Automatically map all module endpoints app.MapModuleEndpoints(); -app.Run(); +// Aspire default health endpoints (/health, /alive) +app.MapDefaultEndpoints(); + +await app.RunAsync(); diff --git a/template/SimpleModule.Host/SimpleModule.Host.csproj b/template/SimpleModule.Host/SimpleModule.Host.csproj index 05fb59d4..88a4417c 100644 --- a/template/SimpleModule.Host/SimpleModule.Host.csproj +++ b/template/SimpleModule.Host/SimpleModule.Host.csproj @@ -9,6 +9,7 @@
+ @@ -23,8 +24,12 @@ /> + + + + @@ -67,12 +72,9 @@ - + diff --git a/template/SimpleModule.Host/Styles/app.css b/template/SimpleModule.Host/Styles/app.css index 381710a6..3c86e7ab 100644 --- a/template/SimpleModule.Host/Styles/app.css +++ b/template/SimpleModule.Host/Styles/app.css @@ -1,6 +1,7 @@ @import "tailwindcss"; @import "@simplemodule/theme-default/theme.css"; @source "../../../packages/SimpleModule.UI/"; +@source "../../../packages/SimpleModule.Client/"; @source "../../../framework/SimpleModule.Blazor/**/*.razor"; @source "../../../modules/**/Components/**/*.razor"; @source "./_scan/"; diff --git a/template/SimpleModule.Host/appsettings.Development.json b/template/SimpleModule.Host/appsettings.Development.json new file mode 100644 index 00000000..ce89481e --- /dev/null +++ b/template/SimpleModule.Host/appsettings.Development.json @@ -0,0 +1,5 @@ +{ + "Database": { + "DefaultConnection": "Data Source=app.db" + } +} diff --git a/tests/SimpleModule.Core.Tests/Authorization/PermissionAuthorizationHandlerTests.cs b/tests/SimpleModule.Core.Tests/Authorization/PermissionAuthorizationHandlerTests.cs new file mode 100644 index 00000000..5f117293 --- /dev/null +++ b/tests/SimpleModule.Core.Tests/Authorization/PermissionAuthorizationHandlerTests.cs @@ -0,0 +1,54 @@ +using System.Security.Claims; +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using SimpleModule.Core.Authorization; + +namespace SimpleModule.Core.Tests.Authorization; + +public class PermissionAuthorizationHandlerTests +{ + [Fact] + public async Task Handle_UserWithPermissionClaim_Succeeds() + { + var handler = new PermissionAuthorizationHandler(); + var requirement = new PermissionRequirement("Products.View"); + var user = CreateUser(new Claim("permission", "Products.View")); + var context = new AuthorizationHandlerContext([requirement], user, null); + + await handler.HandleAsync(context); + + context.HasSucceeded.Should().BeTrue(); + } + + [Fact] + public async Task Handle_UserWithoutPermissionClaim_Fails() + { + var handler = new PermissionAuthorizationHandler(); + var requirement = new PermissionRequirement("Products.View"); + var user = CreateUser(new Claim("permission", "Orders.View")); + var context = new AuthorizationHandlerContext([requirement], user, null); + + await handler.HandleAsync(context); + + context.HasSucceeded.Should().BeFalse(); + } + + [Fact] + public async Task Handle_AdminRole_BypassesPermissionCheck() + { + var handler = new PermissionAuthorizationHandler(); + var requirement = new PermissionRequirement("Products.Delete"); + var user = CreateUser(new Claim(ClaimTypes.Role, "Admin")); + var context = new AuthorizationHandlerContext([requirement], user, null); + + await handler.HandleAsync(context); + + context.HasSucceeded.Should().BeTrue(); + } + + private static ClaimsPrincipal CreateUser(params Claim[] claims) + { + var identity = new ClaimsIdentity(claims, "Test"); + return new ClaimsPrincipal(identity); + } +} diff --git a/tests/SimpleModule.Core.Tests/Authorization/PermissionRegistryBuilderTests.cs b/tests/SimpleModule.Core.Tests/Authorization/PermissionRegistryBuilderTests.cs new file mode 100644 index 00000000..bac8a9d3 --- /dev/null +++ b/tests/SimpleModule.Core.Tests/Authorization/PermissionRegistryBuilderTests.cs @@ -0,0 +1,50 @@ +using FluentAssertions; +using SimpleModule.Core.Authorization; + +namespace SimpleModule.Core.Tests.Authorization; + +public class PermissionRegistryBuilderTests +{ + private sealed class TestPermissions + { + public const string View = "Test.View"; + public const string Create = "Test.Create"; + } + + [Fact] + public void AddPermissions_ScansStaticConstants() + { + var builder = new PermissionRegistryBuilder(); + builder.AddPermissions(); + var registry = builder.Build(); + + registry.AllPermissions.Should().Contain("Test.View"); + registry.AllPermissions.Should().Contain("Test.Create"); + } + + [Fact] + public void Build_GroupsByModule() + { + var builder = new PermissionRegistryBuilder(); + builder.AddPermission("Products.View"); + builder.AddPermission("Products.Create"); + builder.AddPermission("Orders.View"); + var registry = builder.Build(); + + registry.ByModule.Should().ContainKey("Products"); + registry.ByModule["Products"].Should().HaveCount(2); + registry.ByModule.Should().ContainKey("Orders"); + registry.ByModule["Orders"].Should().HaveCount(1); + } + + [Fact] + public void AddPermission_DeduplicatesValues() + { + var builder = new PermissionRegistryBuilder(); + builder.AddPermission("Products.View"); + builder.AddPermission("Products.View"); + var registry = builder.Build(); + + registry.AllPermissions.Should().HaveCount(1); + } +} diff --git a/tests/SimpleModule.Core.Tests/Endpoints/CrudEndpointsTests.cs b/tests/SimpleModule.Core.Tests/Endpoints/CrudEndpointsTests.cs new file mode 100644 index 00000000..aafffa47 --- /dev/null +++ b/tests/SimpleModule.Core.Tests/Endpoints/CrudEndpointsTests.cs @@ -0,0 +1,83 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Http.HttpResults; +using SimpleModule.Core.Endpoints; + +namespace SimpleModule.Core.Tests.Endpoints; + +public class CrudEndpointsTests +{ + [Fact] + public async Task GetAll_ReturnsOkWithItems() + { + var items = new[] { "a", "b" }; + + var result = await CrudEndpoints.GetAll(() => Task.FromResult>(items)); + + var okResult = result.Should().BeOfType>>().Subject; + okResult.Value.Should().HaveCount(2); + okResult.Value.Should().ContainInOrder("a", "b"); + } + + [Fact] + public async Task GetAll_WithEmptyList_ReturnsOkWithEmptyList() + { + var result = await CrudEndpoints.GetAll(() => Task.FromResult>([])); + + var okResult = result.Should().BeOfType>>().Subject; + okResult.Value.Should().BeEmpty(); + } + + [Fact] + public async Task GetById_WhenFound_ReturnsOk() + { + var result = await CrudEndpoints.GetById(() => Task.FromResult("found-item")); + + var okResult = result.Should().BeOfType>().Subject; + okResult.Value.Should().Be("found-item"); + } + + [Fact] + public async Task GetById_WhenNull_ReturnsNotFound() + { + var result = await CrudEndpoints.GetById(() => Task.FromResult(null)); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Create_ReturnsCreatedWithLocation() + { + var result = await CrudEndpoints.Create( + () => Task.FromResult("new-item"), + item => $"/items/{item}" + ); + + var createdResult = result.Should().BeOfType>().Subject; + createdResult.Value.Should().Be("new-item"); + createdResult.Location.Should().Be("/items/new-item"); + } + + [Fact] + public async Task Update_ReturnsOkWithEntity() + { + var result = await CrudEndpoints.Update(() => Task.FromResult("updated-item")); + + var okResult = result.Should().BeOfType>().Subject; + okResult.Value.Should().Be("updated-item"); + } + + [Fact] + public async Task Delete_ReturnsNoContent() + { + var deleteCalled = false; + + var result = await CrudEndpoints.Delete(() => + { + deleteCalled = true; + return Task.CompletedTask; + }); + + result.Should().BeOfType(); + deleteCalled.Should().BeTrue(); + } +} diff --git a/tests/SimpleModule.Core.Tests/Menu/MenuRegistryTests.cs b/tests/SimpleModule.Core.Tests/Menu/MenuRegistryTests.cs index d764767b..8c007372 100644 --- a/tests/SimpleModule.Core.Tests/Menu/MenuRegistryTests.cs +++ b/tests/SimpleModule.Core.Tests/Menu/MenuRegistryTests.cs @@ -134,4 +134,29 @@ public void Constructor_GroupsItemsBySection() registry.GetItems(MenuSection.Navbar).Should().HaveCount(2); registry.GetItems(MenuSection.UserDropdown).Should().HaveCount(3); } + + [Fact] + public void GetItems_AdminSidebar_ReturnsCorrectItems() + { + var items = new List + { + new() + { + Label = "Users", + Url = "/admin/users", + Section = MenuSection.AdminSidebar, + }, + new() + { + Label = "Nav", + Url = "/nav", + Section = MenuSection.Navbar, + }, + }; + var registry = new MenuRegistry(items); + + var sidebarItems = registry.GetItems(MenuSection.AdminSidebar); + + sidebarItems.Should().ContainSingle().Which.Label.Should().Be("Users"); + } } diff --git a/tests/SimpleModule.Core.Tests/Validation/ValidationBuilderTests.cs b/tests/SimpleModule.Core.Tests/Validation/ValidationBuilderTests.cs new file mode 100644 index 00000000..66bd5928 --- /dev/null +++ b/tests/SimpleModule.Core.Tests/Validation/ValidationBuilderTests.cs @@ -0,0 +1,88 @@ +using FluentAssertions; +using SimpleModule.Core.Validation; + +namespace SimpleModule.Core.Tests.Validation; + +public class ValidationBuilderTests +{ + [Fact] + public void Build_WithNoErrors_ReturnsSuccess() + { + var result = new ValidationBuilder().Build(); + + result.IsValid.Should().BeTrue(); + result.Errors.Should().BeEmpty(); + } + + [Fact] + public void AddErrorIf_WhenConditionTrue_AddsError() + { + var result = new ValidationBuilder().AddErrorIf(true, "Name", "Name is required.").Build(); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainKey("Name"); + result.Errors["Name"].Should().Contain("Name is required."); + } + + [Fact] + public void AddErrorIf_WhenConditionFalse_DoesNotAddError() + { + var result = new ValidationBuilder().AddErrorIf(false, "Name", "Name is required.").Build(); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void AddErrorIf_MultipleSameField_AccumulatesErrors() + { + var result = new ValidationBuilder() + .AddErrorIf(true, "Name", "Name is required.") + .AddErrorIf(true, "Name", "Name must be at least 2 characters.") + .Build(); + + result.Errors["Name"].Should().HaveCount(2); + } + + [Fact] + public void AddErrorIf_MultipleFields_AccumulatesErrorsSeparately() + { + var result = new ValidationBuilder() + .AddErrorIf(true, "Name", "Name is required.") + .AddErrorIf(true, "Price", "Price must be positive.") + .Build(); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainKey("Name"); + result.Errors.Should().ContainKey("Price"); + result.Errors["Name"].Should().HaveCount(1); + result.Errors["Price"].Should().HaveCount(1); + } + + [Fact] + public void Build_CalledTwice_ReturnsSameResult() + { + var builder = new ValidationBuilder().AddErrorIf(true, "Name", "Name is required."); + + var result1 = builder.Build(); + var result2 = builder.Build(); + + result1.IsValid.Should().Be(result2.IsValid); + result1.Errors.Should().BeEquivalentTo(result2.Errors); + } + + [Fact] + public void AddErrorIf_MixedConditions_OnlyAddsForTrue() + { + var result = new ValidationBuilder() + .AddErrorIf(true, "Name", "Error one.") + .AddErrorIf(false, "Name", "Error two.") + .AddErrorIf(true, "Name", "Error three.") + .Build(); + + result.IsValid.Should().BeFalse(); + result.Errors["Name"].Should().HaveCount(2); + result.Errors["Name"].Should().Contain("Error one."); + result.Errors["Name"].Should().Contain("Error three."); + result.Errors["Name"].Should().NotContain("Error two."); + } +} diff --git a/tests/SimpleModule.Database.Tests/DatabaseProviderExtensionsTests.cs b/tests/SimpleModule.Database.Tests/DatabaseProviderExtensionsTests.cs new file mode 100644 index 00000000..faac1714 --- /dev/null +++ b/tests/SimpleModule.Database.Tests/DatabaseProviderExtensionsTests.cs @@ -0,0 +1,58 @@ +using FluentAssertions; +using SimpleModule.Database; + +namespace SimpleModule.Database.Tests; + +public sealed class DatabaseProviderExtensionsTests +{ + [Theory] + [InlineData(DatabaseProvider.Sqlite, "Sqlite")] + [InlineData(DatabaseProvider.PostgreSql, "PostgreSql")] + [InlineData(DatabaseProvider.SqlServer, "SqlServer")] + public void ToStringFast_ReturnsCorrectName(DatabaseProvider provider, string expected) + { + provider.ToStringFast().Should().Be(expected); + } + + [Theory] + [InlineData("Sqlite", DatabaseProvider.Sqlite)] + [InlineData("PostgreSql", DatabaseProvider.PostgreSql)] + [InlineData("SqlServer", DatabaseProvider.SqlServer)] + public void TryParse_ValidName_ReturnsTrue(string name, DatabaseProvider expected) + { + DatabaseProviderExtensions.TryParse(name, out var result).Should().BeTrue(); + result.Should().Be(expected); + } + + [Theory] + [InlineData("sqlite")] + [InlineData("POSTGRESQL")] + [InlineData("sqlserver")] + public void TryParse_CaseInsensitive_ReturnsTrue(string name) + { + // Use case-insensitive span-based parsing + DatabaseProviderExtensions.TryParse(name, out var result, true).Should().BeTrue(); + Enum.IsDefined(result).Should().BeTrue(); + } + + [Fact] + public void TryParse_InvalidName_ReturnsFalse() + { + DatabaseProviderExtensions.TryParse("MySQL", out _).Should().BeFalse(); + } + + [Theory] + [InlineData(DatabaseProvider.Sqlite)] + [InlineData(DatabaseProvider.PostgreSql)] + [InlineData(DatabaseProvider.SqlServer)] + public void IsDefined_ValidValue_ReturnsTrue(DatabaseProvider provider) + { + DatabaseProviderExtensions.IsDefined(provider).Should().BeTrue(); + } + + [Fact] + public void IsDefined_InvalidValue_ReturnsFalse() + { + DatabaseProviderExtensions.IsDefined((DatabaseProvider)999).Should().BeFalse(); + } +} diff --git a/tests/SimpleModule.Generator.Tests/DiagnosticTests.cs b/tests/SimpleModule.Generator.Tests/DiagnosticTests.cs new file mode 100644 index 00000000..b4d614a9 --- /dev/null +++ b/tests/SimpleModule.Generator.Tests/DiagnosticTests.cs @@ -0,0 +1,878 @@ +using FluentAssertions; +using SimpleModule.Generator.Tests.Helpers; + +namespace SimpleModule.Generator.Tests; + +public class DiagnosticTests +{ + #region SM0002: Empty module name + + [Fact] + public void SM0002_EmptyModuleName_ReportsWarning() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp + { + [Module("")] + public class BadModule : IModule { } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().Contain(d => d.Id == "SM0002"); + var diag = diagnostics.First(d => d.Id == "SM0002"); + diag.GetMessage(System.Globalization.CultureInfo.InvariantCulture) + .Should() + .Contain("BadModule"); + } + + [Fact] + public void SM0002_NonEmptyModuleName_NoDiagnostic() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp + { + [Module("Valid")] + public class ValidModule : IModule { } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().NotContain(d => d.Id == "SM0002"); + } + + #endregion + + #region SM0003: Multiple IdentityDbContexts + + [Fact] + public void SM0003_MultipleIdentityDbContexts_ReportsError() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.AspNetCore.Identity; + using Microsoft.AspNetCore.Identity.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Users + { + public class UserA : IdentityUser { } + public class RoleA : IdentityRole { } + + [Module("Users")] + public class UsersModule : IModule { } + + public class UsersDbContext : IdentityDbContext + { + public UsersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + } + } + + namespace TestApp.Accounts + { + public class UserB : IdentityUser { } + public class RoleB : IdentityRole { } + + [Module("Accounts")] + public class AccountsModule : IModule { } + + public class AccountsDbContext : IdentityDbContext + { + public AccountsDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (result, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().Contain(d => d.Id == "SM0003"); + var diag = diagnostics.First(d => d.Id == "SM0003"); + var message = diag.GetMessage(System.Globalization.CultureInfo.InvariantCulture); + message.Should().Contain("Users"); + message.Should().Contain("Accounts"); + + // Should NOT emit HostDbContext when there's a conflict + result + .GeneratedTrees.Should() + .NotContain(t => t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal)); + } + + [Fact] + public void SM0003_SingleIdentityDbContext_NoDiagnostic() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.AspNetCore.Identity; + using Microsoft.AspNetCore.Identity.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Users + { + public class AppUser : IdentityUser { } + public class AppRole : IdentityRole { } + + [Module("Users")] + public class UsersModule : IModule { } + + public class UsersDbContext : IdentityDbContext + { + public UsersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().NotContain(d => d.Id == "SM0003"); + } + + #endregion + + #region SM0006: Entity config for entity not in any DbSet + + [Fact] + public void SM0006_EntityConfigForMissingEntity_ReportsWarning() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Orders + { + public class Order { public int Id { get; set; } } + public class OrphanEntity { public int Id { get; set; } } + + [Module("Orders")] + public class OrdersModule : IModule { } + + public class OrdersDbContext : DbContext + { + public OrdersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Orders => Set(); + // Note: no DbSet for OrphanEntity + } + + public class OrphanConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().Contain(d => d.Id == "SM0006"); + var diag = diagnostics.First(d => d.Id == "SM0006"); + var message = diag.GetMessage(System.Globalization.CultureInfo.InvariantCulture); + message.Should().Contain("OrphanEntity"); + message.Should().Contain("OrphanConfiguration"); + } + + [Fact] + public void SM0006_EntityConfigForExistingEntity_NoDiagnostic() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Orders + { + public class Order { public int Id { get; set; } } + + [Module("Orders")] + public class OrdersModule : IModule { } + + public class OrdersDbContext : DbContext + { + public OrdersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Orders => Set(); + } + + public class OrderConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().NotContain(d => d.Id == "SM0006"); + } + + #endregion + + #region SM0007: Duplicate entity configuration + + [Fact] + public void SM0007_DuplicateEntityConfig_ReportsError() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Orders + { + public class Order { public int Id { get; set; } } + + [Module("Orders")] + public class OrdersModule : IModule { } + + public class OrdersDbContext : DbContext + { + public OrdersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Orders => Set(); + } + + public class OrderConfigA : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + } + } + + public class OrderConfigB : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Id).ValueGeneratedNever(); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (result, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().Contain(d => d.Id == "SM0007"); + var diag = diagnostics.First(d => d.Id == "SM0007"); + var message = diag.GetMessage(System.Globalization.CultureInfo.InvariantCulture); + message.Should().Contain("Order"); + message.Should().Contain("OrderConfigA"); + message.Should().Contain("OrderConfigB"); + + // Should NOT emit HostDbContext when there are duplicate configs + result + .GeneratedTrees.Should() + .NotContain(t => t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal)); + } + + [Fact] + public void SM0007_UniqueEntityConfigs_NoDiagnostic() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Orders + { + public class Order { public int Id { get; set; } } + public class OrderItem { public int ProductId { get; set; } } + + [Module("Orders")] + public class OrdersModule : IModule { } + + public class OrdersDbContext : DbContext + { + public OrdersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Orders => Set(); + public DbSet OrderItems => Set(); + } + + public class OrderConfig : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + } + } + + public class OrderItemConfig : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.ProductId); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().NotContain(d => d.Id == "SM0007"); + } + + #endregion + + #region No false positives on valid code + + [Fact] + public void ValidFullSetup_NoDiagnostics() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Microsoft.AspNetCore.Identity; + using Microsoft.AspNetCore.Identity.EntityFrameworkCore; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Users + { + public class AppUser : IdentityUser { } + public class AppRole : IdentityRole { } + + [Module("Users")] + public class UsersModule : IModule + { + public void ConfigureServices(IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class UsersDbContext : IdentityDbContext + { + public UsersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + } + } + + namespace TestApp.Products + { + public class Product { public int Id { get; set; } } + + [Module("Products")] + public class ProductsModule : IModule + { + public void ConfigureServices(IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class ProductsDbContext : DbContext + { + public ProductsDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Products => Set(); + } + } + + namespace TestApp.Orders + { + public class Order { public int Id { get; set; } } + public class OrderItem { public int ProductId { get; set; } } + + [Module("Orders")] + public class OrdersModule : IModule + { + public void ConfigureServices(IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class OrdersDbContext : DbContext + { + public OrdersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Orders => Set(); + public DbSet OrderItems => Set(); + } + + public class OrderItemConfig : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.ProductId); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics + .Where(d => d.Id.StartsWith("SM", StringComparison.Ordinal)) + .Should() + .BeEmpty("valid setup should produce no SM diagnostics"); + } + + #endregion + + #region SM0002: Whitespace-only module name + + [Fact] + public void SM0002_WhitespaceOnlyModuleName_DoesNotReport() + { + // Documents current behavior: IsNullOrEmpty does not catch whitespace-only names + var source = """ + using SimpleModule.Core; + + namespace TestApp + { + [Module(" ")] + public class WhitespaceModule : IModule { } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics + .Should() + .NotContain( + d => d.Id == "SM0002", + "SM0002 uses IsNullOrEmpty, not IsNullOrWhiteSpace, so whitespace-only names are allowed" + ); + } + + #endregion + + #region SM0003: Three IdentityDbContexts + + [Fact] + public void SM0003_ThreeIdentityDbContexts_ReportsError() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.AspNetCore.Identity; + using Microsoft.AspNetCore.Identity.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Users + { + public class UserA : IdentityUser { } + public class RoleA : IdentityRole { } + + [Module("Users")] + public class UsersModule : IModule { } + + public class UsersDbContext : IdentityDbContext + { + public UsersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + } + } + + namespace TestApp.Accounts + { + public class UserB : IdentityUser { } + public class RoleB : IdentityRole { } + + [Module("Accounts")] + public class AccountsModule : IModule { } + + public class AccountsDbContext : IdentityDbContext + { + public AccountsDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + } + } + + namespace TestApp.Auth + { + public class UserC : IdentityUser { } + public class RoleC : IdentityRole { } + + [Module("Auth")] + public class AuthModule : IModule { } + + public class AuthDbContext : IdentityDbContext + { + public AuthDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (result, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().Contain(d => d.Id == "SM0003"); + + // Should NOT emit HostDbContext when there's a conflict + result + .GeneratedTrees.Should() + .NotContain(t => t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal)); + } + + #endregion + + #region SM0007: Three configs for same entity + + [Fact] + public void SM0007_ThreeConfigsForSameEntity_ReportsError() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Orders + { + public class Order { public int Id { get; set; } } + + [Module("Orders")] + public class OrdersModule : IModule { } + + public class OrdersDbContext : DbContext + { + public OrdersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Orders => Set(); + } + + public class OrderConfigA : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + } + } + + public class OrderConfigB : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Id).ValueGeneratedNever(); + } + } + + public class OrderConfigC : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Id).HasColumnName("OrderId"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().Contain(d => d.Id == "SM0007"); + } + + #endregion + + #region Overlapping module name prefixes + + [Fact] + public void OverlappingModuleNamePrefixes_DbContextMatchedByLongestPrefix() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Product + { + public class ProductEntity { public int Id { get; set; } } + + [Module("Product")] + public class ProductModule : IModule { } + + public class ProductDbContext : DbContext + { + public ProductDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Products => Set(); + } + } + + namespace TestApp.ProductInventory + { + public class InventoryItem { public int Id { get; set; } } + + [Module("ProductInventory")] + public class ProductInventoryModule : IModule { } + + public class ProductInventoryDbContext : DbContext + { + public ProductInventoryDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet InventoryItems => Set(); + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (result, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + // No SM diagnostics — each DbContext should match the correct module + diagnostics + .Where(d => d.Id.StartsWith("SM", StringComparison.Ordinal)) + .Should() + .BeEmpty("overlapping prefixes should resolve correctly via longest match"); + + // HostDbContext should be emitted + result + .GeneratedTrees.Should() + .Contain(t => t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal)); + + // Both modules' DbSets should be present + var hostDbContext = result + .GeneratedTrees.First(t => + t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal) + ) + .GetText() + .ToString(); + + hostDbContext.Should().Contain("Products"); + hostDbContext.Should().Contain("InventoryItems"); + + // Each module should get its own schema + hostDbContext.Should().Contain("SetSchema(\"product\")"); + hostDbContext.Should().Contain("SetSchema(\"productinventory\")"); + } + + #endregion + + #region Mixed module types (Identity + Plain + NoDb) + + [Fact] + public void MixedModuleTypes_IdentityPlainAndNoDb_EmitsCorrectHostDbContext() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.AspNetCore.Identity; + using Microsoft.AspNetCore.Identity.EntityFrameworkCore; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Users + { + public class AppUser : IdentityUser { } + public class AppRole : IdentityRole { } + + [Module("Users")] + public class UsersModule : IModule { } + + public class UsersDbContext : IdentityDbContext + { + public UsersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + } + } + + namespace TestApp.Products + { + public class Product { public int Id { get; set; } } + + [Module("Products")] + public class ProductsModule : IModule { } + + public class ProductsDbContext : DbContext + { + public ProductsDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Products => Set(); + } + } + + namespace TestApp.Notifications + { + [Module("Notifications")] + public class NotificationsModule : IModule { } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (result, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + // No SM diagnostics + diagnostics + .Where(d => d.Id.StartsWith("SM", StringComparison.Ordinal)) + .Should() + .BeEmpty("mixed module types should produce no SM diagnostics"); + + // HostDbContext should be emitted + result + .GeneratedTrees.Should() + .Contain(t => t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal)); + + var hostDbContext = result + .GeneratedTrees.First(t => + t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal) + ) + .GetText() + .ToString(); + + // Should extend IdentityDbContext (from Users module) + hostDbContext.Should().Contain("IdentityDbContext<"); + + // Should have Products DbSet from plain module + hostDbContext.Should().Contain("Products"); + + // NoDb module (Notifications) should not cause any issues + hostDbContext.Should().NotContain("Notifications"); + } + + #endregion + + #region Deeply nested namespace matching + + [Fact] + public void DeeplyNestedNamespace_DbContextMatchedCorrectly() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Company.Division.Orders + { + public class Order { public int Id { get; set; } } + + [Module("Orders")] + public class OrdersModule : IModule { } + } + + namespace TestApp.Company.Division.Orders.Infrastructure + { + public class OrdersDbContext : DbContext + { + public OrdersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Orders => Set(); + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (result, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + // No SM diagnostics + diagnostics + .Where(d => d.Id.StartsWith("SM", StringComparison.Ordinal)) + .Should() + .BeEmpty("deeply nested namespace should match correctly"); + + // HostDbContext should be emitted + result + .GeneratedTrees.Should() + .Contain(t => t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal)); + + var hostDbContext = result + .GeneratedTrees.First(t => + t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal) + ) + .GetText() + .ToString(); + + hostDbContext.Should().Contain("Orders"); + hostDbContext.Should().Contain("SetSchema(\"orders\")"); + } + + #endregion + + #region DbContext in unrelated namespace fallback + + [Fact] + public void DbContextInUnrelatedNamespace_StillAssignedToModule() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Products + { + public class Product { public int Id { get; set; } } + + [Module("Products")] + public class ProductsModule : IModule { } + } + + namespace Unrelated.Namespace + { + public class SomeDbContext : DbContext + { + public SomeDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Products => Set(); + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var (result, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + // HostDbContext should still be emitted (DbContext is assigned via fallback) + result + .GeneratedTrees.Should() + .Contain(t => t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal)); + + var hostDbContext = result + .GeneratedTrees.First(t => + t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal) + ) + .GetText() + .ToString(); + + hostDbContext.Should().Contain("Products"); + } + + #endregion +} diff --git a/tests/SimpleModule.Generator.Tests/EndpointExtensionsEmitterTests.cs b/tests/SimpleModule.Generator.Tests/EndpointExtensionsEmitterTests.cs new file mode 100644 index 00000000..16a4d822 --- /dev/null +++ b/tests/SimpleModule.Generator.Tests/EndpointExtensionsEmitterTests.cs @@ -0,0 +1,312 @@ +using FluentAssertions; +using SimpleModule.Generator.Tests.Helpers; + +namespace SimpleModule.Generator.Tests; + +public class EndpointExtensionsEmitterTests +{ + [Fact] + public void Module_WithRoutePrefix_CreatesMapGroupWithTags() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Products", RoutePrefix = "/api/products")] + public class ProductsModule : IModule { } + } + + namespace TestApp.Endpoints + { + public class ListEndpoint : IEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/", () => "list"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var endpointExt = GetGeneratedSource(result, "EndpointExtensions.g.cs"); + + endpointExt + .Should() + .Contain( + "var group = app.MapGroup(\"/api/products\").WithTags(\"Products\").RequireAuthorization();" + ); + endpointExt.Should().Contain("new global::TestApp.Endpoints.ListEndpoint().Map(group);"); + } + + [Fact] + public void Module_WithoutRoutePrefix_MapsEndpointsDirectlyToApp() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Misc")] + public class MiscModule : IModule { } + } + + namespace TestApp.Endpoints + { + public class PingEndpoint : IEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/ping", () => "pong"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var endpointExt = GetGeneratedSource(result, "EndpointExtensions.g.cs"); + + endpointExt.Should().Contain("new global::TestApp.Endpoints.PingEndpoint().Map(app);"); + endpointExt.Should().NotContain("MapGroup"); + } + + [Fact] + public void Module_WithConfigureEndpoints_SkipsAutoRegistration() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Custom", RoutePrefix = "/api/custom")] + public class CustomModule : IModule + { + public void ConfigureEndpoints(IEndpointRouteBuilder endpoints) { } + } + } + + namespace TestApp.Endpoints + { + public class AutoEndpoint : IEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/auto", () => "auto"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var endpointExt = GetGeneratedSource(result, "EndpointExtensions.g.cs"); + + // Should NOT auto-register endpoints because HasConfigureEndpoints is true + endpointExt.Should().NotContain("new global::TestApp.Endpoints.AutoEndpoint()"); + // Should use the escape hatch instead + endpointExt + .Should() + .Contain("ModuleExtensions.s_TestApp_CustomModule.ConfigureEndpoints(app);"); + } + + [Fact] + public void Module_WithConfigureEndpoints_UsesEscapeHatch() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Manual")] + public class ManualModule : IModule + { + public void ConfigureEndpoints(IEndpointRouteBuilder endpoints) { } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var endpointExt = GetGeneratedSource(result, "EndpointExtensions.g.cs"); + + endpointExt + .Should() + .Contain("ModuleExtensions.s_TestApp_ManualModule.ConfigureEndpoints(app);"); + } + + [Fact] + public void Module_WithNoEndpoints_NothingEmittedForThatModule() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp + { + [Module("Empty")] + public class EmptyModule : IModule { } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var endpointExt = GetGeneratedSource(result, "EndpointExtensions.g.cs"); + + endpointExt.Should().NotContain("EmptyModule"); + endpointExt.Should().NotContain("MapGroup"); + } + + [Fact] + public void MultipleModules_WithDifferentPrefixes_EachGetOwnGroup() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace ModuleA + { + [Module("Alpha", RoutePrefix = "/api/alpha")] + public class AlphaModule : IModule { } + } + + namespace ModuleA.Endpoints + { + public class AlphaEndpoint : IEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/", () => "alpha"); + } + } + } + + namespace ModuleB + { + [Module("Beta", RoutePrefix = "/api/beta")] + public class BetaModule : IModule { } + } + + namespace ModuleB.Endpoints + { + public class BetaEndpoint : IEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/", () => "beta"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var endpointExt = GetGeneratedSource(result, "EndpointExtensions.g.cs"); + + endpointExt.Should().Contain("app.MapGroup(\"/api/alpha\").WithTags(\"Alpha\")"); + endpointExt.Should().Contain("app.MapGroup(\"/api/beta\").WithTags(\"Beta\")"); + endpointExt.Should().Contain("new global::ModuleA.Endpoints.AlphaEndpoint().Map(group)"); + endpointExt.Should().Contain("new global::ModuleB.Endpoints.BetaEndpoint().Map(group)"); + } + + [Fact] + public void ViewEndpoints_WithViewPrefix_GetViewGroupWithExcludeFromDescription() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Products", ViewPrefix = "/products")] + public class ProductsModule : IModule { } + } + + namespace TestApp.Views + { + public class BrowseEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/browse", () => "browse"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var endpointExt = GetGeneratedSource(result, "EndpointExtensions.g.cs"); + + endpointExt + .Should() + .Contain( + "var viewGroup = app.MapGroup(\"/products\").WithTags(\"Products\").ExcludeFromDescription().RequireAuthorization();" + ); + endpointExt.Should().Contain("new global::TestApp.Views.BrowseEndpoint().Map(viewGroup);"); + } + + [Fact] + public void ViewEndpoints_WithoutViewPrefix_MapDirectlyToApp() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Test")] + public class TestModule : IModule { } + } + + namespace TestApp.Views + { + public class HomeEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/", () => "home"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var endpointExt = GetGeneratedSource(result, "EndpointExtensions.g.cs"); + + endpointExt.Should().Contain("new global::TestApp.Views.HomeEndpoint().Map(app);"); + endpointExt.Should().NotContain("viewGroup"); + } + + private static string GetGeneratedSource( + Microsoft.CodeAnalysis.GeneratorDriverRunResult result, + string fileName + ) + { + return result + .GeneratedTrees.First(t => t.FilePath.EndsWith(fileName, StringComparison.Ordinal)) + .GetText() + .ToString(); + } +} diff --git a/tests/SimpleModule.Generator.Tests/Helpers/GeneratorTestHelper.cs b/tests/SimpleModule.Generator.Tests/Helpers/GeneratorTestHelper.cs index 09c1dce6..6d0f4dcc 100644 --- a/tests/SimpleModule.Generator.Tests/Helpers/GeneratorTestHelper.cs +++ b/tests/SimpleModule.Generator.Tests/Helpers/GeneratorTestHelper.cs @@ -1,4 +1,5 @@ -using System.Reflection; +using System.Collections.Immutable; +using System.Reflection; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using SimpleModule.Generator; @@ -77,6 +78,56 @@ public static CSharpCompilation CreateCompilation(params string[] sources) ); } + public static CSharpCompilation CreateCompilationWithEfCore(params string[] sources) + { + var compilation = CreateCompilation(sources); + + var efCoreReferences = new List + { + MetadataReference.CreateFromFile( + typeof(Microsoft.EntityFrameworkCore.DbContext).Assembly.Location + ), + MetadataReference.CreateFromFile( + typeof(Microsoft.EntityFrameworkCore.DbSet<>).Assembly.Location + ), + MetadataReference.CreateFromFile( + typeof(Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext<,,>) + .Assembly + .Location + ), + MetadataReference.CreateFromFile( + typeof(Microsoft.AspNetCore.Identity.IdentityUser).Assembly.Location + ), + MetadataReference.CreateFromFile( + typeof(Microsoft.Extensions.Options.IOptions<>).Assembly.Location + ), + MetadataReference.CreateFromFile( + typeof(SimpleModule.Database.DatabaseOptions).Assembly.Location + ), + }; + + // Add EF Core abstractions assembly if separate + var efCoreDir = Path.GetDirectoryName( + typeof(Microsoft.EntityFrameworkCore.DbContext).Assembly.Location + )!; + var efAbstractions = Path.Combine( + efCoreDir, + "Microsoft.EntityFrameworkCore.Abstractions.dll" + ); + if (File.Exists(efAbstractions)) + efCoreReferences.Add(MetadataReference.CreateFromFile(efAbstractions)); + + // Add Microsoft.Extensions.Identity.Stores for IdentityUser + var identityStoresPath = Path.Combine( + efCoreDir, + "Microsoft.Extensions.Identity.Stores.dll" + ); + if (File.Exists(identityStoresPath)) + efCoreReferences.Add(MetadataReference.CreateFromFile(identityStoresPath)); + + return compilation.AddReferences(efCoreReferences); + } + public static GeneratorDriverRunResult RunGenerator(CSharpCompilation compilation) { var generator = new ModuleDiscovererGenerator(); @@ -86,4 +137,17 @@ public static GeneratorDriverRunResult RunGenerator(CSharpCompilation compilatio return driver.GetRunResult(); } + + public static ( + GeneratorDriverRunResult Result, + ImmutableArray Diagnostics + ) RunGeneratorWithDiagnostics(CSharpCompilation compilation) + { + var generator = new ModuleDiscovererGenerator(); + + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out var diagnostics); + + return (driver.GetRunResult(), diagnostics); + } } diff --git a/tests/SimpleModule.Generator.Tests/HostDbContextGenerationTests.cs b/tests/SimpleModule.Generator.Tests/HostDbContextGenerationTests.cs new file mode 100644 index 00000000..a904896b --- /dev/null +++ b/tests/SimpleModule.Generator.Tests/HostDbContextGenerationTests.cs @@ -0,0 +1,1108 @@ +using FluentAssertions; +using SimpleModule.Generator.Tests.Helpers; + +namespace SimpleModule.Generator.Tests; + +public class HostDbContextGenerationTests +{ + private const string ModuleWithDbContext = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Products.Contracts + { + [Dto] + public class Product + { + public int Id { get; set; } + public string Name { get; set; } = ""; + } + } + + namespace TestApp.Products + { + [Module("Products")] + public class ProductsModule : IModule + { + public void ConfigureServices(IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class ProductsDbContext : DbContext + { + public ProductsDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Products => Set(); + } + } + """; + + private const string ModuleWithIdentityDbContext = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.AspNetCore.Identity; + using Microsoft.AspNetCore.Identity.EntityFrameworkCore; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Users.Entities + { + public class AppUser : IdentityUser { } + public class AppRole : IdentityRole { } + } + + namespace TestApp.Users + { + [Module("Users")] + public class UsersModule : IModule + { + public void ConfigureServices(IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class UsersDbContext : IdentityDbContext + { + public UsersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + } + } + """; + + private const string ModuleWithEntityConfig = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Orders.Contracts + { + [Dto] + public class Order + { + public int Id { get; set; } + } + + [Dto] + public class OrderItem + { + public int ProductId { get; set; } + public int Quantity { get; set; } + } + } + + namespace TestApp.Orders + { + [Module("Orders")] + public class OrdersModule : IModule + { + public void ConfigureServices(IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class OrdersDbContext : DbContext + { + public OrdersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Orders => Set(); + public DbSet OrderItems => Set(); + } + } + + namespace TestApp.Orders.EntityConfigurations + { + public class OrderItemConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey("OrderId", nameof(global::TestApp.Orders.Contracts.OrderItem.ProductId)); + } + } + } + """; + + #region Basic emission + + [Fact] + public void ModuleWithDbContext_EmitsHostDbContext() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithDbContext); + var result = GeneratorTestHelper.RunGenerator(compilation); + + result + .GeneratedTrees.Should() + .Contain(t => t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal)); + } + + [Fact] + public void ModuleWithoutDbContext_DoesNotEmitHostDbContext() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("NoDb")] + public class NoDbModule : IModule { } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + result + .GeneratedTrees.Should() + .NotContain(t => t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal)); + } + + [Fact] + public void HostDbContext_HasAutoGeneratedHeader() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithDbContext); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().StartWith("// "); + } + + [Fact] + public void HostDbContext_HasCorrectNamespace() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithDbContext); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().Contain("namespace SimpleModule.Host;"); + } + + #endregion + + #region DbContext base class + + [Fact] + public void PlainDbContext_HostExtendsDbContext() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithDbContext); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().Contain(") : DbContext(options)"); + source.Should().NotContain("IdentityDbContext"); + } + + [Fact] + public void IdentityDbContext_HostExtendsIdentityDbContext() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( + ModuleWithIdentityDbContext + ); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().Contain("IdentityDbContext<"); + source.Should().Contain("global::TestApp.Users.Entities.AppUser"); + source.Should().Contain("global::TestApp.Users.Entities.AppRole"); + source.Should().Contain("string>(options)"); + } + + [Fact] + public void IdentityDbContext_CallsBaseOnModelCreating() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( + ModuleWithIdentityDbContext + ); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().Contain("base.OnModelCreating(builder)"); + } + + [Fact] + public void PlainDbContext_DoesNotCallBaseOnModelCreating() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithDbContext); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().NotContain("base.OnModelCreating(builder)"); + } + + #endregion + + #region Constructor + + [Fact] + public void Constructor_HasDbContextOptionsParameter() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithDbContext); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().Contain("DbContextOptions options"); + } + + [Fact] + public void Constructor_HasDatabaseOptionsParameter() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithDbContext); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().Contain("IOptions dbOptions"); + } + + #endregion + + #region DbSet properties + + [Fact] + public void SingleModule_DbSetPropertiesPresent() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithDbContext); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().Contain("DbSet Products"); + source.Should().Contain("Set()"); + } + + [Fact] + public void MultipleModules_AllDbSetsPresent() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( + ModuleWithDbContext, + ModuleWithEntityConfig + ); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().Contain("Products"); + source.Should().Contain("Orders"); + source.Should().Contain("OrderItems"); + } + + [Fact] + public void DuplicateEntityAcrossModules_DeduplicatedToSingleDbSet() + { + // Both modules have a DbSet for the same entity type + var moduleA = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Shared + { + public class SharedEntity + { + public int Id { get; set; } + } + } + + namespace TestApp.ModuleA + { + [Module("ModuleA")] + public class ModuleAModule : IModule { } + + public class ModuleADbContext : DbContext + { + public ModuleADbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet SharedEntities => Set(); + } + } + """; + + var moduleB = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.ModuleB + { + [Module("ModuleB")] + public class ModuleBModule : IModule { } + + public class ModuleBDbContext : DbContext + { + public ModuleBDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet SharedEntities => Set(); + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(moduleA, moduleB); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + // Should appear only once + var count = CountOccurrences(source, "DbSet"); + count.Should().Be(1); + } + + #endregion + + #region IEntityTypeConfiguration + + [Fact] + public void EntityTypeConfiguration_ApplyConfigurationEmitted() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithEntityConfig); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source + .Should() + .Contain( + "ApplyConfiguration(new global::TestApp.Orders.EntityConfigurations.OrderItemConfiguration())" + ); + } + + [Fact] + public void NoEntityTypeConfiguration_NoApplyConfigurationSection() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithDbContext); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().NotContain("ApplyConfiguration"); + } + + #endregion + + #region Schema isolation + + [Fact] + public void SchemaIsolation_NonSqlite_SetsSchemaPerModule() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( + ModuleWithDbContext, + ModuleWithEntityConfig + ); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().Contain("if (provider != DatabaseProvider.Sqlite)"); + source.Should().Contain(".Metadata.SetSchema(\"products\")"); + source.Should().Contain(".Metadata.SetSchema(\"orders\")"); + } + + [Fact] + public void SchemaIsolation_Sqlite_PrefixesTables() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( + ModuleWithDbContext, + ModuleWithEntityConfig + ); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().Contain(".ToTable(\"Products_\""); + source.Should().Contain(".ToTable(\"Orders_\""); + } + + [Fact] + public void IdentityFallbackSchema_AssignsRemainingEntitiesToIdentityModule() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( + ModuleWithDbContext, + ModuleWithIdentityDbContext + ); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + // Identity tables (not explicitly assigned) should get the Users schema + source.Should().Contain("if (entityType.GetSchema() is null)"); + source.Should().Contain("entityType.SetSchema(\"users\")"); + } + + [Fact] + public void NoIdentityDbContext_NoFallbackSchemaLoop() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithDbContext); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().NotContain("if (entityType.GetSchema() is null)"); + } + + [Fact] + public void SchemaIsolation_UsesDatabaseProviderDetector() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithDbContext); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().Contain("DatabaseProviderDetector.Detect("); + source.Should().Contain("dbOptions.Value.DefaultConnection"); + source.Should().Contain("dbOptions.Value.Provider"); + } + + [Fact] + public void Sqlite_IdentityTablesPrefixed() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( + ModuleWithDbContext, + ModuleWithIdentityDbContext + ); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + source.Should().Contain("Prefix Identity tables for SQLite"); + source.Should().Contain("entityType.SetTableName(\"Users_\" + tableName)"); + } + + [Fact] + public void Sqlite_IdentityPrefixing_SkipsAlreadyPrefixedTables() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( + ModuleWithDbContext, + ModuleWithIdentityDbContext + ); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + // Should check for Products_ prefix before applying Users_ prefix + source.Should().Contain("tableName.StartsWith(\"Products_\""); + } + + #endregion + + #region Abstract/static DbContext ignored + + [Fact] + public void AbstractDbContext_IsIgnored() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + + public abstract class AbstractDbContext : DbContext + { + protected AbstractDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Items => Set(); + } + + public class SomeEntity + { + public int Id { get; set; } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + result + .GeneratedTrees.Should() + .NotContain(t => t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal)); + } + + #endregion + + #region Duplicate table name diagnostic + + [Fact] + public void DuplicateDbSetPropertyName_ReportsDiagnostic() + { + var moduleA = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.ModuleA; + + public class EntityA { public int Id { get; set; } } + + [Module("ModuleA")] + public class ModuleAModule : IModule { } + + public class ModuleADbContext : DbContext + { + public ModuleADbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Items => Set(); + } + """; + + var moduleB = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.ModuleB; + + public class EntityB { public int Id { get; set; } } + + [Module("ModuleB")] + public class ModuleBModule : IModule { } + + public class ModuleBDbContext : DbContext + { + public ModuleBDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Items => Set(); + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(moduleA, moduleB); + var (result, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().Contain(d => d.Id == "SM0001"); + + // Should NOT emit HostDbContext when there are conflicts + result + .GeneratedTrees.Should() + .NotContain(t => t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal)); + } + + [Fact] + public void DuplicateDbSetPropertyName_DiagnosticContainsModuleNames() + { + var moduleA = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Alpha; + + public class AlphaEntity { public int Id { get; set; } } + + [Module("Alpha")] + public class AlphaModule : IModule { } + + public class AlphaDbContext : DbContext + { + public AlphaDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Records => Set(); + } + """; + + var moduleB = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Beta; + + public class BetaEntity { public int Id { get; set; } } + + [Module("Beta")] + public class BetaModule : IModule { } + + public class BetaDbContext : DbContext + { + public BetaDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Records => Set(); + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(moduleA, moduleB); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + var diagnostic = diagnostics.First(d => d.Id == "SM0001"); + var message = diagnostic.GetMessage(System.Globalization.CultureInfo.InvariantCulture); + message.Should().Contain("Records"); + message.Should().Contain("Alpha"); + message.Should().Contain("Beta"); + } + + [Fact] + public void UniqueDbSetPropertyNames_NoDiagnostic() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( + ModuleWithDbContext, + ModuleWithEntityConfig + ); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().NotContain(d => d.Id == "SM0001"); + } + + #endregion + + #region Multiple modules combined + + [Fact] + public void IdentityAndPlainModules_CombinedCorrectly() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( + ModuleWithDbContext, + ModuleWithIdentityDbContext, + ModuleWithEntityConfig + ); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var source = GetHostDbContext(result); + + // Should extend IdentityDbContext + source.Should().Contain("IdentityDbContext<"); + + // Should have all DbSets + source.Should().Contain("Products"); + source.Should().Contain("Orders"); + source.Should().Contain("OrderItems"); + + // Should apply entity configs + source + .Should() + .Contain( + "ApplyConfiguration(new global::TestApp.Orders.EntityConfigurations.OrderItemConfiguration()" + ); + + // Should have schemas for all modules + source.Should().Contain("SetSchema(\"products\")"); + source.Should().Contain("SetSchema(\"orders\")"); + source.Should().Contain("SetSchema(\"users\")"); + } + + #endregion + + #region Edge cases + + [Fact] + public void DbContextThroughIntermediateBaseClass_IsDiscovered() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Auditing + { + public class AuditEntry + { + public int Id { get; set; } + public string Action { get; set; } = ""; + } + + public class BaseDbContext : DbContext + { + public BaseDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + } + + [Module("Auditing")] + public class AuditingModule : IModule + { + public void ConfigureServices(Microsoft.Extensions.DependencyInjection.IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class AuditingDbContext : BaseDbContext + { + public AuditingDbContext(DbContextOptions options, IOptions dbOptions) + : base(options, dbOptions) { } + + public DbSet AuditEntries => Set(); + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var hostSource = GetHostDbContext(result); + hostSource.Should().Contain("DbSet AuditEntries"); + } + + [Fact] + public void PrivateDbSetProperties_AreIgnored() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Secrets + { + public class PublicEntity + { + public int Id { get; set; } + } + + public class InternalEntity + { + public int Id { get; set; } + } + + [Module("Secrets")] + public class SecretsModule : IModule + { + public void ConfigureServices(Microsoft.Extensions.DependencyInjection.IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class SecretsDbContext : DbContext + { + public SecretsDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet PublicEntities => Set(); + private DbSet InternalEntities => Set(); + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var hostSource = GetHostDbContext(result); + hostSource.Should().Contain("DbSet PublicEntities"); + hostSource.Should().NotContain("InternalEntities"); + } + + [Fact] + public void NonDbSetProperties_AreIgnored() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Metadata + { + public class MetaItem + { + public int Id { get; set; } + } + + [Module("Metadata")] + public class MetadataModule : IModule + { + public void ConfigureServices(Microsoft.Extensions.DependencyInjection.IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class MetadataDbContext : DbContext + { + public MetadataDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public string ConnectionInfo { get; set; } = ""; + public DbSet MetaItems => Set(); + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var hostSource = GetHostDbContext(result); + hostSource.Should().Contain("DbSet MetaItems"); + hostSource.Should().NotContain("ConnectionInfo"); + } + + [Fact] + public void AbstractEntityConfiguration_IsIgnored() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Catalog + { + public class CatalogItem + { + public int Id { get; set; } + public string Name { get; set; } = ""; + } + + [Module("Catalog")] + public class CatalogModule : IModule + { + public void ConfigureServices(Microsoft.Extensions.DependencyInjection.IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class CatalogDbContext : DbContext + { + public CatalogDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet CatalogItems => Set(); + } + + public abstract class BaseCatalogConfiguration : IEntityTypeConfiguration + { + public abstract void Configure(EntityTypeBuilder builder); + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var hostSource = GetHostDbContext(result); + hostSource.Should().NotContain("ApplyConfiguration"); + hostSource.Should().NotContain("BaseCatalogConfiguration"); + } + + [Fact] + public void IdentityDbContextOnly_NoExtraDbSets_EmitsHostDbContext() + { + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( + ModuleWithIdentityDbContext + ); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var hostSource = GetHostDbContext(result); + + // Should extend IdentityDbContext + hostSource.Should().Contain("IdentityDbContext<"); + hostSource.Should().Contain("global::TestApp.Users.Entities.AppUser"); + hostSource.Should().Contain("global::TestApp.Users.Entities.AppRole"); + + // Should have zero extra DbSet properties (no "=>" expression body for DbSet) + hostSource.Should().NotContain("=> Set<"); + + // Should still have schema isolation + hostSource.Should().Contain("SetSchema(\"users\")"); + } + + [Fact] + public void EntityNameConflictsAcrossNamespaces_BothAppearWithCorrectFQN() + { + var moduleA = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Products + { + public class Item + { + public int Id { get; set; } + public string ProductName { get; set; } = ""; + } + + [Module("Products")] + public class ProductsModule : IModule + { + public void ConfigureServices(Microsoft.Extensions.DependencyInjection.IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class ProductsDbContext : DbContext + { + public ProductsDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet ProductItems => Set(); + } + } + """; + + var moduleB = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Orders + { + public class Item + { + public int Id { get; set; } + public int Quantity { get; set; } + } + + [Module("Orders")] + public class OrdersModule : IModule + { + public void ConfigureServices(Microsoft.Extensions.DependencyInjection.IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class OrdersDbContext : DbContext + { + public OrdersDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet OrderItems => Set(); + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(moduleA, moduleB); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var hostSource = GetHostDbContext(result); + hostSource.Should().Contain("DbSet ProductItems"); + hostSource.Should().Contain("DbSet OrderItems"); + } + + [Fact] + public void MultipleEntityConfigs_AllApplyConfigurationCallsEmitted() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp.Inventory + { + public class Warehouse + { + public int Id { get; set; } + public string Location { get; set; } = ""; + } + + public class StockItem + { + public int Id { get; set; } + public int WarehouseId { get; set; } + } + + [Module("Inventory")] + public class InventoryModule : IModule + { + public void ConfigureServices(IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class InventoryDbContext : DbContext + { + public InventoryDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet Warehouses => Set(); + public DbSet StockItems => Set(); + } + } + + namespace TestApp.Inventory.EntityConfigurations + { + public class WarehouseConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(w => w.Id); + } + } + + public class StockItemConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(s => s.Id); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var hostSource = GetHostDbContext(result); + hostSource + .Should() + .Contain( + "ApplyConfiguration(new global::TestApp.Inventory.EntityConfigurations.WarehouseConfiguration())" + ); + hostSource + .Should() + .Contain( + "ApplyConfiguration(new global::TestApp.Inventory.EntityConfigurations.StockItemConfiguration())" + ); + } + + [Fact] + public void DbSetWithGenericEntityType_PreservesGenericInGeneratedCode() + { + var source = """ + using SimpleModule.Core; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using SimpleModule.Database; + + namespace TestApp + { + public class AuditLog + { + public int Id { get; set; } + public T Value { get; set; } = default!; + } + } + + namespace TestApp.Logging + { + [Module("Logging")] + public class LoggingModule : IModule + { + public void ConfigureServices(Microsoft.Extensions.DependencyInjection.IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) { } + } + + public class LoggingDbContext : DbContext + { + public LoggingDbContext(DbContextOptions options, IOptions dbOptions) + : base(options) { } + + public DbSet> AuditLogs => Set>(); + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var hostSource = GetHostDbContext(result); + hostSource.Should().Contain("DbSet> AuditLogs"); + } + + #endregion + + #region Helpers + + private static string GetHostDbContext(Microsoft.CodeAnalysis.GeneratorDriverRunResult result) + { + return result + .GeneratedTrees.First(t => + t.FilePath.EndsWith("HostDbContext.g.cs", StringComparison.Ordinal) + ) + .GetText() + .ToString(); + } + + private static int CountOccurrences(string source, string substring) + { + var count = 0; + var index = 0; + while ((index = source.IndexOf(substring, index, StringComparison.Ordinal)) != -1) + { + count++; + index += substring.Length; + } + return count; + } + + #endregion +} diff --git a/tests/SimpleModule.Generator.Tests/JsonResolverEmitterTests.cs b/tests/SimpleModule.Generator.Tests/JsonResolverEmitterTests.cs new file mode 100644 index 00000000..0f1f49e3 --- /dev/null +++ b/tests/SimpleModule.Generator.Tests/JsonResolverEmitterTests.cs @@ -0,0 +1,262 @@ +using FluentAssertions; +using SimpleModule.Generator.Tests.Helpers; + +namespace SimpleModule.Generator.Tests; + +public class JsonResolverEmitterTests +{ + [Fact] + public void Dto_WithInternalProperty_ExcludedFromResolver() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + + [Dto] + public class InternalPropDto + { + public int Id { get; set; } + internal string Secret { get; set; } = ""; + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var resolver = GetResolver(result); + + resolver.Should().Contain("prop_Id"); + resolver.Should().NotContain("prop_Secret"); + } + + [Fact] + public void Resolver_DispatchesByExactTypeComparison() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + + [Dto] + public class MyDto + { + public int Id { get; set; } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var resolver = GetResolver(result); + + resolver.Should().Contain("if (type == typeof(global::TestApp.MyDto))"); + } + + [Fact] + public void EachDto_GetsOwnCreateFactoryMethod() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + + [Dto] + public class AlphaDto + { + public int Id { get; set; } + } + + [Dto] + public class BetaDto + { + public string Name { get; set; } = ""; + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var resolver = GetResolver(result); + + resolver + .Should() + .Contain( + "private static JsonTypeInfo Create_TestApp_AlphaDto(JsonSerializerOptions options)" + ); + resolver + .Should() + .Contain( + "private static JsonTypeInfo Create_TestApp_BetaDto(JsonSerializerOptions options)" + ); + } + + [Fact] + public void Property_WithNoSetter_OnlyGetsGetLambda() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + + [Dto] + public class ReadOnlyPropDto + { + public int Id { get; } + public string Name { get; set; } = ""; + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var resolver = GetResolver(result); + + // Id has Get but no Set + resolver.Should().Contain("prop_Id.Get ="); + // Count the Set assignments - Id should not have one + var lines = resolver.Split('\n'); + var idSetLine = lines.Any(l => l.Contains("prop_Id.Set =", StringComparison.Ordinal)); + idSetLine.Should().BeFalse(); + + // Name has both Get and Set + resolver.Should().Contain("prop_Name.Get ="); + resolver.Should().Contain("prop_Name.Set ="); + } + + [Fact] + public void Resolver_CreateObject_UsesNewInstance() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + + [Dto] + public class SimpleDto + { + public int Id { get; set; } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var resolver = GetResolver(result); + + resolver + .Should() + .Contain("info.CreateObject = static () => new global::TestApp.SimpleDto();"); + } + + [Fact] + public void Resolver_PropertyJsonName_IsCamelCased() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + + [Dto] + public class CamelDto + { + public int ProductId { get; set; } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var resolver = GetResolver(result); + + resolver.Should().Contain("\"productId\""); + resolver.Should().NotContain("\"ProductId\""); + } + + [Fact] + public void Resolver_IncludesPragmaWarningDisable() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + + [Dto] + public class SimpleDto + { + public int Id { get; set; } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var resolver = GetResolver(result); + + resolver.Should().Contain("#pragma warning disable IL2026"); + resolver.Should().Contain("#pragma warning disable IL3050"); + resolver.Should().Contain("#nullable enable"); + } + + [Fact] + public void Property_WithPrivateSetter_ExcludesSetLambda() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + + [Dto] + public class PrivateSetterDto + { + public int Id { get; private set; } + public string Name { get; set; } = ""; + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var resolver = GetResolver(result); + + // Id has Get but no Set (setter is private) + resolver.Should().Contain("prop_Id.Get ="); + var lines = resolver.Split('\n'); + lines.Any(l => l.Contains("prop_Id.Set =", StringComparison.Ordinal)).Should().BeFalse(); + + // Name has both + resolver.Should().Contain("prop_Name.Set ="); + } + + private static string GetResolver(Microsoft.CodeAnalysis.GeneratorDriverRunResult result) => + result + .GeneratedTrees.First(t => + t.FilePath.EndsWith("ModulesJsonResolver.g.cs", StringComparison.Ordinal) + ) + .GetText() + .ToString(); +} diff --git a/tests/SimpleModule.Generator.Tests/RazorComponentExtensionsEmitterTests.cs b/tests/SimpleModule.Generator.Tests/RazorComponentExtensionsEmitterTests.cs new file mode 100644 index 00000000..a7181ae7 --- /dev/null +++ b/tests/SimpleModule.Generator.Tests/RazorComponentExtensionsEmitterTests.cs @@ -0,0 +1,127 @@ +using FluentAssertions; +using SimpleModule.Generator.Tests.Helpers; + +namespace SimpleModule.Generator.Tests; + +public class RazorComponentExtensionsEmitterTests +{ + [Fact] + public void NoModulesWithRazorComponents_MethodBodyHasNoAddAdditionalAssemblies() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var razorExt = GetGeneratedSource(result, "RazorComponentExtensions.g.cs"); + + razorExt.Should().NotContain("AddAdditionalAssemblies"); + razorExt.Should().Contain("return builder;"); + } + + [Fact] + public void GeneratedCode_UsesCorrectNamespaceAndClassName() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var razorExt = GetGeneratedSource(result, "RazorComponentExtensions.g.cs"); + + razorExt.Should().Contain("namespace SimpleModule.Core;"); + razorExt.Should().Contain("public static class RazorComponentExtensions"); + razorExt + .Should() + .Contain("public static RazorComponentsEndpointConventionBuilder AddModuleAssemblies("); + razorExt.Should().Contain("this RazorComponentsEndpointConventionBuilder builder)"); + } + + [Fact] + public void AlwaysGenerated_EvenWithNoModules_FileExists() + { + // This test verifies the file is always emitted even when no modules have Razor components. + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + result + .GeneratedTrees.Should() + .Contain(t => + t.FilePath.EndsWith("RazorComponentExtensions.g.cs", StringComparison.Ordinal) + ); + } + + [Fact] + public void GeneratedCode_StartsWithAutoGeneratedComment() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var razorExt = GetGeneratedSource(result, "RazorComponentExtensions.g.cs"); + + razorExt.Should().StartWith("// "); + } + + [Fact] + public void GeneratedCode_IncludesUsingDirective() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var razorExt = GetGeneratedSource(result, "RazorComponentExtensions.g.cs"); + + razorExt.Should().Contain("using Microsoft.AspNetCore.Builder;"); + } + + private static string GetGeneratedSource( + Microsoft.CodeAnalysis.GeneratorDriverRunResult result, + string fileName + ) + { + return result + .GeneratedTrees.First(t => t.FilePath.EndsWith(fileName, StringComparison.Ordinal)) + .GetText() + .ToString(); + } +} diff --git a/tests/SimpleModule.Generator.Tests/SimpleModule.Generator.Tests.csproj b/tests/SimpleModule.Generator.Tests/SimpleModule.Generator.Tests.csproj index 8fa734f8..07e9c65b 100644 --- a/tests/SimpleModule.Generator.Tests/SimpleModule.Generator.Tests.csproj +++ b/tests/SimpleModule.Generator.Tests/SimpleModule.Generator.Tests.csproj @@ -13,9 +13,11 @@ + + diff --git a/tests/SimpleModule.Generator.Tests/TypeScriptDefinitionsEmitterTests.cs b/tests/SimpleModule.Generator.Tests/TypeScriptDefinitionsEmitterTests.cs new file mode 100644 index 00000000..ad274922 --- /dev/null +++ b/tests/SimpleModule.Generator.Tests/TypeScriptDefinitionsEmitterTests.cs @@ -0,0 +1,334 @@ +using FluentAssertions; +using SimpleModule.Generator.Tests.Helpers; + +namespace SimpleModule.Generator.Tests; + +public class TypeScriptDefinitionsEmitterTests +{ + [Fact] + public void Dto_WithStringIntBool_MapsToCorrectTsTypes() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp.Contracts; + + [Module("TestApp")] + public class TestAppModule : IModule { } + + [Dto] + public class ItemDto + { + public string Name { get; set; } = ""; + public int Count { get; set; } + public bool IsActive { get; set; } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var tsSource = GetGeneratedSource(result, "DtoTypeScript_Contracts.g.cs"); + + tsSource.Should().Contain("name: string;"); + tsSource.Should().Contain("count: number;"); + tsSource.Should().Contain("isActive: boolean;"); + } + + [Fact] + public void Dto_WithDateTimeGuidDateOnly_MapsToString() + { + var source = """ + using System; + using SimpleModule.Core; + + namespace TestApp.Contracts; + + [Module("TestApp")] + public class TestAppModule : IModule { } + + [Dto] + public class DatesDto + { + public DateTime CreatedAt { get; set; } + public Guid Id { get; set; } + public DateOnly BirthDate { get; set; } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var tsSource = GetGeneratedSource(result, "DtoTypeScript_Contracts.g.cs"); + + tsSource.Should().Contain("createdAt: string;"); + tsSource.Should().Contain("id: string;"); + tsSource.Should().Contain("birthDate: string;"); + } + + [Fact] + public void Dto_WithListAndIEnumerable_MapsToArray() + { + var source = """ + using System.Collections.Generic; + using SimpleModule.Core; + + namespace TestApp.Contracts; + + [Module("TestApp")] + public class TestAppModule : IModule { } + + [Dto] + public class CollectionDto + { + public List Tags { get; set; } = new(); + public IEnumerable Scores { get; set; } = new List(); + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var tsSource = GetGeneratedSource(result, "DtoTypeScript_Contracts.g.cs"); + + tsSource.Should().Contain("tags: string[];"); + tsSource.Should().Contain("scores: number[];"); + } + + [Fact] + public void Dto_WithNullableShorthandValueType_MapsToAny() + { + // Note: Nullable via shorthand (int?) produces FQNs with nested global:: prefixes + // that the TypeMappingHelpers does not currently resolve. This test documents that behavior. + var source = """ + using SimpleModule.Core; + + namespace TestApp.Contracts; + + [Module("TestApp")] + public class TestAppModule : IModule { } + + [Dto] + public class NullableDto + { + public int? OptionalCount { get; set; } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var tsSource = GetGeneratedSource(result, "DtoTypeScript_Contracts.g.cs"); + + // Current behavior: nullable value types via shorthand (int?) map to `any` + // because Roslyn represents them with nested global:: prefixes that + // MapCSharpTypeToTypeScript doesn't resolve. + tsSource.Should().Contain("optionalCount: any;"); + } + + [Fact] + public void Dto_ReferencingAnotherDto_UsesDtoInterfaceName() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp.Contracts; + + [Module("TestApp")] + public class TestAppModule : IModule { } + + [Dto] + public class CategoryDto + { + public int Id { get; set; } + public string Name { get; set; } = ""; + } + + [Dto] + public class ProductDto + { + public int Id { get; set; } + public CategoryDto Category { get; set; } = new(); + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var tsSource = GetGeneratedSource(result, "DtoTypeScript_Contracts.g.cs"); + + tsSource.Should().Contain("category: CategoryDto;"); + tsSource.Should().NotContain("category: any;"); + } + + [Fact] + public void PropertyNames_AreCamelCased() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp.Contracts; + + [Module("TestApp")] + public class TestAppModule : IModule { } + + [Dto] + public class CamelDto + { + public string FirstName { get; set; } = ""; + public int ProductId { get; set; } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var tsSource = GetGeneratedSource(result, "DtoTypeScript_Contracts.g.cs"); + + tsSource.Should().Contain("firstName: string;"); + tsSource.Should().Contain("productId: number;"); + tsSource.Should().NotContain("FirstName:"); + tsSource.Should().NotContain("ProductId:"); + } + + [Fact] + public void File_UsesSimpleModuleTsWrapper() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp.Contracts; + + [Module("TestApp")] + public class TestAppModule : IModule { } + + [Dto] + public class SimpleDto + { + public int Id { get; set; } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var tsSource = GetGeneratedSource(result, "DtoTypeScript_Contracts.g.cs"); + + tsSource.Should().Contain("#if SIMPLEMODULE_TS"); + tsSource.Should().Contain("/*"); + tsSource.Should().Contain("*/"); + tsSource.Should().Contain("#endif"); + } + + [Fact] + public void File_IncludesModuleComment() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp.Contracts; + + [Module("TestApp")] + public class TestAppModule : IModule { } + + [Dto] + public class SimpleDto + { + public int Id { get; set; } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var tsSource = GetGeneratedSource(result, "DtoTypeScript_Contracts.g.cs"); + + tsSource.Should().Contain("// @module Contracts"); + } + + [Fact] + public void NoDtoTypes_NoTypeScriptFileEmitted() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + result.GeneratedTrees.Should().NotContain(t => t.FilePath.Contains("DtoTypeScript_")); + } + + [Fact] + public void Dto_WithUnknownType_MapsToAny() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp.Contracts; + + [Module("TestApp")] + public class TestAppModule : IModule { } + + public class NonDtoClass + { + public int Value { get; set; } + } + + [Dto] + public class ContainerDto + { + public NonDtoClass Child { get; set; } = new(); + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var tsSource = GetGeneratedSource(result, "DtoTypeScript_Contracts.g.cs"); + + tsSource.Should().Contain("child: any;"); + } + + [Fact] + public void Dto_GeneratesExportInterface() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp.Contracts; + + [Module("TestApp")] + public class TestAppModule : IModule { } + + [Dto] + public class MyDto + { + public int Id { get; set; } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var tsSource = GetGeneratedSource(result, "DtoTypeScript_Contracts.g.cs"); + + tsSource.Should().Contain("export interface MyDto {"); + } + + private static string GetGeneratedSource( + Microsoft.CodeAnalysis.GeneratorDriverRunResult result, + string fileName + ) + { + return result + .GeneratedTrees.First(t => t.FilePath.EndsWith(fileName, StringComparison.Ordinal)) + .GetText() + .ToString(); + } +} diff --git a/tests/SimpleModule.Generator.Tests/ViewPagesEmitterTests.cs b/tests/SimpleModule.Generator.Tests/ViewPagesEmitterTests.cs new file mode 100644 index 00000000..13c9ecec --- /dev/null +++ b/tests/SimpleModule.Generator.Tests/ViewPagesEmitterTests.cs @@ -0,0 +1,266 @@ +using FluentAssertions; +using SimpleModule.Generator.Tests.Helpers; + +namespace SimpleModule.Generator.Tests; + +public class ViewPagesEmitterTests +{ + [Fact] + public void Module_WithNoViews_NoViewPagesFileEmitted() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp; + + [Module("Test")] + public class TestModule : IModule { } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + result.GeneratedTrees.Should().NotContain(t => t.FilePath.Contains("ViewPages_")); + } + + [Fact] + public void ModuleName_DerivedFromClassName_StripsModuleSuffix() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Products", ViewPrefix = "/products")] + public class ProductsModule : IModule { } + } + + namespace TestApp.Views + { + public class BrowseEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/browse", () => "browse"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + result + .GeneratedTrees.Should() + .Contain(t => t.FilePath.EndsWith("ViewPages_Products.g.cs", StringComparison.Ordinal)); + } + + [Fact] + public void ImportStatements_UseComponentName() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Items", ViewPrefix = "/items")] + public class ItemsModule : IModule { } + } + + namespace TestApp.Views + { + public class CreateEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/create", () => "create"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var viewPages = GetGeneratedSource(result, "ViewPages_Items.g.cs"); + + viewPages.Should().Contain("import Create from '../Views/Create';"); + } + + [Fact] + public void PagesRecord_MapsViewPageNameToComponent() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Orders", ViewPrefix = "/orders")] + public class OrdersModule : IModule { } + } + + namespace TestApp.Views + { + public class DetailEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/{id}", () => "detail"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var viewPages = GetGeneratedSource(result, "ViewPages_Orders.g.cs"); + + viewPages.Should().Contain("export const pages: Record = {"); + viewPages.Should().Contain("'Orders/Detail': Detail"); + } + + [Fact] + public void MultipleViews_AllAppearInPagesRecord() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Test", ViewPrefix = "/test")] + public class TestModule : IModule { } + } + + namespace TestApp.Views + { + public class BrowseEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/browse", () => "browse"); + } + } + + public class CreateEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/create", () => "create"); + } + } + + public class EditEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/edit", () => "edit"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var viewPages = GetGeneratedSource(result, "ViewPages_Test.g.cs"); + + viewPages.Should().Contain("import Browse from '../Views/Browse';"); + viewPages.Should().Contain("import Create from '../Views/Create';"); + viewPages.Should().Contain("import Edit from '../Views/Edit';"); + viewPages.Should().Contain("'Test/Browse': Browse"); + viewPages.Should().Contain("'Test/Create': Create"); + viewPages.Should().Contain("'Test/Edit': Edit"); + } + + [Fact] + public void Output_WrappedInSimpleModuleTsDirective() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Test", ViewPrefix = "/test")] + public class TestModule : IModule { } + } + + namespace TestApp.Views + { + public class IndexEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/", () => "index"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var viewPages = GetGeneratedSource(result, "ViewPages_Test.g.cs"); + + viewPages.Should().Contain("#if SIMPLEMODULE_TS"); + viewPages.Should().Contain("/*"); + viewPages.Should().Contain("*/"); + viewPages.Should().Contain("#endif"); + } + + [Fact] + public void ViewClassName_WithViewSuffix_StripsViewSuffix() + { + var source = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Routing; + using SimpleModule.Core; + + namespace TestApp + { + [Module("Test", ViewPrefix = "/test")] + public class TestModule : IModule { } + } + + namespace TestApp.Views + { + public class DetailView : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/{id}", () => "detail"); + } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var viewPages = GetGeneratedSource(result, "ViewPages_Test.g.cs"); + + viewPages.Should().Contain("import Detail from '../Views/Detail';"); + viewPages.Should().Contain("'Test/Detail': Detail"); + } + + private static string GetGeneratedSource( + Microsoft.CodeAnalysis.GeneratorDriverRunResult result, + string fileName + ) + { + return result + .GeneratedTrees.First(t => t.FilePath.EndsWith(fileName, StringComparison.Ordinal)) + .GetText() + .ToString(); + } +} diff --git a/tests/SimpleModule.Tests.Shared/Fakes/FakeDataGenerators.cs b/tests/SimpleModule.Tests.Shared/Fakes/FakeDataGenerators.cs index c5394ad6..3ef39ef9 100644 --- a/tests/SimpleModule.Tests.Shared/Fakes/FakeDataGenerators.cs +++ b/tests/SimpleModule.Tests.Shared/Fakes/FakeDataGenerators.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; using Bogus; using SimpleModule.Orders.Contracts; using SimpleModule.Products.Contracts; @@ -10,7 +10,10 @@ public static class FakeDataGenerators { public static Faker UserFaker { get; } = new Faker() - .RuleFor(u => u.Id, f => (f.IndexFaker + 1).ToString(CultureInfo.InvariantCulture)) + .RuleFor( + u => u.Id, + f => UserId.From((f.IndexFaker + 1).ToString(CultureInfo.InvariantCulture)) + ) .RuleFor(u => u.Email, f => f.Internet.Email()) .RuleFor(u => u.DisplayName, f => f.Person.FullName) .RuleFor(u => u.EmailConfirmed, _ => true) @@ -18,21 +21,21 @@ public static class FakeDataGenerators public static Faker ProductFaker { get; } = new Faker() - .RuleFor(p => p.Id, f => f.IndexFaker + 1) + .RuleFor(p => p.Id, f => ProductId.From(f.IndexFaker + 1)) .RuleFor(p => p.Name, f => f.Commerce.ProductName()) .RuleFor(p => p.Price, f => f.Finance.Amount(1, 1000)); public static Faker OrderItemFaker { get; } = new Faker() - .RuleFor(oi => oi.ProductId, f => f.Random.Int(1, 100)) + .RuleFor(oi => oi.ProductId, f => ProductId.From(f.Random.Int(1, 100))) .RuleFor(oi => oi.Quantity, f => f.Random.Int(1, 10)); public static Faker OrderFaker { get; } = new Faker() - .RuleFor(o => o.Id, f => f.IndexFaker + 1) + .RuleFor(o => o.Id, f => OrderId.From(f.IndexFaker + 1)) .RuleFor( o => o.UserId, - f => f.Random.Int(1, 100).ToString(CultureInfo.InvariantCulture) + f => UserId.From(f.Random.Int(1, 100).ToString(CultureInfo.InvariantCulture)) ) .RuleFor(o => o.Items, f => OrderItemFaker.Generate(f.Random.Int(1, 3))) .RuleFor(o => o.Total, f => f.Finance.Amount(10, 500)) @@ -42,7 +45,7 @@ public static class FakeDataGenerators new Faker() .RuleFor( r => r.UserId, - f => f.Random.Int(1, 100).ToString(CultureInfo.InvariantCulture) + f => UserId.From(f.Random.Int(1, 100).ToString(CultureInfo.InvariantCulture)) ) .RuleFor(r => r.Items, f => OrderItemFaker.Generate(f.Random.Int(1, 3))); @@ -50,7 +53,7 @@ public static class FakeDataGenerators new Faker() .RuleFor( r => r.UserId, - f => f.Random.Int(1, 100).ToString(CultureInfo.InvariantCulture) + f => UserId.From(f.Random.Int(1, 100).ToString(CultureInfo.InvariantCulture)) ) .RuleFor(r => r.Items, f => OrderItemFaker.Generate(f.Random.Int(1, 3))); diff --git a/tests/SimpleModule.Tests.Shared/Fakes/FakeOrderContracts.cs b/tests/SimpleModule.Tests.Shared/Fakes/FakeOrderContracts.cs index 488f40e9..6ae6e5a0 100644 --- a/tests/SimpleModule.Tests.Shared/Fakes/FakeOrderContracts.cs +++ b/tests/SimpleModule.Tests.Shared/Fakes/FakeOrderContracts.cs @@ -1,4 +1,4 @@ -using SimpleModule.Orders.Contracts; +using SimpleModule.Orders.Contracts; namespace SimpleModule.Tests.Shared.Fakes; @@ -11,14 +11,14 @@ public class FakeOrderContracts : IOrderContracts public Task> GetAllOrdersAsync() => Task.FromResult>(Orders); - public Task GetOrderByIdAsync(int id) => + public Task GetOrderByIdAsync(OrderId id) => Task.FromResult(Orders.FirstOrDefault(o => o.Id == id)); public Task CreateOrderAsync(CreateOrderRequest request) { var order = new Order { - Id = _nextId++, + Id = OrderId.From(_nextId++), UserId = request.UserId, Items = request.Items, Total = 0m, @@ -28,7 +28,7 @@ public Task CreateOrderAsync(CreateOrderRequest request) return Task.FromResult(order); } - public Task UpdateOrderAsync(int id, UpdateOrderRequest request) + public Task UpdateOrderAsync(OrderId id, UpdateOrderRequest request) { var order = Orders.FirstOrDefault(o => o.Id == id); if (order is null) @@ -41,7 +41,7 @@ public Task UpdateOrderAsync(int id, UpdateOrderRequest request) return Task.FromResult(order); } - public Task DeleteOrderAsync(int id) + public Task DeleteOrderAsync(OrderId id) { var order = Orders.FirstOrDefault(o => o.Id == id); if (order is null) diff --git a/tests/SimpleModule.Tests.Shared/Fakes/FakePermissionContracts.cs b/tests/SimpleModule.Tests.Shared/Fakes/FakePermissionContracts.cs new file mode 100644 index 00000000..1b57558b --- /dev/null +++ b/tests/SimpleModule.Tests.Shared/Fakes/FakePermissionContracts.cs @@ -0,0 +1,59 @@ +using SimpleModule.Permissions.Contracts; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Tests.Shared.Fakes; + +public class FakePermissionContracts : IPermissionContracts +{ + private readonly Dictionary> _userPermissions = new(); + private readonly Dictionary> _rolePermissions = new(); + + public Task> GetPermissionsForUserAsync(UserId userId) + { + return Task.FromResult>( + _userPermissions.TryGetValue(userId.Value, out var perms) + ? perms + : new HashSet() + ); + } + + public Task> GetPermissionsForRoleAsync(RoleId roleId) + { + return Task.FromResult>( + _rolePermissions.TryGetValue(roleId.Value, out var perms) + ? perms + : new HashSet() + ); + } + + public async Task> GetAllPermissionsForUserAsync( + UserId userId, + IEnumerable roleIds + ) + { + var result = new HashSet(); + + var userPerms = await GetPermissionsForUserAsync(userId); + result.UnionWith(userPerms); + + foreach (var roleId in roleIds) + { + var rolePerms = await GetPermissionsForRoleAsync(roleId); + result.UnionWith(rolePerms); + } + + return result; + } + + public Task SetPermissionsForUserAsync(UserId userId, IEnumerable permissions) + { + _userPermissions[userId.Value] = new HashSet(permissions); + return Task.CompletedTask; + } + + public Task SetPermissionsForRoleAsync(RoleId roleId, IEnumerable permissions) + { + _rolePermissions[roleId.Value] = new HashSet(permissions); + return Task.CompletedTask; + } +} diff --git a/tests/SimpleModule.Tests.Shared/Fakes/FakeProductContracts.cs b/tests/SimpleModule.Tests.Shared/Fakes/FakeProductContracts.cs index 7df3b2f4..9007202b 100644 --- a/tests/SimpleModule.Tests.Shared/Fakes/FakeProductContracts.cs +++ b/tests/SimpleModule.Tests.Shared/Fakes/FakeProductContracts.cs @@ -1,4 +1,4 @@ -using SimpleModule.Products.Contracts; +using SimpleModule.Products.Contracts; namespace SimpleModule.Tests.Shared.Fakes; @@ -9,10 +9,10 @@ public class FakeProductContracts : IProductContracts public Task> GetAllProductsAsync() => Task.FromResult>(Products); - public Task GetProductByIdAsync(int id) => + public Task GetProductByIdAsync(ProductId id) => Task.FromResult(Products.FirstOrDefault(p => p.Id == id)); - public Task> GetProductsByIdsAsync(IEnumerable ids) + public Task> GetProductsByIdsAsync(IEnumerable ids) { var idSet = ids.ToHashSet(); return Task.FromResult>( @@ -26,7 +26,7 @@ public Task CreateProductAsync(CreateProductRequest request) { var product = new Product { - Id = _nextId++, + Id = ProductId.From(_nextId++), Name = request.Name, Price = request.Price, }; @@ -34,7 +34,7 @@ public Task CreateProductAsync(CreateProductRequest request) return Task.FromResult(product); } - public Task UpdateProductAsync(int id, UpdateProductRequest request) + public Task UpdateProductAsync(ProductId id, UpdateProductRequest request) { var product = Products.FirstOrDefault(p => p.Id == id); if (product is null) @@ -47,7 +47,7 @@ public Task UpdateProductAsync(int id, UpdateProductRequest request) return Task.FromResult(product); } - public Task DeleteProductAsync(int id) + public Task DeleteProductAsync(ProductId id) { var product = Products.FirstOrDefault(p => p.Id == id); if (product is null) diff --git a/tests/SimpleModule.Tests.Shared/Fakes/FakeUserContracts.cs b/tests/SimpleModule.Tests.Shared/Fakes/FakeUserContracts.cs index 6cf00c74..b92218dc 100644 --- a/tests/SimpleModule.Tests.Shared/Fakes/FakeUserContracts.cs +++ b/tests/SimpleModule.Tests.Shared/Fakes/FakeUserContracts.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; using SimpleModule.Users.Contracts; namespace SimpleModule.Tests.Shared.Fakes; @@ -10,10 +10,10 @@ public class FakeUserContracts : IUserContracts public Task> GetAllUsersAsync() => Task.FromResult>(Users); - public Task GetUserByIdAsync(string id) => + public Task GetUserByIdAsync(UserId id) => Task.FromResult(Users.FirstOrDefault(u => u.Id == id)); - public Task GetCurrentUserAsync(string userId) => + public Task GetCurrentUserAsync(UserId userId) => Task.FromResult(Users.FirstOrDefault(u => u.Id == userId)); private int _nextId = 100; @@ -22,7 +22,7 @@ public Task CreateUserAsync(CreateUserRequest request) { var user = new UserDto { - Id = (_nextId++).ToString(CultureInfo.InvariantCulture), + Id = UserId.From((_nextId++).ToString(CultureInfo.InvariantCulture)), Email = request.Email, DisplayName = request.DisplayName, EmailConfirmed = false, @@ -32,7 +32,7 @@ public Task CreateUserAsync(CreateUserRequest request) return Task.FromResult(user); } - public Task UpdateUserAsync(string id, UpdateUserRequest request) + public Task UpdateUserAsync(UserId id, UpdateUserRequest request) { var user = Users.FirstOrDefault(u => u.Id == id); if (user is null) @@ -45,7 +45,7 @@ public Task UpdateUserAsync(string id, UpdateUserRequest request) return Task.FromResult(user); } - public Task DeleteUserAsync(string id) + public Task DeleteUserAsync(UserId id) { var user = Users.FirstOrDefault(u => u.Id == id); if (user is null) diff --git a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs index 3b044a70..799222a7 100644 --- a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs +++ b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs @@ -8,7 +8,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using SimpleModule.Admin; +using SimpleModule.Host; +using SimpleModule.OpenIddict; using SimpleModule.Orders; +using SimpleModule.Permissions; using SimpleModule.Products; using SimpleModule.Users; @@ -29,9 +33,18 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) builder.ConfigureServices(services => { - ReplaceDbContext(services, useOpenIddict: true); + ReplaceDbContext(services, useOpenIddict: true); + ReplaceDbContext(services); ReplaceDbContext(services); ReplaceDbContext(services); + ReplaceDbContext(services); + ReplaceDbContext(services); + ReplaceDbContext(services, useOpenIddict: true); + + // Remove hosted seed services — they need real DB tables that + // EnsureCreated on HostDbContext alone won't produce for module contexts. + RemoveHostedService(services); + RemoveHostedService(services); // Add test authentication scheme that bypasses OpenIddict validation services @@ -44,6 +57,19 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) }); } + public HttpClient CreateAuthenticatedClient( + string[] permissions, + params Claim[] additionalClaims + ) + { + var claims = new List(additionalClaims); + foreach (var permission in permissions) + { + claims.Add(new Claim("permission", permission)); + } + return CreateAuthenticatedClient(claims.ToArray()); + } + public HttpClient CreateAuthenticatedClient(params Claim[] claims) { var client = CreateClient(); @@ -63,6 +89,19 @@ public HttpClient CreateAuthenticatedClient(params Claim[] claims) return client; } + private static void RemoveHostedService(IServiceCollection services) + where TService : class + { + var descriptor = services.SingleOrDefault(d => + d.ServiceType == typeof(Microsoft.Extensions.Hosting.IHostedService) + && d.ImplementationType == typeof(TService) + ); + if (descriptor is not null) + { + services.Remove(descriptor); + } + } + private void ReplaceDbContext(IServiceCollection services, bool useOpenIddict = false) where TContext : DbContext { diff --git a/tests/SimpleModule.Tests.Shared/SimpleModule.Tests.Shared.csproj b/tests/SimpleModule.Tests.Shared/SimpleModule.Tests.Shared.csproj index 04352023..a3e5f019 100644 --- a/tests/SimpleModule.Tests.Shared/SimpleModule.Tests.Shared.csproj +++ b/tests/SimpleModule.Tests.Shared/SimpleModule.Tests.Shared.csproj @@ -18,5 +18,8 @@ + + + diff --git a/tests/e2e/pages/openiddict/clients.page.ts b/tests/e2e/pages/openiddict/clients.page.ts new file mode 100644 index 00000000..557bc2a5 --- /dev/null +++ b/tests/e2e/pages/openiddict/clients.page.ts @@ -0,0 +1,82 @@ +import type { Page } from '@playwright/test'; + +export class ClientsPage { + constructor(private page: Page) {} + + async goto() { + await this.page.goto('/openiddict/clients'); + } + + get heading() { + return this.page.getByRole('heading', { name: /clients/i }); + } + + get createButton() { + return this.page.getByRole('button', { name: 'Create Client' }); + } + + clientRow(clientId: string) { + return this.page.getByRole('row').filter({ hasText: clientId }); + } + + editButton(clientId: string) { + return this.clientRow(clientId).getByRole('button', { name: 'Edit' }); + } + + deleteButton(clientId: string) { + return this.clientRow(clientId).getByRole('button', { name: 'Delete' }); + } +} + +export class ClientsCreatePage { + constructor(private page: Page) {} + + async goto() { + await this.page.goto('/openiddict/clients/create'); + } + + get heading() { + return this.page.getByRole('heading', { name: /create client/i }); + } + + async createClient(clientId: string, displayName: string, type: 'public' | 'confidential' = 'public') { + await this.page.getByLabel('Client ID').fill(clientId); + await this.page.getByLabel('Display Name').fill(displayName); + await this.page.getByLabel('Client Type').selectOption(type); + await this.page.getByRole('button', { name: 'Create Client' }).click(); + } +} + +export class ClientsEditPage { + constructor(private page: Page) {} + + get heading() { + return this.page.getByRole('heading', { name: /edit client/i }); + } + + get detailsTab() { + return this.page.getByRole('button', { name: 'Details' }); + } + + get urisTab() { + return this.page.getByRole('button', { name: 'URIs' }); + } + + get permissionsTab() { + return this.page.getByRole('button', { name: 'Permissions' }); + } + + get displayNameInput() { + return this.page.getByLabel('Display Name'); + } + + get saveButton() { + return this.page.getByRole('button', { name: 'Save' }); + } + + async updateDisplayName(name: string) { + await this.displayNameInput.fill(name); + await this.saveButton.click(); + await this.page.waitForLoadState('networkidle'); + } +} diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index 7d16bff9..64f1ca75 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -44,5 +44,10 @@ export default defineConfig({ reuseExistingServer: true, ignoreHTTPSErrors: true, timeout: 60_000, + env: { + ...process.env, + ASPNETCORE_URLS: 'https://localhost:5001', + Database__DefaultConnection: 'Data Source=e2e-test.db', + }, }, }); diff --git a/tests/e2e/tests/flows/openiddict-clients-crud.spec.ts b/tests/e2e/tests/flows/openiddict-clients-crud.spec.ts new file mode 100644 index 00000000..cf7d70c0 --- /dev/null +++ b/tests/e2e/tests/flows/openiddict-clients-crud.spec.ts @@ -0,0 +1,87 @@ +import { expect, test } from '../../fixtures/base'; +import { + ClientsCreatePage, + ClientsEditPage, + ClientsPage, +} from '../../pages/openiddict/clients.page'; + +test.describe('OpenIddict Clients CRUD', () => { + test('create, verify, edit, and delete a client', async ({ page }) => { + const suffix = Date.now(); + const clientId = `e2e-client-${suffix}`; + const displayName = `E2E Test Client ${suffix}`; + const updatedName = `E2E Updated Client ${suffix}`; + + const clientsPage = new ClientsPage(page); + const createPage = new ClientsCreatePage(page); + const editPage = new ClientsEditPage(page); + + // Create a client + await createPage.goto(); + await expect(createPage.heading).toBeVisible(); + await createPage.createClient(clientId, displayName); + + // Should redirect to edit page + await expect(editPage.heading).toBeVisible(); + + // Verify it appears on list page (search by clientId which is always in the row) + await clientsPage.goto(); + await expect(clientsPage.clientRow(clientId)).toBeVisible(); + + // Edit the client display name + await clientsPage.editButton(clientId).click(); + await expect(editPage.heading).toBeVisible(); + await editPage.updateDisplayName(updatedName); + + // Verify the update on list page (the clientId row should now contain the updated name) + await clientsPage.goto(); + await expect(clientsPage.clientRow(clientId)).toBeVisible(); + // The display name column should show the updated name + await expect(clientsPage.clientRow(clientId)).toContainText(updatedName, { timeout: 10000 }); + + // Delete the client + page.on('dialog', (dialog) => dialog.accept()); + await clientsPage.deleteButton(clientId).click(); + await page.waitForLoadState('networkidle'); + + // Verify it's gone + await expect(clientsPage.clientRow(clientId)).not.toBeVisible(); + }); + + test('edit client URIs tab', async ({ page }) => { + const clientsPage = new ClientsPage(page); + const editPage = new ClientsEditPage(page); + + // Navigate to the seeded client's edit page + await clientsPage.goto(); + await clientsPage.editButton('simplemodule-client').click(); + await expect(editPage.heading).toBeVisible(); + + // Switch to URIs tab + await editPage.urisTab.click(); + await expect(page.getByRole('heading', { name: /redirect uris/i })).toBeVisible(); + + // Verify seeded redirect URIs are displayed in input fields + await expect(page.locator('input[value*="swagger/oauth2-redirect"]')).toBeVisible(); + await expect(page.locator('input[value*="oauth-callback"]')).toBeVisible(); + }); + + test('edit client Permissions tab', async ({ page }) => { + const clientsPage = new ClientsPage(page); + const editPage = new ClientsEditPage(page); + + // Navigate to the seeded client's edit page + await clientsPage.goto(); + await clientsPage.editButton('simplemodule-client').click(); + await expect(editPage.heading).toBeVisible(); + + // Switch to Permissions tab + await editPage.permissionsTab.click(); + + // Verify permission groups are visible + await expect(page.getByText('Endpoints')).toBeVisible(); + await expect(page.getByText('Grant Types')).toBeVisible(); + await expect(page.getByText('Response Types')).toBeVisible(); + await expect(page.getByText('Scopes')).toBeVisible(); + }); +}); diff --git a/tests/e2e/tests/flows/permissions.spec.ts b/tests/e2e/tests/flows/permissions.spec.ts new file mode 100644 index 00000000..a4752337 --- /dev/null +++ b/tests/e2e/tests/flows/permissions.spec.ts @@ -0,0 +1,78 @@ +import { expect, test } from '../../fixtures/base'; + +test.describe('Permission System', () => { + test.describe('authenticated admin user', () => { + // These tests use the default auth state (admin user) + + test('can access products API', async ({ page }) => { + const response = await page.request.get('https://localhost:5001/api/products'); + expect(response.status()).toBe(200); + }); + + test('can access orders API', async ({ page }) => { + const response = await page.request.get('https://localhost:5001/api/orders'); + expect(response.status()).toBe(200); + }); + + test('can access admin users page', async ({ page }) => { + await page.goto('/admin/users'); + await expect(page.getByRole('heading', { name: /users/i })).toBeVisible(); + }); + + test('can access admin roles page', async ({ page }) => { + await page.goto('/admin/roles'); + await expect(page.getByRole('heading', { name: /roles/i })).toBeVisible(); + }); + + test('can access products manage page', async ({ page }) => { + await page.goto('/products/manage'); + await expect(page.getByRole('heading', { name: /manage|products/i })).toBeVisible(); + }); + }); + + test.describe('unauthenticated user', () => { + // Clear auth state for these tests + test.use({ storageState: { cookies: [], origins: [] } }); + + test('products API rejects unauthenticated request', async ({ request }) => { + const response = await request.get('https://localhost:5001/api/products', { + maxRedirects: 0, + }); + // Identity cookie scheme returns 302 redirect to login for unauthenticated requests + expect(response.status()).toBe(302); + }); + + test('orders API rejects unauthenticated request', async ({ request }) => { + const response = await request.get('https://localhost:5001/api/orders', { + maxRedirects: 0, + }); + expect(response.status()).toBe(302); + }); + + test('can access public browse page', async ({ page }) => { + await page.goto('/products/browse'); + // Should NOT redirect to login — this page is AllowAnonymous + await expect(page.getByRole('heading', { name: /products/i })).toBeVisible(); + }); + + test('can access home page', async ({ page }) => { + await page.goto('/'); + // Home page is AllowAnonymous — should load without redirect + await expect(page.locator('body')).toBeVisible(); + // Should not be on login page + expect(page.url()).not.toContain('/Account/Login'); + }); + + test('protected page redirects to login', async ({ page }) => { + await page.goto('/products/manage'); + // Should redirect to login — verify we're not on the manage page + expect(page.url()).toContain('/Account/Login'); + }); + + test('admin page redirects to login', async ({ page }) => { + await page.goto('/admin/users'); + // Should redirect to login + expect(page.url()).toContain('/Account/Login'); + }); + }); +}); diff --git a/tests/e2e/tests/smoke/openiddict-clients.spec.ts b/tests/e2e/tests/smoke/openiddict-clients.spec.ts new file mode 100644 index 00000000..37374439 --- /dev/null +++ b/tests/e2e/tests/smoke/openiddict-clients.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from '../../fixtures/base'; +import { ClientsPage } from '../../pages/openiddict/clients.page'; + +test.describe('OpenIddict Clients', () => { + test('clients page loads', async ({ page }) => { + const clientsPage = new ClientsPage(page); + await clientsPage.goto(); + await expect(clientsPage.heading).toBeVisible(); + }); + + test('seeded client is visible', async ({ page }) => { + const clientsPage = new ClientsPage(page); + await clientsPage.goto(); + await expect(clientsPage.clientRow('simplemodule-client')).toBeVisible(); + }); + + test('create page loads', async ({ page }) => { + await page.goto('/openiddict/clients/create'); + await expect(page.getByRole('heading', { name: /create client/i })).toBeVisible(); + }); + + test('navbar has OAuth Clients link', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('link', { name: 'OAuth Clients' })).toBeVisible(); + }); +}); + +test.describe('OpenIddict Clients - unauthenticated', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('clients page redirects to login', async ({ page }) => { + await page.goto('/openiddict/clients'); + expect(page.url()).toContain('/Account/Login'); + }); +}); diff --git a/tools/download-tailwind.sh b/tools/download-tailwind.sh old mode 100644 new mode 100755