From 2626d2e88e8daa23f2829af7ebe5e9a4aab975dd Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sat, 11 Apr 2026 22:40:25 +0200 Subject: [PATCH 01/10] feat: add ForbiddenException with 403 mapping in GlobalExceptionHandler --- .../Constants/ErrorMessages.cs | 3 +++ .../Exceptions/ForbiddenException.cs | 15 +++++++++++++++ .../Exceptions/GlobalExceptionHandler.cs | 5 +++++ .../GlobalExceptionHandlerTests.cs | 19 +++++++++++++++++++ 4 files changed, 42 insertions(+) create mode 100644 framework/SimpleModule.Core/Exceptions/ForbiddenException.cs diff --git a/framework/SimpleModule.Core/Constants/ErrorMessages.cs b/framework/SimpleModule.Core/Constants/ErrorMessages.cs index d4cb5dc9..bbbee803 100644 --- a/framework/SimpleModule.Core/Constants/ErrorMessages.cs +++ b/framework/SimpleModule.Core/Constants/ErrorMessages.cs @@ -6,6 +6,7 @@ public static class ErrorMessages public const string ValidationErrorTitle = "Validation Error"; public const string NotFoundTitle = "Not Found"; public const string ConflictTitle = "Conflict"; + public const string ForbiddenTitle = "Forbidden"; public const string InternalServerErrorTitle = "Internal Server Error"; // Default exception messages @@ -13,4 +14,6 @@ public static class ErrorMessages public const string DefaultValidationMessage = "One or more validation errors occurred."; public const string DefaultNotFoundMessage = "The requested resource was not found."; public const string DefaultConflictMessage = "A conflict occurred."; + public const string DefaultForbiddenMessage = + "You do not have permission to access this resource."; } diff --git a/framework/SimpleModule.Core/Exceptions/ForbiddenException.cs b/framework/SimpleModule.Core/Exceptions/ForbiddenException.cs new file mode 100644 index 00000000..f8429045 --- /dev/null +++ b/framework/SimpleModule.Core/Exceptions/ForbiddenException.cs @@ -0,0 +1,15 @@ +using SimpleModule.Core.Constants; + +namespace SimpleModule.Core.Exceptions; + +public sealed class ForbiddenException : Exception +{ + public ForbiddenException() + : base(ErrorMessages.DefaultForbiddenMessage) { } + + public ForbiddenException(string message) + : base(message) { } + + public ForbiddenException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/framework/SimpleModule.Core/Exceptions/GlobalExceptionHandler.cs b/framework/SimpleModule.Core/Exceptions/GlobalExceptionHandler.cs index 10d2d060..e1ec1cef 100644 --- a/framework/SimpleModule.Core/Exceptions/GlobalExceptionHandler.cs +++ b/framework/SimpleModule.Core/Exceptions/GlobalExceptionHandler.cs @@ -29,6 +29,11 @@ CancellationToken cancellationToken ), NotFoundException => (StatusCodes.Status404NotFound, ErrorMessages.NotFoundTitle, null), ConflictException => (StatusCodes.Status409Conflict, ErrorMessages.ConflictTitle, null), + ForbiddenException => ( + StatusCodes.Status403Forbidden, + ErrorMessages.ForbiddenTitle, + null + ), _ => ( StatusCodes.Status500InternalServerError, ErrorMessages.InternalServerErrorTitle, diff --git a/tests/SimpleModule.Core.Tests/GlobalExceptionHandlerTests.cs b/tests/SimpleModule.Core.Tests/GlobalExceptionHandlerTests.cs index a5ee89e9..5a6f5dc3 100644 --- a/tests/SimpleModule.Core.Tests/GlobalExceptionHandlerTests.cs +++ b/tests/SimpleModule.Core.Tests/GlobalExceptionHandlerTests.cs @@ -128,6 +128,25 @@ public async Task NotFoundException_IncludesEntityInfoInDetail() .Be("User with ID abc-123 not found"); } + [Fact] + public async Task ForbiddenException_Returns403() + { + var context = CreateHttpContext(); + var exception = new ForbiddenException("Access denied to admin panel"); + + var handled = await _handler.TryHandleAsync(context, exception, CancellationToken.None); + + handled.Should().BeTrue(); + context.Response.StatusCode.Should().Be(403); + + var doc = await ReadResponseBodyAsync(context); + doc.RootElement.GetProperty("title").GetString().Should().Be("Forbidden"); + doc.RootElement.GetProperty("detail") + .GetString() + .Should() + .Be("Access denied to admin panel"); + } + [Fact] public async Task Handler_AlwaysReturnsTrue() { From bae8ed608adda7eb2936a7173260baff505b6c9b Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sat, 11 Apr 2026 22:43:09 +0200 Subject: [PATCH 02/10] feat: render Inertia error pages for browser requests in GlobalExceptionHandler --- .../Exceptions/GlobalExceptionHandler.cs | 50 +++++++++++++- .../GlobalExceptionHandlerTests.cs | 65 +++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/framework/SimpleModule.Core/Exceptions/GlobalExceptionHandler.cs b/framework/SimpleModule.Core/Exceptions/GlobalExceptionHandler.cs index e1ec1cef..5c887765 100644 --- a/framework/SimpleModule.Core/Exceptions/GlobalExceptionHandler.cs +++ b/framework/SimpleModule.Core/Exceptions/GlobalExceptionHandler.cs @@ -1,14 +1,21 @@ +using System.Text.Json; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using SimpleModule.Core.Constants; +using SimpleModule.Core.Inertia; namespace SimpleModule.Core.Exceptions; public sealed class GlobalExceptionHandler(ILogger logger) : IExceptionHandler { + private static readonly JsonSerializerOptions InertiaJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + public async ValueTask TryHandleAsync( HttpContext httpContext, Exception exception, @@ -28,12 +35,12 @@ CancellationToken cancellationToken null ), NotFoundException => (StatusCodes.Status404NotFound, ErrorMessages.NotFoundTitle, null), - ConflictException => (StatusCodes.Status409Conflict, ErrorMessages.ConflictTitle, null), ForbiddenException => ( StatusCodes.Status403Forbidden, ErrorMessages.ForbiddenTitle, null ), + ConflictException => (StatusCodes.Status409Conflict, ErrorMessages.ConflictTitle, null), _ => ( StatusCodes.Status500InternalServerError, ErrorMessages.InternalServerErrorTitle, @@ -59,6 +66,15 @@ CancellationToken cancellationToken ? ErrorMessages.UnexpectedError : exception.Message; + httpContext.Response.StatusCode = statusCode; + + // Inertia requests get an Inertia error page response + if (httpContext.Request.Headers.ContainsKey("X-Inertia")) + { + return await WriteInertiaErrorAsync(httpContext, statusCode, title, detail); + } + + // API/non-Inertia requests get ProblemDetails JSON var problemDetails = new ProblemDetails { Status = statusCode, @@ -71,8 +87,38 @@ CancellationToken cancellationToken problemDetails.Extensions["errors"] = errors; } - httpContext.Response.StatusCode = statusCode; await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); return true; } + + private static async ValueTask WriteInertiaErrorAsync( + HttpContext httpContext, + int statusCode, + string title, + string message + ) + { + var component = $"Error/{statusCode}"; + var props = new + { + status = statusCode, + title, + message, + }; + + var pageData = new + { + component, + props, + url = httpContext.Request.Path + httpContext.Request.QueryString, + version = InertiaMiddleware.Version, + }; + + httpContext.Response.Headers["X-Inertia"] = "true"; + httpContext.Response.Headers["Vary"] = "X-Inertia"; + httpContext.Response.ContentType = "application/json"; + var json = JsonSerializer.Serialize(pageData, InertiaJsonOptions); + await httpContext.Response.WriteAsync(json); + return true; + } } diff --git a/tests/SimpleModule.Core.Tests/GlobalExceptionHandlerTests.cs b/tests/SimpleModule.Core.Tests/GlobalExceptionHandlerTests.cs index 5a6f5dc3..d247c249 100644 --- a/tests/SimpleModule.Core.Tests/GlobalExceptionHandlerTests.cs +++ b/tests/SimpleModule.Core.Tests/GlobalExceptionHandlerTests.cs @@ -167,4 +167,69 @@ public async Task Handler_AlwaysReturnsTrue() result1.Should().BeTrue(); result2.Should().BeTrue(); } + + [Fact] + public async Task InertiaRequest_NotFoundException_ReturnsInertiaErrorPage() + { + var context = CreateHttpContext(); + context.Request.Headers["X-Inertia"] = "true"; + context.Request.Headers["X-Inertia-Version"] = "1"; + var exception = new NotFoundException("Product", 42); + + var handled = await _handler.TryHandleAsync(context, exception, CancellationToken.None); + + handled.Should().BeTrue(); + context.Response.StatusCode.Should().Be(404); + context.Response.Headers["X-Inertia"].ToString().Should().Be("true"); + context.Response.ContentType.Should().Contain("application/json"); + + var doc = await ReadResponseBodyAsync(context); + doc.RootElement.GetProperty("component").GetString().Should().Be("Error/404"); + doc.RootElement.GetProperty("props").GetProperty("status").GetInt32().Should().Be(404); + doc.RootElement.GetProperty("props") + .GetProperty("title") + .GetString() + .Should() + .Be("Not Found"); + } + + [Fact] + public async Task NonInertiaRequest_NotFoundException_ReturnsJsonProblemDetails() + { + var context = CreateHttpContext(); + var exception = new NotFoundException("Product", 42); + + var handled = await _handler.TryHandleAsync(context, exception, CancellationToken.None); + + handled.Should().BeTrue(); + context.Response.StatusCode.Should().Be(404); + + var doc = await ReadResponseBodyAsync(context); + doc.RootElement.GetProperty("title").GetString().Should().Be("Not Found"); + doc.RootElement.TryGetProperty("component", out _).Should().BeFalse(); + } + + [Fact] + public async Task InertiaRequest_UnhandledException_ReturnsInertia500_WithoutSensitiveDetails() + { + var context = CreateHttpContext(); + context.Request.Headers["X-Inertia"] = "true"; + context.Request.Headers["X-Inertia-Version"] = "1"; + var exception = new InvalidOperationException("Sensitive DB error"); + + var handled = await _handler.TryHandleAsync(context, exception, CancellationToken.None); + + handled.Should().BeTrue(); + context.Response.StatusCode.Should().Be(500); + + var doc = await ReadResponseBodyAsync(context); + doc.RootElement.GetProperty("component").GetString().Should().Be("Error/500"); + var props = doc.RootElement.GetProperty("props"); + props.GetProperty("message").GetString().Should().NotContain("Sensitive DB error"); + props + .GetProperty("message") + .GetString() + .Should() + .Be("An unexpected error occurred. Please try again later."); + } } From d041602a5f839ed3ff8ffe3330a91e823e7712c5 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sat, 11 Apr 2026 22:44:27 +0200 Subject: [PATCH 03/10] feat: add status code pages middleware and /error/{statusCode} endpoint --- .../SimpleModuleHostExtensions.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index 0802cc09..b194a5e5 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -149,6 +149,7 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app) app.UseForwardedHeaders(); app.UseExceptionHandler(); + app.UseStatusCodePagesWithReExecute("/error/{0}"); var options = app.Services.GetRequiredService(); if (options.EnableSwagger && app.Environment.IsDevelopment()) @@ -240,6 +241,37 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app) ) .AllowAnonymous(); } + + app.MapGet( + "/error/{statusCode:int}", + (int statusCode) => + { + var (title, message) = statusCode switch + { + 403 => ( + ErrorMessages.ForbiddenTitle, + ErrorMessages.DefaultForbiddenMessage + ), + 404 => (ErrorMessages.NotFoundTitle, ErrorMessages.DefaultNotFoundMessage), + _ => ( + ErrorMessages.InternalServerErrorTitle, + ErrorMessages.UnexpectedError + ), + }; + + return SimpleModule.Core.Inertia.Inertia.Render( + $"Error/{statusCode}", + new + { + status = statusCode, + title, + message, + } + ); + } + ) + .AllowAnonymous() + .ExcludeFromDescription(); } private static void BridgeAspireConnectionString(ConfigurationManager configuration) From c417859832022f361fb199fc6dcb495464ff0c60 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sat, 11 Apr 2026 22:46:23 +0200 Subject: [PATCH 04/10] feat: add React error page components (404, 500, 403) in @simplemodule/ui --- .../components/errors/error-page-403.tsx | 33 +++++++++++++++ .../components/errors/error-page-404.tsx | 33 +++++++++++++++ .../components/errors/error-page-500.tsx | 33 +++++++++++++++ .../components/errors/error-page-layout.tsx | 42 +++++++++++++++++++ .../components/errors/index.ts | 4 ++ packages/SimpleModule.UI/package.json | 3 +- 6 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 packages/SimpleModule.UI/components/errors/error-page-403.tsx create mode 100644 packages/SimpleModule.UI/components/errors/error-page-404.tsx create mode 100644 packages/SimpleModule.UI/components/errors/error-page-500.tsx create mode 100644 packages/SimpleModule.UI/components/errors/error-page-layout.tsx create mode 100644 packages/SimpleModule.UI/components/errors/index.ts diff --git a/packages/SimpleModule.UI/components/errors/error-page-403.tsx b/packages/SimpleModule.UI/components/errors/error-page-403.tsx new file mode 100644 index 00000000..145bd771 --- /dev/null +++ b/packages/SimpleModule.UI/components/errors/error-page-403.tsx @@ -0,0 +1,33 @@ +import { ErrorPageLayout } from './error-page-layout'; + +interface Props { + status?: number; + title?: string; + message?: string; +} + +export default function ErrorPage403({ message }: Props) { + return ( +