- Introduction & Philosophy
- Prerequisites
- Architecture Overview
- Project Setup
- The .NET Core (Built-in DI & Services)
- Domain & Abstraction Layer
- Infrastructure & Security
- White-Labeling & UI System
- Feature Implementation (The ERP) 9.1 Account Controller Auth Flow
- State Management (Reactive Services)
- Containerization (Podman/Docker)
- Running the Application
- References & Documentation
- Appendix: Unit Testing
- Enterprise CI/CD Pipeline (GitHub Actions)
- Enterprise Privacy & Crawler Shield
- Hosting on Azure App Service / Fly.io
- How to Debug
- Typography: Libre Franklin
- ASP.NET Core Web API Gateway (DummyJSON Wrapper)
This guide builds a lightweight, high-performance ERP Dashboard using Blazor Static Server-Side Rendering (SSR) in .NET 9. The stack is: Blazor SSR with Enhanced Navigation, MudBlazor for UI components, QuickGrid for data grids, the built-in <Virtualize> component for large lists, EditForm for type-safe form management, and DummyJSON as the API backend.
This is a genuine .NET-native implementation — not a JavaScript project in disguise. Every tool is a first-class citizen of the ASP.NET Core ecosystem.
Important
Why Blazor Static SSR over Blazor Server or WASM?
Blazor Static SSR renders pages on the server and delivers fully-formed HTML on first load — giving you superior SEO, faster Time-to-First-Byte, and no JavaScript bundle for initial paint. Enhanced Navigation (.NET 8+) then intercepts subsequent link clicks and performs lightweight DOM-diffing updates, giving users the feel of a SPA without the SPA runtime. For customer-facing ERP portals that need to be discoverable and fast on every device, Static SSR is the correct default. Opt into @rendermode InteractiveServer selectively, only for components that need real-time interactivity (e.g., a live dashboard chart), and keep everything else static.
- Strictly Open Source: No Azure-paid tiers required — self-hostable on any OCI-compatible runtime.
- Nullable Reference Types enforced:
<Nullable>enable</Nullable>and<TreatWarningsAsErrors>true</TreatWarningsAsErrors>throughout. - Built-in Dependency Injection: .NET's first-party
IServiceCollection/IServiceProvider— no custom container needed. - Clean Architecture: Separation of concerns into
Core,Infrastructure, andPresentation(Components) layers. - SEO-First: Every page ships with
<PageTitle>,<HeadContent>, structured meta tags, and arobots.txt— all rendered server-side.
- .NET 9 SDK (download from dot.net)
- Podman (or Docker Desktop)
- Visual Studio 2022 17.8+ or VS Code with the C# Dev Kit extension
- Git
Verify your environment:
dotnet --version
# 9.0.x
podman -v
# podman version 5.x.xWe enforce a strict separation of concerns following Clean Architecture. Blazor pages live in the Presentation layer and call into Core abstractions; the Infrastructure layer provides concrete implementations.
ErpPortal/
├── Components/ # Presentation layer (Blazor pages & components)
│ ├── Pages/ # @page routed components (the "View")
│ │ ├── Dashboard.razor
│ │ ├── Login.razor
│ │ ├── Users/
│ │ │ └── Index.razor
│ │ └── Tasks/
│ │ └── Index.razor
│ ├── Layout/ # App shell, nav menu, error boundaries
│ │ ├── MainLayout.razor
│ │ └── NavMenu.razor
│ └── App.razor # Router root — Enhanced Navigation configured here
├── Core/ # Pure business logic (no Blazor, no HTTP)
│ ├── Contracts/ # Interfaces (IRepository, IAuthService, etc.)
│ ├── Domain/ # C# records (User, Todo)
│ ├── Exceptions/ # AppException
│ └── Extensions/ # Extension methods / utilities
├── Infrastructure/ # External communications
│ ├── Http/ # Typed HttpClient + DelegatingHandlers
│ ├── Repositories/ # Concrete IRepository<T> implementations
│ └── Services/ # AuthService, LayoutService, WhitelabelService
├── wwwroot/ # Static assets
│ ├── css/app.css
│ └── robots.txt
├── appsettings.json # Configuration (replaces .env)
├── appsettings.Development.json
└── Program.cs # DI composition root + middleware pipeline
.NET Library Responsibilities:
| Library / Feature | Role |
|---|---|
Blazor @page routing |
File-based routing, route parameters, [Authorize] guards |
| Enhanced Navigation | SPA-like DOM-diff navigation without a client-side router |
IMemoryCache + IOutputCacheStore |
Server-state caching (replaces TanStack Query's stale time) |
EditForm + DataAnnotationsValidator |
Type-safe form management, server-side validation |
QuickGrid<T> |
Headless server-side data grid: sorting, filtering, pagination |
<Virtualize<T>> |
Windowed rendering for large lists (exact equivalent of Virtualize in Blazor) |
Initialize a Blazor Web App targeting Static SSR with no interactivity by default.
dotnet new blazor -n ErpPortal --interactivity None --empty
cd ErpPortalNote
--interactivity None
This creates a pure Static SSR project. Interactivity is opt-in per component via @rendermode InteractiveServer. This is the correct default for a customer-facing ERP where SEO and TTFB matter most.
# UI component library (Mantine equivalent)
dotnet add package MudBlazor
# Data grid (TanStack Table equivalent — ships with .NET 8+)
dotnet add package Microsoft.AspNetCore.Components.QuickGrid
# HTTP client JSON helpers
dotnet add package Microsoft.Extensions.Http
# Fluent validation (optional, for complex forms)
dotnet add package FluentValidation.DependencyInjectionExtensions
# Output caching middleware
# (included in Microsoft.AspNetCore.OutputCaching — no extra package needed for .NET 9)
# Unit testing (see Appendix)
dotnet add package xunit --project ErpPortal.Tests
dotnet add package Moq --project ErpPortal.Tests
dotnet add package FluentAssertions --project ErpPortal.TestsReplace the default contents with a strict, production-grade configuration:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Fail the build on any warning — the "No any" equivalent for C# -->
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsAsErrors />
<RootNamespace>ErpPortal</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MudBlazor" Version="7.*" />
<PackageReference Include="Microsoft.AspNetCore.Components.QuickGrid" Version="9.*" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.*" />
</ItemGroup>
</Project>Tip
Build Strategy: Treating Warnings as Errors
The equivalent of TypeScript's strict: true in .NET is <Nullable>enable</Nullable> combined with <TreatWarningsAsErrors>true</TreatWarningsAsErrors>. This prevents nullable dereferences, unused variables, and unhandled cases from silently shipping to production.
Replaces .env files. Values are validated at startup via IOptions binding with ValidateDataAnnotations().
{
"ApiSettings": {
"BaseUrl": "https://localhost:5002/api"
},
"Branding": {
"CompanyName": "Acme Corp ERP",
"LogoUrl": "https://dummyjson.com/icon/acme/128",
"PrimaryColor": "#0052cc",
"SecondaryColor": "#172b4d",
"AccentColor": "#ffab00"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}Create appsettings.Development.json for local overrides (equivalent to .env.local):
{
"ApiSettings": {
"BaseUrl": "https://localhost:5002/api"
},
"Logging": {
"LogLevel": {
"Default": "Debug"
}
}
}Caution
Environment-Specific Secrets
Never commit API keys or tokens to appsettings.json. Use dotnet user-secrets locally and GitHub Secrets / environment variables in CI. ASP.NET Core automatically reads ASPNETCORE_ApiSettings__BaseUrl (double underscore as separator) from environment variables, overriding appsettings.json.
# Local development secrets (stored outside the project directory)
dotnet user-secrets init
dotnet user-secrets set "ApiSettings:BaseUrl" "https://your-api.example.com"To strictly enforce that the API Gateway is running before the Blazor App starts, use a shared .vscode setup with a sequenced task pipeline.
{
"version": "2.0.0",
"tasks": [
{ "label": "build-api", "command": "dotnet", "type": "process", "args": ["build", "${workspaceFolder}/ErpPortal.Api/ErpPortal.Api.csproj"] },
{ "label": "build-app", "command": "dotnet", "type": "process", "args": ["build", "${workspaceFolder}/ErpPortal/ErpPortal.csproj"] },
{
"label": "ready-frontend",
"dependsOrder": "sequence",
"dependsOn": ["build-app", "wait-for-api"]
},
{
"label": "wait-for-api",
"type": "shell",
"command": "${workspaceFolder}/.vscode/wait-for-port.sh",
"args": ["localhost", "5002"]
}
]
}{
"version": "0.2.0",
"configurations": [
{
"name": "Backend: API Gateway",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/ErpPortal.Api/ErpPortal.Api.csproj",
"preLaunchTask": "build-api",
"env": { "ASPNETCORE_URLS": "http://localhost:5002" }
},
{
"name": "Frontend: Blazor Portal",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/ErpPortal/ErpPortal.csproj",
"preLaunchTask": "ready-frontend",
"env": { "ASPNETCORE_URLS": "http://localhost:5000" }
}
],
"compounds": [
{
"name": "Full ERP Solution",
"configurations": ["Backend: API Gateway", "Frontend: Blazor Portal"],
"stopAll": true
}
]
}Unlike the JavaScript implementation which required a hand-rolled Service Locator, .NET ships a production-grade IoC container. Services are registered in Program.cs and injected via @inject in Blazor components — exactly the same mental model as Blazor's @inject directive has always promised.
Validates configuration at startup — causing a fast crash if required values are missing, equivalent to the Zod validation in the original.
// Core/Config/ApiSettings.cs
using System.ComponentModel.DataAnnotations;
namespace ErpPortal.Core.Config;
public sealed class ApiSettings
{
public const string SectionName = "ApiSettings";
[Required, Url]
public string BaseUrl { get; init; } = string.Empty;
}Register with eager validation in Program.cs:
builder.Services
.AddOptions<ApiSettings>()
.BindConfiguration(ApiSettings.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart(); // Fast crash if config is invalid — equivalent to Zod's safeParse throwDefine WHAT the system does, not HOW. C# record types replace Zod schemas as the single source of truth for domain entities. They are immutable, structurally comparable, and serialization-friendly.
using System.Text.Json.Serialization;
namespace ErpPortal.Core.Domain;
/// <summary>
/// Immutable domain entity. Replaces the Zod-inferred TypeScript type.
/// Records give value-equality semantics for free.
/// </summary>
public sealed record User(
int Id,
string Username,
string Email,
string FirstName,
string LastName,
string Image,
// DummyJSON returns the token as "accessToken" — JsonPropertyName maps it to this property
[property: JsonPropertyName("accessToken")] string? Token = null
);using System.Text.Json.Serialization;
namespace ErpPortal.Core.Domain;
public sealed record Todo(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("todo")] string TodoText,
[property: JsonPropertyName("completed")] bool Completed,
[property: JsonPropertyName("userId")] int UserId
);Note
Use Microsoft.Extensions.Logging.ILogger<T>
Do not define a custom ILogger interface. .NET ships ILogger<T> which is generic, structured, and supports multiple sinks (Console, Application Insights, Sentry) without any business logic changes. Simply inject ILogger<MyService> and swap sinks via appsettings.json configuration.
// This interface is provided by the framework — no custom definition needed.
// Inject as: ILogger<MyService> _logger
// Log structured data: _logger.LogInformation("Login attempt for {Username}", username);namespace ErpPortal.Core.Exceptions;
public sealed class AppException : Exception
{
public string Code { get; }
public int StatusCode { get; }
public object? Context { get; }
public AppException(
string message,
string code = "UNKNOWN_ERROR",
int statusCode = 500,
object? context = null)
: base(message)
{
Code = code;
StatusCode = statusCode;
Context = context;
}
}namespace ErpPortal.Core.Contracts;
public interface IErpHttpClient
{
Task<T> GetAsync<T>(string url, CancellationToken ct = default) where T : class;
Task<T> PostAsync<T>(string url, object data, CancellationToken ct = default) where T : class;
Task<T> PutAsync<T>(string url, object data, CancellationToken ct = default) where T : class;
Task DeleteAsync(string url, CancellationToken ct = default);
void SetAuthToken(string? token);
}using ErpPortal.Core.Domain;
namespace ErpPortal.Core.Contracts;
public interface IAuthService
{
Task<User> LoginAsync(string username, string password, CancellationToken ct = default);
Task LogoutAsync();
Task<User?> GetCurrentUserAsync();
Task<bool> IsAuthenticatedAsync();
}namespace ErpPortal.Core.Contracts;
public interface IRepository<T> where T : class
{
Task<(IReadOnlyList<T> Data, int Total)> GetAllAsync(int skip = 0, int limit = 50, CancellationToken ct = default);
Task<T?> GetByIdAsync(int id, CancellationToken ct = default);
Task<T> CreateAsync(T entity, CancellationToken ct = default);
Task<T> UpdateAsync(int id, T entity, CancellationToken ct = default);
Task DeleteAsync(int id, CancellationToken ct = default);
}namespace ErpPortal.Core.Contracts;
public interface INotificationService
{
void ShowSuccess(string title, string message);
void ShowError(string title, string message);
void ShowInfo(string title, string message);
}Tip
Architectural Pattern: Data Access Abstraction
The Repository pattern decouples business logic from the HTTP client. If you switch from HttpClient to a gRPC client, or REST to GraphQL, only the Infrastructure layer changes. Your domain logic and Blazor pages remain untouched.
Authentication in Blazor SSR uses cookie-based sessions managed by ASP.NET Core — this is more secure than the SPA approach of storing tokens in localStorage, since HTTP-only cookies are inaccessible to JavaScript.
A DelegatingHandler that automatically injects the Bearer token into outgoing requests — the .NET equivalent of the Axios request interceptor.
using System.Net.Http.Headers;
namespace ErpPortal.Infrastructure.Http;
/// <summary>
/// Equivalent of the Axios request interceptor that injects "Authorization: Bearer ..."
/// Registered as a transient DelegatingHandler in Program.cs.
/// </summary>
/// <summary>
/// Historically injected "Authorization: Bearer ..." into outgoing requests.
/// Since the API Gateway (ErpPortal.Api) now manages the DummyJSON JWT lifecycle,
/// this handler is preserved as a no-op pass-through to maintain architecture
/// consistency, or can be used for Blazor-to-API-Gateway authentication.
/// </summary>
public sealed class AuthTokenHandler : DelegatingHandler
{
/*
private readonly IHttpContextAccessor _httpContextAccessor;
public AuthTokenHandler(IHttpContextAccessor httpContextAccessor)
=> _httpContextAccessor = httpContextAccessor;
*/
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
/*
string? token = _httpContextAccessor.HttpContext?.User.FindFirst("Token")?.Value;
if (!string.IsNullOrEmpty(token))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
*/
return await base.SendAsync(request, cancellationToken);
}
}Intercepts HTTP errors and maps them to AppException, then re-throws for the caller to handle. INotificationService is intentionally not injected here — ISnackbar (the MudBlazor implementation) depends on NavigationManager, which is not initialized during Static SSR when the HTTP handler pipeline is first constructed. Pages catch AppException and call INotificationService directly.
using ErpPortal.Core.Exceptions;
using Microsoft.Extensions.Logging;
namespace ErpPortal.Infrastructure.Http;
public sealed class ErrorHandlingHandler : DelegatingHandler
{
private readonly ILogger<ErrorHandlingHandler> _logger;
public ErrorHandlingHandler(ILogger<ErrorHandlingHandler> logger)
{
_logger = logger;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
HttpResponseMessage response;
try
{
response = await base.SendAsync(request, cancellationToken);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "[API Error] Network failure calling {Url}", request.RequestUri);
throw new AppException(ex.Message, "NETWORK_ERROR", 0);
}
if (!response.IsSuccessStatusCode)
{
int status = (int)response.StatusCode;
AppException appError = status switch
{
401 => new AppException("Unauthorized", "AUTH_401", 401),
500 => new AppException("Server Error", "SERVER_500", 500),
_ => new AppException($"HTTP {status}", $"HTTP_{status}", status),
};
_logger.LogError("[API Error] {Code} — {Url}", appError.Code, request.RequestUri);
throw appError;
}
return response;
}
}using System.Net.Http.Json;
using System.Text.Json;
using ErpPortal.Core.Contracts;
namespace ErpPortal.Infrastructure.Http;
public sealed class ErpHttpClient : IErpHttpClient
{
private readonly HttpClient _http;
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public ErpHttpClient(HttpClient http) => _http = http;
public async Task<T> GetAsync<T>(string url, CancellationToken ct = default) where T : class
{
T? result = await _http.GetFromJsonAsync<T>(url, _jsonOptions, ct);
return result ?? throw new InvalidOperationException($"Null response from GET {url}");
}
public async Task<T> PostAsync<T>(string url, object data, CancellationToken ct = default) where T : class
{
HttpResponseMessage response = await _http.PostAsJsonAsync(url, data, _jsonOptions, ct);
response.EnsureSuccessStatusCode();
T? result = await response.Content.ReadFromJsonAsync<T>(_jsonOptions, ct);
return result ?? throw new InvalidOperationException($"Null response from POST {url}");
}
public async Task<T> PutAsync<T>(string url, object data, CancellationToken ct = default) where T : class
{
HttpResponseMessage response = await _http.PutAsJsonAsync(url, data, _jsonOptions, ct);
response.EnsureSuccessStatusCode();
T? result = await response.Content.ReadFromJsonAsync<T>(_jsonOptions, ct);
return result ?? throw new InvalidOperationException($"Null response from PUT {url}");
}
public async Task DeleteAsync(string url, CancellationToken ct = default)
{
HttpResponseMessage response = await _http.DeleteAsync(url, ct);
response.EnsureSuccessStatusCode();
}
// In Blazor SSR the token is injected by AuthTokenHandler — this is a no-op here
// but satisfies the interface for testability.
public void SetAuthToken(string? token) { }
}Server-side cookie authentication — HTTP-only cookies replace the localStorage approach from the SPA version. This is more secure and eliminates token rehydration edge cases.
Note
In the current implementation, the primary sign-in/sign-out flow is the controller POST path (/account/login, /account/logout) from Login.razor.
AuthService remains useful for programmatic auth operations, user-session helpers (GetCurrentUserAsync, IsAuthenticatedAsync), and integration scenarios outside the form-post login route.
using System.Security.Claims;
using ErpPortal.Core.Contracts;
using ErpPortal.Core.Domain;
using ErpPortal.Core.Exceptions;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.Logging;
namespace ErpPortal.Infrastructure.Services;
public sealed class AuthService : IAuthService
{
private readonly IErpHttpClient _http;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly INotificationService _notifier;
private readonly ILogger<AuthService> _logger;
public AuthService(
IErpHttpClient http,
IHttpContextAccessor httpContextAccessor,
INotificationService notifier,
ILogger<AuthService> logger)
{
_http = http;
_httpContextAccessor = httpContextAccessor;
_notifier = notifier;
_logger = logger;
}
public async Task<User> LoginAsync(string username, string password, CancellationToken ct = default)
{
_logger.LogInformation("Login attempt for {Username}", username);
try
{
User user = await _http.PostAsync<User>("/auth/login",
new { username, password }, ct);
List<Claim> claims =
[
new(ClaimTypes.Name, user.Username),
new(ClaimTypes.Email, user.Email),
new("FirstName", user.FirstName),
new("LastName", user.LastName),
new("Token", user.Token ?? string.Empty),
];
ClaimsIdentity identity = new(claims, CookieAuthenticationDefaults.AuthenticationScheme);
ClaimsPrincipal principal = new(identity);
await _httpContextAccessor.HttpContext!.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
new AuthenticationProperties
{
IsPersistent = true,
ExpiresUtc = DateTimeOffset.UtcNow.AddHours(1),
});
_notifier.ShowSuccess("Welcome", $"Logged in as {user.FirstName} {user.LastName}");
_logger.LogInformation("Login successful for {Username}", username);
return user;
}
catch (AppException ex)
{
string message = ex.StatusCode switch
{
401 => "Invalid username or password.",
403 => "Login request was blocked by the upstream service.",
0 => "Could not reach the authentication service.",
_ => $"Login failed ({ex.Code}).",
};
_notifier.ShowError("Login Failed", message);
_logger.LogWarning("Login failed for {Username}. Code: {Code}, Status: {Status}", username, ex.Code, ex.StatusCode);
throw;
}
catch (Exception ex)
{
_notifier.ShowError("Login Failed", "An unexpected error occurred during login.");
_logger.LogError(ex, "Unexpected error during login for {Username}", username);
throw;
}
}
public async Task LogoutAsync()
{
await _httpContextAccessor.HttpContext!.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
_notifier.ShowInfo("Logged Out", "You have been safely signed out.");
_logger.LogInformation("User logged out");
}
public Task<User?> GetCurrentUserAsync()
{
HttpContext? ctx = _httpContextAccessor.HttpContext;
if (ctx?.User.Identity?.IsAuthenticated is not true) return Task.FromResult<User?>(null);
User user = new User(
Id: 0,
Username: ctx.User.Identity.Name ?? string.Empty,
Email: ctx.User.FindFirst(ClaimTypes.Email)?.Value ?? string.Empty,
FirstName: ctx.User.FindFirst("FirstName")?.Value ?? string.Empty,
LastName: ctx.User.FindFirst("LastName")?.Value ?? string.Empty,
Image: string.Empty,
Token: ctx.User.FindFirst("Token")?.Value);
return Task.FromResult<User?>(user);
}
public Task<bool> IsAuthenticatedAsync()
=> Task.FromResult(_httpContextAccessor.HttpContext?.User.Identity?.IsAuthenticated is true);
}Note
Use the Built-in Logger
.NET's ILogger<T> writes structured JSON to the console in production automatically when the Console formatter is set to json. There is no ConsoleLogger.cs to write — simply inject ILogger<MyService> and configure the formatter in appsettings.json. To swap to Sentry or Datadog, add the sink package and configure it in Program.cs — no business logic changes required.
// appsettings.Production.json — structured JSON logging
{
"Logging": {
"Console": {
"FormatterName": "json"
}
}
}using ErpPortal.Core.Contracts;
using ErpPortal.Core.Domain;
using ErpPortal.Core.Exceptions;
using Microsoft.Extensions.Logging;
namespace ErpPortal.Infrastructure.Repositories;
// Internal DTO for deserializing the paginated API response
internal sealed record UsersApiResponse(List<User> Users, int Total);
public sealed class UserRepository : IRepository<User>
{
private readonly IErpHttpClient _http;
private readonly ILogger<UserRepository> _logger;
private readonly INotificationService _notifier;
public UserRepository(IErpHttpClient http, ILogger<UserRepository> logger, INotificationService notifier)
{
_http = http;
_logger = logger;
_notifier = notifier;
}
public async Task<(IReadOnlyList<User> Data, int Total)> GetAllAsync(
int skip = 0, int limit = 50, CancellationToken ct = default)
{
try
{
// Now calling the API Gateway's /products proxy instead of direct /users
UsersApiResponse response = await _http.GetAsync<UsersApiResponse>($"/products?limit={limit}&skip={skip}", ct);
return (response.Users, response.Total);
}
catch (Exception e)
{
_logger.LogCritical(e, "CRITICAL: User Fetch — {Message}", e.Message);
throw;
}
}
public async Task<User?> GetByIdAsync(int id, CancellationToken ct = default)
{
try { return await _http.GetAsync<User>($"/products/{id}", ct); }
catch (Exception e) { _logger.LogError(e, "Failed to fetch user {Id}", id); throw; }
}
public async Task<User> CreateAsync(User entity, CancellationToken ct = default)
{
try
{
User user = await _http.PostAsync<User>("/users/add", entity, ct);
_notifier.ShowSuccess("User Created", $"{user.FirstName} has been added.");
return user;
}
catch (Exception e) { _logger.LogError(e, "Failed to create user"); throw; }
}
public async Task<User> UpdateAsync(int id, User entity, CancellationToken ct = default)
{
try
{
User user = await _http.PutAsync<User>($"/users/{id}", entity, ct);
_notifier.ShowSuccess("User Updated", $"Profile for {user.FirstName} saved.");
return user;
}
catch (Exception e) { _logger.LogError(e, "Failed to update user {Id}", id); throw; }
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
try
{
await _http.DeleteAsync($"/users/{id}", ct);
_notifier.ShowSuccess("User Deleted", "The record has been permanently removed.");
}
catch (Exception e) { _logger.LogError(e, "Failed to delete user {Id}", id); throw; }
}
}using ErpPortal.Core.Contracts;
using ErpPortal.Core.Domain;
using Microsoft.Extensions.Logging;
namespace ErpPortal.Infrastructure.Repositories;
internal sealed record TodosApiResponse(List<Todo> Todos, int Total);
public sealed class TodoRepository : IRepository<Todo>
{
private readonly IErpHttpClient _http;
private readonly ILogger<TodoRepository> _logger;
private readonly INotificationService _notifier;
public TodoRepository(IErpHttpClient http, ILogger<TodoRepository> logger, INotificationService notifier)
{
_http = http; _logger = logger; _notifier = notifier;
}
public async Task<(IReadOnlyList<Todo> Data, int Total)> GetAllAsync(
int skip = 0, int limit = 150, CancellationToken ct = default)
{
try
{
// Now calling the API Gateway's /todos proxy
TodosApiResponse response = await _http.GetAsync<TodosApiResponse>($"/todos?limit={limit}&skip={skip}", ct);
return (response.Todos, response.Total);
}
catch (Exception e) { _logger.LogError(e, "Failed to fetch todos"); throw; }
}
public async Task<Todo?> GetByIdAsync(int id, CancellationToken ct = default)
=> await _http.GetAsync<Todo>($"/todos/{id}", ct);
public async Task<Todo> CreateAsync(Todo entity, CancellationToken ct = default)
{
Todo todo = await _http.PostAsync<Todo>("/todos/add", entity, ct);
_notifier.ShowSuccess("Task Created", "New task added.");
return todo;
}
public async Task<Todo> UpdateAsync(int id, Todo entity, CancellationToken ct = default)
{
Todo todo = await _http.PutAsync<Todo>($"/todos/{id}", entity, ct);
_notifier.ShowSuccess("Task Updated", "Changes saved.");
return todo;
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
await _http.DeleteAsync($"/todos/{id}", ct);
_notifier.ShowSuccess("Task Deleted", "The task has been removed.");
}
}using System.ComponentModel.DataAnnotations;
namespace ErpPortal.Core.Config;
public sealed class BrandingConfig
{
public const string SectionName = "Branding";
[Required] public string CompanyName { get; init; } = "Enterprise ERP";
[Required, Url] public string LogoUrl { get; init; } = string.Empty;
public string PrimaryColor { get; init; } = "#0052cc";
public string SecondaryColor { get; init; } = "#172b4d";
public string AccentColor { get; init; } = "#ffab00";
}Replaces the BaseObservable<LayoutState> reactive service. In Blazor, components subscribe to the service's OnChange event and call StateHasChanged() — the same observable pattern, using idiomatic .NET events.
namespace ErpPortal.Infrastructure.Services;
/// <summary>
/// Scoped service managing transient UI state.
/// Equivalent of the TypeScript BaseObservable<LayoutState> reactive service.
/// Components subscribe to OnChange and call StateHasChanged() — Blazor's useSyncExternalStore.
/// </summary>
public sealed class LayoutService
{
// MudBlazor v9: @bind-Open on MudDrawer requires a public setter.
// A backing field is used so the setter can also fire OnChange (CS0272 fix).
private bool _isSidebarOpen;
public bool IsSidebarOpen
{
get => _isSidebarOpen;
set
{
if (_isSidebarOpen == value) return;
_isSidebarOpen = value;
NotifyStateChanged();
}
}
// The Action-based event replaces the Set<Listener> in BaseObservable
public event Action? OnChange;
public void ToggleSidebar()
{
IsSidebarOpen = !IsSidebarOpen;
}
public void CloseSidebar()
{
IsSidebarOpen = false;
}
private void NotifyStateChanged() => OnChange?.Invoke();
}using ErpPortal.Core.Contracts;
using MudBlazor;
namespace ErpPortal.Infrastructure.Services;
/// <summary>
/// MudBlazor implementation of INotificationService.
/// Equivalent of MantineNotificationService — swap out by registering a different
/// implementation in Program.cs without touching any business logic.
/// </summary>
public sealed class MudBlazorNotificationService : INotificationService
{
private readonly ISnackbar _snackbar;
public MudBlazorNotificationService(ISnackbar snackbar) => _snackbar = snackbar;
public void ShowSuccess(string title, string message)
=> _snackbar.Add($"{title}: {message}", Severity.Success);
public void ShowError(string title, string message)
=> _snackbar.Add($"{title}: {message}", Severity.Error);
public void ShowInfo(string title, string message)
=> _snackbar.Add($"{title}: {message}", Severity.Info);
}MudBlazor's MudThemeProvider is the direct equivalent of the Mantine createTheme + MantineProvider wrapper. CSS custom properties are synchronized at the root for third-party component compatibility.
@using Microsoft.Extensions.Options
@using MudBlazor.Utilities
@inject IOptions<BrandingConfig> BrandingOptions
@* Equivalent of EnterpriseThemeProvider.tsx — sets the MudBlazor theme and
synchronises CSS variables at the document root for third-party component compatibility. *@
<MudThemeProvider Theme="_theme" />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<HeadContent>
<style>
:root {
--brand-primary: @_branding.PrimaryColor;
--brand-secondary: @_branding.SecondaryColor;
--brand-accent: @_branding.AccentColor;
}
</style>
</HeadContent>
@code {
private BrandingConfig _branding = default!;
private MudTheme _theme = default!;
protected override void OnInitialized()
{
_branding = BrandingOptions.Value;
_theme = new MudTheme
{
PaletteLight = new PaletteLight
{
// MudBlazor v9: MudColor lives in MudBlazor.Utilities — @using above is required (CS0246 fix)
Primary = new MudColor(_branding.PrimaryColor),
Secondary = new MudColor(_branding.SecondaryColor),
AppbarBackground = new MudColor(_branding.PrimaryColor),
DrawerBackground = new MudColor(_branding.SecondaryColor),
DrawerText = Colors.Shades.White,
},
// MudBlazor v9: Typography slot types (Default, H6, etc.) are short class names that collide
// with C# identifiers under TreatWarningsAsErrors=true, causing CS0246.
// The font is enforced via the CSS universal selector in wwwroot/app.css instead — see §19.
};
}
}Tip
White-Labeling Strategy: Dynamic Styling Root
By injecting CSS custom properties into <head> via <HeadContent>, even third-party components and legacy stylesheets automatically inherit the brand colours. Since this renders server-side, there is no flash of unstyled content (FOUC).
using ErpPortal.Core.Config;
using ErpPortal.Core.Contracts;
using ErpPortal.Core.Domain;
using ErpPortal.Components;
using ErpPortal.Infrastructure.Http;
using ErpPortal.Infrastructure.Repositories;
using ErpPortal.Infrastructure.Services;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.Options;
using MudBlazor.Services;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// ─── Options & Configuration Validation (replaces Zod envSchema) ─────────────
builder.Services
.AddOptions<ApiSettings>()
.BindConfiguration(ApiSettings.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services
.AddOptions<BrandingConfig>()
.BindConfiguration(BrandingConfig.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
// ─── Authentication (HTTP-only cookies — more secure than SPA localStorage) ───
builder.Services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/login";
options.LogoutPath = "/logout";
options.ExpireTimeSpan = TimeSpan.FromHours(1);
options.SlidingExpiration = true;
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Cookie.SameSite = SameSiteMode.Strict;
});
builder.Services.AddAuthorization();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddControllersWithViews(); // Required for [ValidateAntiForgeryToken] filters
// ─── Output Caching (replaces TanStack Query's staleTime) ────────────────────
builder.Services.AddOutputCache(options =>
{
options.AddBasePolicy(policy => policy.Expire(TimeSpan.FromMinutes(5)));
options.AddPolicy("UsersList", policy =>
policy.Expire(TimeSpan.FromMinutes(5)).Tag("users"));
options.AddPolicy("TodosList", policy =>
policy.Expire(TimeSpan.FromMinutes(5)).Tag("todos"));
});
// ─── HTTP Client (Typed Client with DelegatingHandlers = Axios interceptors) ──
builder.Services.AddHttpContextAccessor();
builder.Services.AddTransient<AuthTokenHandler>();
builder.Services.AddTransient<ErrorHandlingHandler>();
builder.Services
.AddHttpClient<IErpHttpClient, ErpHttpClient>((sp, client) =>
{
ApiSettings settings = sp.GetRequiredService<IOptions<ApiSettings>>().Value;
client.BaseAddress = new Uri(settings.BaseUrl);
client.DefaultRequestHeaders.Add("Accept", "application/json");
})
.AddHttpMessageHandler<AuthTokenHandler>()
.AddHttpMessageHandler<ErrorHandlingHandler>();
// ─── Application Services (Scoped = per HTTP request, same as React's request scope) ─
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<INotificationService, MudBlazorNotificationService>();
builder.Services.AddScoped<IRepository<User>, UserRepository>();
builder.Services.AddScoped<IRepository<Todo>, TodoRepository>();
builder.Services.AddScoped<LayoutService>();
// ─── UI ───────────────────────────────────────────────────────────────────────
builder.Services.AddMudServices();
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
// ─── Build ────────────────────────────────────────────────────────────────────
WebApplication app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseStatusCodePagesWithReExecute("/error/{0}");
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseOutputCache();
app.UseAntiforgery();
app.MapControllers();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();Enhanced Navigation is configured here. It intercepts link clicks on <a> tags, fetches the new page as an HTML fragment, and patches only the changed DOM nodes — giving users SPA-like transitions with zero client-side router overhead.
<!DOCTYPE html>
<html lang="en">
@inject IOptions<BrandingConfig> BrandingOptions
@using Microsoft.AspNetCore.Components
@using static Microsoft.AspNetCore.Components.Web.RenderMode
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
<meta name="googlebot" content="noindex, nofollow" />
<base href="/" />
<link href="https://fonts.googleapis.com/css2?family=Libre+Franklin:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet" />
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<link rel="stylesheet" href="@Assets[\"app.css\"]" />
<link rel="stylesheet" href="@Assets[\"ErpPortal.styles.css\"]" />
<ImportMap />
<HeadOutlet />
</head>
<body>
@* Use per-request render mode. Excluded routes (e.g., /login) stay static SSR,
while authenticated app routes remain interactive. *@
<Routes @rendermode="PageRenderMode" />
<script src="@Assets[\"_framework/blazor.web.js\"]"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
</body>
</html>
@code {
[CascadingParameter]
private HttpContext HttpContext { get; set; } = default!;
private IComponentRenderMode? PageRenderMode =>
HttpContext.AcceptsInteractiveRouting() ? InteractiveServer : null;
}Important
Enhanced Navigation: How It Works
When a user clicks an <a> link, Blazor's blazor.web.js intercepts the navigation, issues a fetch request for the next page, and diffs only the changed HTML sections into the DOM — no full reload, no flash. This is functionally equivalent to TanStack Router's client-side navigation, but powered by server-rendered HTML. To opt out of Enhanced Navigation for a specific link (e.g., external URLs or file downloads), add data-enhance-nav="false" to the anchor tag.
@inject NavigationManager NavigationManager
@code {
protected override void OnInitialized()
{
NavigationManager.NavigateTo($"/login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}", forceLoad: true);
}
}<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
<NotAuthorized>
<RedirectToLogin />
</NotAuthorized>
<Authorizing>
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
</Authorizing>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using ErpPortal
@using ErpPortal.Components
@using MudBlazor
@using ErpPortal.Core.Contracts
@using ErpPortal.Core.Domain
@using ErpPortal.Infrastructure.Services
@using ErpPortal.Core.Config
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.OutputCaching
@using Microsoft.Extensions.Options
@using System.ComponentModel.DataAnnotations@inherits LayoutComponentBase
@inject LayoutService LayoutSvc
@inject IOptions<BrandingConfig> BrandingOptions
@inject NavigationManager Nav
@implements IDisposable
<ThemeProvider />
<PageTitle>@_branding.CompanyName</PageTitle>
<MudLayout>
<MudAppBar Elevation="1" Color="Color.Primary">
<MudIconButton Icon="@Icons.Material.Filled.Menu"
Color="Color.Inherit"
Edge="Edge.Start"
OnClick="@(() => LayoutSvc.ToggleSidebar())" />
<MudText Typo="Typo.h6" Class="ml-3">@_branding.CompanyName</MudText>
</MudAppBar>
<MudDrawer @bind-Open="@LayoutSvc.IsSidebarOpen" Elevation="2" Variant="@DrawerVariant.Responsive">
<NavMenu />
</MudDrawer>
<MudMainContent>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-4">
@Body
</MudContainer>
</MudMainContent>
</MudLayout>
@code {
private BrandingConfig _branding = default!;
protected override void OnInitialized()
{
_branding = BrandingOptions.Value;
// Subscribe to LayoutService changes — equivalent of useObservable(layoutService)
LayoutSvc.OnChange += StateHasChanged;
// Auto-close mobile drawer on navigation — equivalent of the useEffect LocationChanged handler
Nav.LocationChanged += OnLocationChanged;
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
=> LayoutSvc.CloseSidebar();
public void Dispose()
{
LayoutSvc.OnChange -= StateHasChanged;
Nav.LocationChanged -= OnLocationChanged;
}
}<MudNavMenu>
<MudText Typo="Typo.overline" Class="px-4 mt-2 mud-text-secondary">MAIN MENU</MudText>
<MudNavLink Href="/dashboard" Icon="@Icons.Material.Filled.Dashboard" Match="NavLinkMatch.All">Dashboard</MudNavLink>
<MudNavLink Href="/users" Icon="@Icons.Material.Filled.People">Users</MudNavLink>
<MudNavLink Href="/tasks" Icon="@Icons.Material.Filled.Assignment">Tasks</MudNavLink>
</MudNavMenu>@page "/"
@inject NavigationManager Nav
@code {
protected override void OnInitialized()
=> Nav.NavigateTo("/dashboard", replace: true);
}Note
Cookie Login Should Use Plain HTTP POST
The login page is intentionally excluded from interactive routing and posts to a controller endpoint. This ensures cookie headers are written before an interactive circuit is active and keeps antiforgery validation reliable.
[!NOTE] DummyJSON Login Credentials
Use any credentials from dummyjson.com/users. Example: username
emilys, passwordemilyspass.
@page "/login"
@attribute [ExcludeFromInteractiveRouting]
@using Microsoft.AspNetCore.Antiforgery
@using Microsoft.Extensions.Options
@inject IAntiforgery Antiforgery
@inject IOptions<BrandingConfig> BrandingOptions
@layout ErpPortal.Components.Layout.MainLayout
<PageTitle>Sign In — @BrandingOptions.Value.CompanyName</PageTitle>
<form method="post" action="/account/login">
<input type="hidden" name="__RequestVerificationToken" value="@AntiforgeryToken" />
@* Mud inputs still submit standard form field names for controller model binding *@
<MudTextField @bind-Value="Model.Username"
UserAttributes="@(new Dictionary<string, object>
{
["name"] = "Username",
["autocomplete"] = "username"
})" />
<MudTextField @bind-Value="Model.Password"
InputType="InputType.Password"
UserAttributes="@(new Dictionary<string, object>
{
["name"] = "Password",
["autocomplete"] = "current-password"
})" />
<MudCheckBox @bind-Value="Model.RememberMe"
UserAttributes="@(new Dictionary<string, object>
{
["name"] = "RememberMe",
["value"] = "true"
})" />
<MudButton ButtonType="ButtonType.Submit">Sign In</MudButton>
</form>
@code {
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
[SupplyParameterFromQuery(Name = "error")]
private string? Error { get; set; }
private LoginModel Model { get; set; } = new();
private string? _errorMessage;
private string? AntiforgeryToken =>
HttpContext is null
? null
: Antiforgery.GetAndStoreTokens(HttpContext).RequestToken;
protected override void OnParametersSet()
=> _errorMessage = Error switch
{
"invalid" => "Please provide both username and password.",
"blocked" => "Login request was blocked by the upstream service.",
"unreachable" => "Could not reach the authentication service.",
"failed" => "Authentication failed. Please check your credentials.",
_ => null,
};
private sealed class LoginModel
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public bool RememberMe { get; set; }
}
}Controller endpoints are the write-path for authentication. This keeps cookie header writes and antiforgery validation on standard HTTP POST requests.
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using ErpPortal.Core.Contracts;
using ErpPortal.Core.Domain;
using ErpPortal.Core.Exceptions;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
namespace ErpPortal.Controllers;
[Route("account")]
public sealed class AccountController : Controller
{
private readonly IErpHttpClient _http;
private readonly ILogger<AccountController> _logger;
public AccountController(IErpHttpClient http, ILogger<AccountController> logger)
{
_http = http;
_logger = logger;
}
[HttpPost("login")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login([FromForm] LoginForm model, CancellationToken ct)
{
if (!ModelState.IsValid)
return Redirect("/login?error=invalid");
try
{
int sessionMinutes = model.RememberMe ? 60 * 24 * 30 : 60;
User user = await _http.PostAsync<User>(
"/auth/login",
new { username = model.Username, password = model.Password },
ct);
List<Claim> claims =
[
new(ClaimTypes.Name, user.Username),
new(ClaimTypes.Email, user.Email),
new("FirstName", user.FirstName),
new("LastName", user.LastName),
new("Token", user.Token ?? string.Empty),
];
ClaimsIdentity identity = new(claims, CookieAuthenticationDefaults.AuthenticationScheme);
ClaimsPrincipal principal = new(identity);
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
new AuthenticationProperties
{
IsPersistent = model.RememberMe,
ExpiresUtc = model.RememberMe
? DateTimeOffset.UtcNow.AddDays(30)
: DateTimeOffset.UtcNow.AddHours(1),
});
return Redirect("/dashboard");
}
catch (AppException ex)
{
string reason = ex.StatusCode switch
{
401 => "invalid",
403 => "blocked",
0 => "unreachable",
_ => "failed",
};
return Redirect($"/login?error={reason}");
}
}
[HttpPost("logout")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
_logger.LogInformation("User logged out");
return Redirect("/login");
}
public sealed class LoginForm
{
[Required] public string Username { get; set; } = string.Empty;
[Required] public string Password { get; set; } = string.Empty;
public bool RememberMe { get; set; }
}
}@page "/dashboard"
@rendermode InteractiveServer
@attribute [Authorize]
@inject LayoutService LayoutSvc
@inject IOptions<BrandingConfig> BrandingOptions
<PageTitle>Dashboard — @BrandingOptions.Value.CompanyName</PageTitle>
<HeadContent>
<meta name="description" content="ERP Dashboard Overview." />
</HeadContent>
<MudText Typo="Typo.h4" Class="mb-4">Dashboard Overview</MudText>
<MudGrid>
<MudItem xs="12" sm="6" lg="4">
<MudPaper Elevation="2" Class="pa-4">
<MudStack Row="true" AlignItems="AlignItems.Center">
<MudIcon Icon="@Icons.Material.Filled.People" Color="Color.Primary" Size="Size.Large" />
<MudStack>
<MudText Typo="Typo.caption" Color="Color.Secondary">Total Users (Server State)</MudText>
<MudText Typo="Typo.h5" Class="font-weight-bold">150</MudText>
</MudStack>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" lg="4">
<MudPaper Elevation="2" Class="pa-4">
<MudStack Row="true" AlignItems="AlignItems.Center">
<MudIcon Icon="@Icons.Material.Filled.Menu" Color="Color.Info" Size="Size.Large" />
<MudStack>
<MudText Typo="Typo.caption" Color="Color.Secondary">Sidebar (UI State)</MudText>
<MudChip T="string" Color="@(LayoutSvc.IsSidebarOpen ? Color.Success : Color.Default)" Size="Size.Small">
@(LayoutSvc.IsSidebarOpen ? "Expanded" : "Collapsed")
</MudChip>
</MudStack>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="12" lg="4">
<MudPaper Elevation="2" Class="pa-4" Style="border: 1px dashed var(--brand-primary)">
<MudText Typo="Typo.subtitle2" Class="mb-2">Pro Insight: Dynamic Theming</MudText>
<MudText Typo="Typo.body2">
This card's border uses a <strong>CSS Variable</strong> synced via our
ThemeProvider for brand-consistent styling across all components.
</MudText>
</MudPaper>
</MudItem>
</MudGrid>Note
MudTable<T> instead of QuickGrid<T>
MudBlazor v9 and Microsoft.AspNetCore.Components.QuickGrid both export a component named TemplateColumn. Razor cannot disambiguate them when both namespaces appear in _Imports.razor (RZ9985). MudTable<T> is already available via MudBlazor and has no naming collisions, so it is used here instead.
@page "/users"
@rendermode InteractiveServer
@attribute [Authorize]
@attribute [OutputCache(PolicyName = "UsersList")]
@inject IRepository<User> UserRepo
@inject INotificationService Notifier
<PageTitle>User Management</PageTitle>
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-4">
<MudText Typo="Typo.h4">User Management</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
OnClick="@(() => _createModalOpen = true)">
Add User
</MudButton>
</MudStack>
@if (_isLoading)
{
<MudSkeleton Height="400px" />
}
else if (_error is not null)
{
<MudAlert Severity="Severity.Error">@_error</MudAlert>
}
else
{
<MudPaper Elevation="2" Class="pa-2">
<MudTextField @bind-Value="_globalFilter"
Placeholder="Search users..."
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
Class="mb-3" />
@* MudBlazor v9: MudTable replaces QuickGrid — both libraries export TemplateColumn and
Razor cannot disambiguate them (RZ9985). MudTable carries no such ambiguity. *@
<MudTable Items="@FilteredUsers" RowsPerPage="20" Hover="true" Striped="true">
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Email</MudTh>
<MudTh>Username</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudStack Row="true" AlignItems="AlignItems.Center">
@* MudBlazor v9: MUD0002 rejects Image attribute on MudAvatar — use initials *@
<MudAvatar Size="Size.Small" Color="Color.Primary">
@context.FirstName[0]@context.LastName[0]
</MudAvatar>
<MudText Typo="Typo.body2">@context.FirstName @context.LastName</MudText>
</MudStack>
</MudTd>
<MudTd>@context.Email</MudTd>
<MudTd>@context.Username</MudTd>
<MudTd>
<MudStack Row="true" Justify="Justify.FlexEnd">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small" Color="Color.Default" />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small" Color="Color.Error"
OnClick="@(() => OpenDeleteConfirm(context))" />
</MudStack>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager />
</PagerContent>
</MudTable>
</MudPaper>
}
@* Create User Modal *@
<MudDialog @bind-Visible="_createModalOpen" Options="_dialogOptions">
<TitleContent><MudText Typo="Typo.h6">Add New User</MudText></TitleContent>
<DialogContent>
<EditForm Model="_newUser" OnValidSubmit="HandleCreateUserAsync" FormName="CreateUserForm">
<DataAnnotationsValidator />
<MudStack>
<MudTextField @bind-Value="_newUser.FirstName" Label="First Name" For="@(() => _newUser.FirstName)" />
<MudTextField @bind-Value="_newUser.LastName" Label="Last Name" For="@(() => _newUser.LastName)" />
<MudTextField @bind-Value="_newUser.Email" Label="Email" For="@(() => _newUser.Email)" />
<MudTextField @bind-Value="_newUser.Username" Label="Username" For="@(() => _newUser.Username)" />
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled"
Color="Color.Primary" Disabled="_isSubmitting">
Create User
</MudButton>
</MudStack>
</EditForm>
</DialogContent>
</MudDialog>
@* Delete Confirmation Dialog — equivalent of modals.openConfirmModal() *@
<MudDialog @bind-Visible="_deleteConfirmOpen" Options="_dialogOptions">
<TitleContent><MudText Typo="Typo.h6">Confirm Deletion</MudText></TitleContent>
<DialogContent>
<MudText>This action cannot be undone.</MudText>
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => _deleteConfirmOpen = false)">Cancel</MudButton>
<MudButton Color="Color.Error" Variant="Variant.Filled"
OnClick="HandleDeleteAsync" Disabled="_isDeleting">
Delete User
</MudButton>
</DialogActions>
</MudDialog>
@code {
private IReadOnlyList<User> _users = [];
private bool _isLoading, _isSubmitting, _isDeleting, _createModalOpen, _deleteConfirmOpen;
private string? _error;
private string _globalFilter = string.Empty;
private User? _userToDelete;
private readonly DialogOptions _dialogOptions = new() { CloseOnEscapeKey = true };
private readonly CreateUserModel _newUser = new();
private IEnumerable<User> FilteredUsers => _users
.Where(u => string.IsNullOrEmpty(_globalFilter) ||
u.FirstName.Contains(_globalFilter, StringComparison.OrdinalIgnoreCase) ||
u.Email.Contains(_globalFilter, StringComparison.OrdinalIgnoreCase) ||
u.Username.Contains(_globalFilter, StringComparison.OrdinalIgnoreCase));
protected override async Task OnInitializedAsync()
{
_isLoading = true;
try
{
(IReadOnlyList<User> data, int _) = await UserRepo.GetAllAsync(0, 50);
_users = data;
}
catch (Exception e) { _error = e.Message; }
finally { _isLoading = false; }
}
private void OpenDeleteConfirm(User user)
{
_userToDelete = user;
_deleteConfirmOpen = true;
}
private async Task HandleDeleteAsync()
{
if (_userToDelete is null) return;
_isDeleting = true;
try
{
await UserRepo.DeleteAsync(_userToDelete.Id);
_users = _users.Where(u => u.Id != _userToDelete.Id).ToList();
_deleteConfirmOpen = false;
}
finally { _isDeleting = false; }
}
private async Task HandleCreateUserAsync()
{
_isSubmitting = true;
try
{
User user = new User(0, _newUser.Username, _newUser.Email, _newUser.FirstName, _newUser.LastName, string.Empty);
User created = await UserRepo.CreateAsync(user);
_users = [.. _users, created];
_createModalOpen = false;
}
finally { _isSubmitting = false; }
}
private sealed class CreateUserModel
{
[Required] public string FirstName { get; set; } = string.Empty;
[Required] public string LastName { get; set; } = string.Empty;
[Required, EmailAddress] public string Email { get; set; } = string.Empty;
[Required] public string Username { get; set; } = string.Empty;
}
}Note
<Virtualize<T>>: The Built-in Windowed Renderer
Blazor's <Virtualize> component renders only the visible items in a large list — the exact equivalent of @tanstack/react-virtual's useVirtualizer. It ships with the framework; no extra package required. Set ItemSize to your estimated row height and OverscanCount to control the buffer above/below the viewport.
@page "/tasks"
@rendermode InteractiveServer
@attribute [Authorize]
@attribute [OutputCache(PolicyName = "TodosList")]
@inject IRepository<Todo> TodoRepo
<PageTitle>Task Management</PageTitle>
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-4">
<MudStack Row="true" AlignItems="AlignItems.Baseline">
<MudText Typo="Typo.h4">Task Management</MudText>
@if (_todos is not null)
{
<MudText Typo="Typo.caption" Color="Color.Secondary">
(@_todos.Count tasks — virtualized)
</MudText>
}
</MudStack>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add">Add Task</MudButton>
</MudStack>
@if (_isLoading)
{
<MudSkeleton Height="600px" />
}
else if (_error is not null)
{
<MudAlert Severity="Severity.Error">@_error</MudAlert>
}
else
{
@* Scrollable container for the virtualizer *@
<MudPaper Elevation="2" Style="height:600px;overflow-y:auto;">
@* <Virtualize> renders only the visible rows into the DOM.
Equivalent of rowVirtualizer.getVirtualItems() — no third-party package required. *@
<Virtualize Items="_todos" Context="todo" ItemSize="55" OverscanCount="10">
<div style="display:flex;align-items:center;padding:0 16px;height:55px;
border-bottom:1px solid var(--mud-palette-divider);">
<MudText Typo="Typo.body2" Style="flex:1"
Class="@(todo.Completed ? "mud-text-disabled" : "")"
Style="@(todo.Completed ? "text-decoration:line-through" : "")">
@todo.TodoText
</MudText>
<MudChip T="string" Color="@(todo.Completed ? Color.Success : Color.Default)"
Size="Size.Small"
OnClick="@(() => HandleToggleAsync(todo))"
Class="mr-3">
@(todo.Completed ? "Completed" : "Pending")
</MudChip>
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
OnClick="@(() => OpenDeleteConfirm(todo))" />
</div>
</Virtualize>
</MudPaper>
}
@* Delete confirmation dialog *@
<MudDialog @bind-Visible="_deleteConfirmOpen">
<TitleContent><MudText Typo="Typo.h6">Delete Task</MudText></TitleContent>
<DialogContent>
<MudText>Are you sure you want to delete this task?</MudText>
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => _deleteConfirmOpen = false)">Cancel</MudButton>
<MudButton Color="Color.Error" Variant="Variant.Filled" OnClick="HandleDeleteAsync">Delete</MudButton>
</DialogActions>
</MudDialog>
@code {
private List<Todo> _todos = [];
private bool _isLoading, _deleteConfirmOpen;
private string? _error;
private Todo? _todoToDelete;
protected override async Task OnInitializedAsync()
{
_isLoading = true;
try
{
(IReadOnlyList<Todo> data, int _) = await TodoRepo.GetAllAsync(0, 150);
_todos = [.. data];
}
catch (Exception e) { _error = e.Message; }
finally { _isLoading = false; }
}
private async Task HandleToggleAsync(Todo todo)
{
Todo updated = await TodoRepo.UpdateAsync(todo.Id, todo with { Completed = !todo.Completed });
int index = _todos.FindIndex(t => t.Id == todo.Id);
if (index >= 0) _todos[index] = updated;
}
private void OpenDeleteConfirm(Todo todo)
{
_todoToDelete = todo;
_deleteConfirmOpen = true;
}
private async Task HandleDeleteAsync()
{
if (_todoToDelete is null) return;
await TodoRepo.DeleteAsync(_todoToDelete.Id);
_todos.RemoveAll(t => t.Id == _todoToDelete.Id);
_deleteConfirmOpen = false;
}
}Tip
State Management Practice: Server vs UI State
Always keep "Server State" (data loaded via IRepository<T>) separate from "UI State" (LayoutService, component fields). Use [OutputCache] on pages for server-state caching and StateHasChanged() for UI-state reactivity. Never cache API data in a LayoutService; let the output cache handle invalidation.
The reactive state pattern maps cleanly to Blazor's component model. Here is a summary of all pieces working together:
| File | Purpose |
|---|---|
Infrastructure/Services/LayoutService.cs |
Manages { IsSidebarOpen } with an event Action? OnChange |
Components/Layout/MainLayout.razor |
Subscribes to OnChange and calls StateHasChanged() in OnInitialized |
Components/Pages/Dashboard.razor |
Reads LayoutSvc.IsSidebarOpen directly (no wrapper hook needed) |
To add a new piece of UI state (e.g., a notification drawer), create a new scoped service with an OnChange event, register it in Program.cs, and subscribe from the consuming component. No external state library (Redux, Zustand, MobX) is needed — Blazor's component lifecycle is the state container.
Because this is an ASP.NET Core application, the runtime is the container itself — we use Microsoft's official aspnet base image. No separate reverse proxy is needed for basic hosting.
Tip
Production Strategy: ASP.NET Core as the Server
Unlike the SPA version which required Nginx to serve static files, Blazor SSR serves everything — pages, API proxying, and static assets — from a single dotnet process. The container is simpler, startup is faster, and you get the full Kestrel performance profile out of the box.
# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS builder
WORKDIR /app
# Restore packages first (layer-cached until .csproj changes)
COPY *.csproj ./
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o ./publish --no-restore
# Stage 2: Runtime (much smaller than SDK image)
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runner
WORKDIR /app
# Non-root user for security
RUN adduser --disabled-password --no-create-home appuser
USER appuser
COPY --from=builder /app/publish .
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "ErpPortal.dll"]bin/
obj/
.git/
.vscode/
*.md
Containerfile
.containerignore
appsettings.Development.json
Caution
Runtime Environment Variables
Unlike Vite where secrets are baked into the JS bundle at build time, ASP.NET Core reads environment variables at runtime. Pass secrets as container environment variables — never bake them into the image. This is strictly more secure.
# Restore dependencies
dotnet restore
# Start the dev server with Hot Reload
dotnet watch run
# → Listening on https://localhost:5001 (or http://localhost:5000)Tip
dotnet watch = npm run dev
dotnet watch run starts the application and watches for file changes. When you edit a .razor or .cs file, it hot-reloads the change — comparable to Vite's HMR. For Blazor pages, changes to markup and @code blocks are applied without a full restart.
dotnet publish -c Release -o ./publish
# Output: publish/ directory (self-contained .NET application)# Build the container image
podman build -t enterprise-erp-portal .
# Run the container — pass configuration as environment variables at RUNTIME
podman run -d \
-p 8080:8080 \
--name erp_portal \
-e "ApiSettings__BaseUrl=https://your-api.example.com" \
-e "Branding__CompanyName=My Corp ERP" \
-e "ASPNETCORE_ENVIRONMENT=Production" \
enterprise-erp-portal
# Verify
podman ps
# View logs
podman logs -f erp_portalVisit http://localhost:8080 to see your containerized ERP portal.
- ASP.NET Core Blazor: Microsoft's official Blazor documentation.
- Blazor Static SSR: Render modes and Enhanced Navigation.
- QuickGrid: Official QuickGrid samples and API reference.
- Blazor
<Virtualize>: Built-in list virtualization docs.
- MudBlazor: Feature-rich Blazor component library (Mantine equivalent).
- MudBlazor Theming: Custom theme configuration guide.
- DummyJSON: Fake REST API for prototyping.
- Podman: Daemonless OCI container manager.
- Microsoft.Extensions.Http: Typed HttpClient factory and
DelegatingHandlerpatterns. - FluentValidation: Powerful validation library for complex form rules.
- xUnit: .NET unit testing framework.
- Moq: Mocking framework for .NET.
Following the Testability principle, every concrete service codes against an abstraction, making mock injection trivial. The xUnit + Moq stack is the .NET equivalent of Vitest + vi.fn().
dotnet new xunit -n ErpPortal.Tests
cd ErpPortal.Tests
dotnet add reference ../ErpPortal/ErpPortal.csproj
dotnet add package Moq
dotnet add package FluentAssertions
dotnet add package Microsoft.Extensions.Logging.Abstractionsusing ErpPortal.Core.Contracts;
using ErpPortal.Core.Domain;
using ErpPortal.Infrastructure.Http;
using ErpPortal.Infrastructure.Repositories;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace ErpPortal.Tests;
public sealed class UserRepositoryTests
{
// Equivalent of beforeEach(() => { mockHttp = { get: vi.fn() ... } })
private readonly Mock<IErpHttpClient> _mockHttp = new();
private readonly Mock<INotificationService> _mockNotifier = new();
private readonly NullLogger<UserRepository> _logger = new();
private UserRepository CreateRepo()
=> new(_mockHttp.Object, _logger, _mockNotifier.Object);
[Fact]
public async Task GetAllAsync_ShouldReturnFormattedUsers()
{
// Arrange — equivalent of mockHttp.get.mockResolvedValue(...)
List<User> users = new List<User>
{
new(1, "johnd", "john@example.com", "John", "Doe", "https://example.com/img.jpg")
};
_mockHttp
.Setup(h => h.GetAsync<UsersApiResponse>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new UsersApiResponse(users, 1));
UserRepository repo = CreateRepo();
// Act
(IReadOnlyList<User> data, int total) = await repo.GetAllAsync(0, 10);
// Assert — FluentAssertions replaces Vitest's expect().toBe()
data[0].FirstName.Should().Be("John");
total.Should().Be(1);
_mockHttp.Verify(h => h.GetAsync<UsersApiResponse>(
It.Is<string>(u => u.Contains("limit=10") && u.Contains("skip=0")),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task GetAllAsync_ShouldLogCriticalAndThrowOnFailure()
{
// Arrange — equivalent of mockHttp.get.mockRejectedValue(...)
_mockHttp
.Setup(h => h.GetAsync<UsersApiResponse>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new HttpRequestException("Network Error"));
UserRepository repo = CreateRepo();
// Act & Assert
await repo.Invoking(r => r.GetAllAsync())
.Should().ThrowAsync<HttpRequestException>();
}
}# Run all tests with detailed output (equivalent of npm run test:unit -- --reporter=verbose)
dotnet test --verbosity normal
# Run a single test file
dotnet test --filter "FullyQualifiedName~UserRepositoryTests"
# Run in watch mode (equivalent of npm run test:watch)
dotnet watch test
# Run with coverage
dotnet test --collect:"XPlat Code Coverage"Note
Test Isolation via DI
Because all dependencies are injected via interfaces, every test creates fresh Mock<T>() objects. Tests are fully isolated — no shared state, no order dependencies. If a test fails, the fault is in the code under test, not in test setup leakage.
name: Enterprise Build & Test
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
quality-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET 9
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
# Equivalent of npm ci — restores packages with lock file
- name: Restore Dependencies
run: dotnet restore
# Equivalent of npx tsc --noEmit — compile-time type checking with -warnaserror
- name: Build (Strict — Warnings as Errors)
run: dotnet build --no-restore -warnaserror
# Equivalent of npm run test:unit
- name: Unit Tests (xUnit)
run: dotnet test --no-build --verbosity normal --collect:"XPlat Code Coverage"
# Equivalent of npm run build
- name: Publish Release Artifact
run: dotnet publish -c Release -o ./publish
env:
# Runtime config is passed here — NOT baked into the build
ASPNETCORE_ENVIRONMENT: Production
container-delivery:
needs: quality-gate
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
environment: production
steps:
- uses: actions/checkout@v4
- name: Build and Push Container
uses: docker/build-push-action@v5
with:
context: .
file: ./Containerfile
push: true
tags: ghcr.io/${{ github.repository }}:latestFor teams using Azure DevOps, this pipeline restores, builds, and publishes ErpPortal as a zipped artifact from the dev branch trigger.
# ASP.NET Core
# Build and test ASP.NET Core projects.
# https://docs.microsoft.com/azure/devops/pipelines/ecosystems/dotnet-core
trigger:
- dev/sprint_280/tm/sprint_support/47798
pool:
vmImage: 'windows-latest'
variables:
project: 'ErpPortal/ErpPortal.csproj'
buildPlatform: 'Any CPU'
buildConfiguration: 'Release'
steps:
- task: UseDotNet@2
displayName: 'Install .NET SDK'
inputs:
packageType: 'sdk'
useGlobalJson: false
version: '10.x'
- task: DotNetCoreCLI@2
displayName: 'Restore NuGet Packages'
inputs:
command: restore
projects: '$(project)'
- task: DotNetCoreCLI@2
displayName: 'Build ErpPortal'
inputs:
command: build
projects: '$(project)'
arguments: '--configuration $(buildConfiguration) --no-restore'
- task: DotNetCoreCLI@2
displayName: 'Publish ErpPortal'
inputs:
command: publish
projects: '$(project)'
arguments: '--configuration $(buildConfiguration) --no-build --output $(Build.ArtifactStagingDirectory)'
publishWebProjects: false
zipAfterPublish: true
- task: PublishBuildArtifacts@1
inputs:
PathtoPublish: '$(Build.ArtifactStagingDirectory)'
ArtifactName: 'Dev_Release_WebUI_Artifact'
publishLocation: 'Container'Important
Runtime Secrets in CI
Unlike the SPA version where env vars were baked into the JS bundle, ASP.NET Core reads its configuration at runtime. Store ApiSettings__BaseUrl and other secrets as GitHub Secrets or as environment variables on your hosting platform. The container image itself contains zero secrets and is safe to push to a public registry.
In enterprise ERP environments, preventing external scraping and protecting internal data is critical. The Blazor SSR approach has a structural advantage here: the application returns 401 / 302 to /login for unauthenticated requests at the server level, before any HTML is rendered — crawlers never see the content.
User-agent: *
Disallow: /
# Specifically block AI Scrapers & Social Crawlers
User-agent: GPTBot
User-agent: ChatGPT-User
User-agent: Google-Extended
User-agent: CCBot
User-agent: OAI-SearchBot
User-agent: meta-externalagent
User-agent: Facebot
User-agent: facebookexternalhit
Disallow: /
Inject "No-Index" globally via <HeadOutlet> to prevent search engines from indexing the portal. Since these are rendered server-side, they are present in the very first HTTP response — more reliable than injecting them via JavaScript.
@* In the <head> section of App.razor *@
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
<meta name="googlebot" content="noindex, nofollow" />
<meta property="og:type" content="website" />Set security headers at the middleware level — more reliable than HTML meta tags, and applied before any page logic runs.
// In Program.cs — add after app.UseStaticFiles()
app.Use(async (context, next) =>
{
context.Response.Headers["X-Frame-Options"] = "DENY";
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
context.Response.Headers["Referrer-Policy"] = "no-referrer";
// context.Response.Headers["Content-Security-Policy"] =
// "default-src 'self'; " +
// "script-src 'self'; " +
// "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
// "font-src 'self' https://fonts.gstatic.com; " +
// "img-src 'self' data: https:; " +
// "connect-src 'self' https://dummyjson.com;";
await next();
});// In Program.cs — add before app.UseRouting()
string[] blockedAgents = new[] { "GPTBot", "ChatGPT", "facebookexternalhit", "meta-externalagent", "CCBot" };
app.Use(async (context, next) =>
{
string ua = context.Request.Headers.UserAgent.ToString();
if (blockedAgents.Any(agent => ua.Contains(agent, StringComparison.OrdinalIgnoreCase)))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return;
}
await next();
});- ASP.NET Core: Zero telemetry collected by default in production builds.
- MudBlazor: Pure UI library — no analytics or tracking.
- Kestrel: Open source, self-hosted — zero external calls.
dotnetCLI: SetDOTNET_CLI_TELEMETRY_OPTOUT=1in your container image or CI environment to suppress build-time telemetry entirely.
# Add to Containerfile — Stage 1 (builder)
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
ENV DOTNET_NOLOGO=1Tip
Security & Privacy Ops: Zero-Trust Scripting
The primary source of telemetry leakage in ERPs is third-party tracking scripts. In enterprise portals, avoid these entirely. If usage tracking is required, use self-hosted solutions like Umami and integrate them via a strongly-typed IAnalyticsService so they can be swapped out via DI.
This application is a standard ASP.NET Core app and can be hosted on any platform that supports containers or the .NET runtime.
# Tag the image for ACR
podman tag enterprise-erp-portal myregistry.azurecr.io/erp-portal:latest
# Push
podman push myregistry.azurecr.io/erp-portal:latestIn the Azure Portal under Configuration > Application settings, add:
| Key | Example Value |
|---|---|
ApiSettings__BaseUrl |
https://api.yourdomain.com |
Branding__CompanyName |
My Corp ERP |
ASPNETCORE_ENVIRONMENT |
Production |
Important
Double Underscore as Separator
ASP.NET Core uses __ (double underscore) to represent nesting in environment variable names. ApiSettings__BaseUrl maps to appsettings.json's ApiSettings.BaseUrl. This is the .NET equivalent of Netlify's flat environment variable system.
# Install flyctl
curl -L https://fly.io/install.sh | sh
# Deploy from the Containerfile
fly launch
fly secrets set ApiSettings__BaseUrl="https://api.yourdomain.com"
fly deployUnlike the SPA version which required a public/_redirects file to redirect all paths to index.html, Blazor SSR handles routing on the server. Every URL is matched by ASP.NET Core's routing middleware and rendered server-side. There are no client-side routing edge cases on page refresh.
Blazor SSR debugging is native to the .NET ecosystem — no source maps configuration needed. VS and VS Code attach to the running process and step through C# and Razor files directly.
- Open
ErpPortal.sln. - Press F5 → VS builds, launches, and attaches the debugger.
- Set breakpoints in any
.cs,.razor, or code-behind file. - The full call stack, locals, watch window, and Immediate Window are available.
Install the C# Dev Kit extension, then:
- Create
.vscode/launch.json:
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Blazor SSR (dotnet)",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/ErpPortal/ErpPortal.csproj"
}
]
}- Press F5 → the debugger attaches to the running Kestrel server.
- Set breakpoints in
.razorfiles — VS Code pauses execution in both the C#@codeblock and the rendering logic.
# Start with Hot Reload
dotnet watch run
# Hot Reload behaviour:
# - Editing .razor markup or @code blocks → UI updates without restart
# - Editing .cs service files → application restarts automatically
# - Editing appsettings.json → configuration reloads at runtime (IOptionsMonitor)| Symptom | Cause | Fix |
|---|---|---|
NullReferenceException in @code |
Nullable not initialised | Enable <Nullable>enable</Nullable>; initialise fields in OnInitialized |
InvalidOperationException: HttpContext is null |
IHttpContextAccessor used in a non-request context |
Ensure the service is Scoped, not Singleton |
| Redirect loop on login page | [Authorize] attribute present but auth middleware not configured |
Confirm app.UseAuthentication() is before app.UseAuthorization() in Program.cs |
| Cache not invalidating after mutation | [OutputCache] in use with no eviction |
Call IOutputCacheStore.EvictByTagAsync("users", ct) after write operations |
| Enhanced Navigation not working | blazor.web.js not loaded |
Ensure <script src="_framework/blazor.web.js"></script> is at end of <body> in App.razor |
| Styles not loading (unstyled MudBlazor) | Missing CSS import | Confirm <link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" /> is in App.razor |
Missing <MudPopoverProvider /> at runtime |
MudBlazor overlay stack incomplete | Add <MudPopoverProvider /> in Components/Layout/ThemeProvider.razor before dialogs/snackbars |
CS8618: Non-nullable property uninitialized |
Strict nullable enabled | Initialise in constructor or use = default! for properties injected by the framework |
| Component not re-rendering on service change | Missing StateHasChanged() call |
Subscribe to ServiceName.OnChange += StateHasChanged and unsubscribe in Dispose() |
RemoteNavigationManager has not been initialized |
Blazor-scoped service (e.g. ISnackbar) injected into an HTTP DelegatingHandler that is constructed before a circuit exists |
Remove Blazor-scoped dependencies from DelegatingHandler; call INotificationService from the page's catch block instead |
| Login reports wrong-password for valid credentials | API returns accessToken but User.Token property has no [JsonPropertyName] |
Add [property: JsonPropertyName("accessToken")] to the Token parameter in the User record |
A valid antiforgery token was not provided on /account/login |
Login rendered as interactive route, so SSR form token lifecycle breaks | Mark login with [ExcludeFromInteractiveRouting], post to controller action, and use hidden __RequestVerificationToken |
| The POST request does not specify which form is being submitted | Static SSR form post with unnamed <EditForm> |
Add a unique FormName to each <EditForm> (or @formname on raw <form>) |
| Form validation not triggering | Missing <DataAnnotationsValidator /> |
Add <DataAnnotationsValidator /> inside <EditForm> |
.NET's built-in container throws descriptive exceptions at startup for misconfigured services:
System.InvalidOperationException:
Some services are not able to be constructed (Error while validating the service descriptor
'ServiceType: IAuthService Lifetime: Scoped ImplementationType: AuthService'):
Unable to resolve service for type 'IErpHttpClient' while attempting to activate 'AuthService'.
Fix: Enable service validation at startup to catch all DI errors before the first request:
// In Program.cs
if (app.Environment.IsDevelopment())
{
// Equivalent of services.debugListServices() — validates all registrations eagerly
builder.Host.UseDefaultServiceProvider(options =>
{
options.ValidateScopes = true;
options.ValidateOnBuild = true;
});
}To inspect the output cache in development, disable it globally and verify data freshness:
// In appsettings.Development.json — disable output cache for debugging
{
"OutputCache": {
"DefaultExpirationTimeSpan": "00:00:01"
}
}Or evict a specific tag from a page after a mutation:
@inject IOutputCacheStore CacheStore
// After a delete or create operation:
await CacheStore.EvictByTagAsync("users", CancellationToken.None);# Filter logs by level (equivalent of console filter by "level":"error")
dotnet run | grep '"LogLevel":"Error"'
# Or use the built-in development console filter in appsettings.Development.json:
# Set "Microsoft.AspNetCore": "Debug" to trace routing decisionsTip
Production Logger Swap
Because all logging flows through ILogger<T>, swapping the sink in production requires only a NuGet package and a builder.Logging.AddSentry(...) call in Program.cs — no business logic changes required.
All text in the application uses Libre Franklin exclusively — a versatile, open-source humanist sans-serif from Google Fonts (SIL Open Font License). It is applied at two levels.
Load the full variable-font axis (weights 100–900, including italic) from the Google Fonts CDN:
<link href="https://fonts.googleapis.com/css2?family=Libre+Franklin:ital,wght@0,100..900;1,100..900&display=swap"
rel="stylesheet" />Tip
Variable-font axis wght@0,100..900;1,100..900
This single request loads the entire weight/italic range as a variable font, which is more efficient than requesting individual weights (e.g., wght@300,400,600,700). The browser only downloads the glyph subsets actually used on each page.
Apply the font to every element via the universal selector so all HTML — including MudBlazor components, plain Blazor markup, and third-party components — inherits Libre Franklin:
/* ── Global Font ─────────────────────────────────────────────────────────── */
*, *::before, *::after {
font-family: 'Libre Franklin', sans-serif;
}Note
Why not the MudBlazor Typography block?
MudBlazor v9's Typography slot types (Default, H1, H6, Button, etc.) are short class names that collide with other C# identifiers when <TreatWarningsAsErrors>true</TreatWarningsAsErrors> is active, causing CS0246 build failures. The CSS universal selector achieves the same result — overriding MudBlazor's built-in Roboto — without any build-time ambiguity. The Typography block in ThemeProvider.razor is therefore omitted; the CSS layer handles font enforcement entirely.
This section introduces a companion Web API project — ErpPortal.Api — that sits between the Blazor SSR front-end and the DummyJSON upstream. It authenticates against POST /auth/login, captures the JWT accessToken and refreshToken, and transparently injects the Bearer token into subsequent calls to protected routes like /auth/products and /auth/todos.
Important
Why a Separate Web API Project?
In the main guide, the Blazor SSR app calls DummyJSON directly via IErpHttpClient. That approach works well for a monolith. This API gateway pattern is the correct choice when:
- Multiple front-ends (Blazor, mobile, third-party) need a single authenticated proxy.
- You want to encapsulate upstream JWT lifecycle (acquire → cache → refresh → retry) in one place.
- You need to add your own authorization, rate-limiting, or audit logging before forwarding to DummyJSON.
- The front-end should never see or manage the DummyJSON JWT — it authenticates to your API only.
┌──────────────────┐ ┌──────────────────────┐ ┌──────────────────┐
│ Blazor SSR App │ ──────► │ ErpPortal.Api │ ──────► │ dummyjson.com │
│ (or any client) │ HTTP │ (Web API Gateway) │ HTTP │ (upstream) │
│ │ │ │ │ │
│ Uses cookie │ │ • POST /api/auth │ │ POST /auth/login│
│ auth to talk │ │ • GET /api/products │ │ GET /auth/prods │
│ to this API │ │ • GET /api/todos │ │ GET /auth/todos │
│ │ │ • Token mgmt svc │ │ │
└──────────────────┘ └──────────────────────┘ └──────────────────┘
# From the solution root
dotnet new webapi -n ErpPortal.Api --no-openapi false
cd ErpPortal.Api
# Add to existing solution (optional)
cd ..
dotnet sln add ErpPortal.Api/ErpPortal.Api.csprojErpPortal.Api/
├── Controllers/
│ ├── AuthController.cs # POST /api/auth/login → proxies to DummyJSON /auth/login
│ ├── ProductsController.cs # GET /api/products → proxies to DummyJSON /auth/products
│ └── TodosController.cs # GET /api/todos → proxies to DummyJSON /auth/todos
├── Core/
│ ├── Contracts/
│ │ ├── IDummyJsonClient.cs # Typed HttpClient interface for upstream calls
│ │ └── ITokenService.cs # JWT lifecycle: acquire, cache, refresh
│ └── Domain/
│ ├── AuthTokens.cs # accessToken + refreshToken record
│ ├── Product.cs # Product domain model
│ └── Todo.cs # Todo domain model (reuse or redefine)
├── Infrastructure/
│ ├── Http/
│ │ ├── DummyJsonAuthHandler.cs # DelegatingHandler — injects Bearer token automatically
│ │ └── DummyJsonClient.cs # Concrete typed HttpClient
│ └── Services/
│ └── TokenService.cs # In-memory JWT cache with auto-refresh
├── appsettings.json
└── Program.cs # DI composition root + middleware
{
"DummyJson": {
"BaseUrl": "https://dummyjson.com",
"Username": "emilys",
"Password": "emilyspass",
"TokenExpiryMinutes": 30
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}Caution
Credentials in appsettings.json
The Username and Password above are DummyJSON test credentials committed solely for demonstration. In production, store real upstream credentials in dotnet user-secrets locally and in environment variables (DummyJson__Username, DummyJson__Password) in CI/hosting. Never commit secrets to source control.
// Core/Config/DummyJsonSettings.cs
using System.ComponentModel.DataAnnotations;
namespace ErpPortal.Api.Core.Config;
public sealed class DummyJsonSettings
{
public const string SectionName = "DummyJson";
[Required, Url]
public string BaseUrl { get; init; } = string.Empty;
[Required]
public string Username { get; init; } = string.Empty;
[Required]
public string Password { get; init; } = string.Empty;
[Range(1, 1440)]
public int TokenExpiryMinutes { get; init; } = 30;
}using System.Text.Json.Serialization;
namespace ErpPortal.Api.Core.Domain;
/// <summary>
/// Represents the token pair returned by DummyJSON's /auth/login endpoint.
/// </summary>
public sealed record AuthTokens(
[property: JsonPropertyName("accessToken")] string AccessToken,
[property: JsonPropertyName("refreshToken")] string RefreshToken
);using System.Text.Json.Serialization;
namespace ErpPortal.Api.Core.Domain;
public sealed record Product(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("description")] string Description,
[property: JsonPropertyName("price")] decimal Price,
[property: JsonPropertyName("brand")] string? Brand,
[property: JsonPropertyName("category")] string Category,
[property: JsonPropertyName("thumbnail")] string Thumbnail
);
public sealed record ProductsResponse(
[property: JsonPropertyName("products")] List<Product> Products,
[property: JsonPropertyName("total")] int Total,
[property: JsonPropertyName("skip")] int Skip,
[property: JsonPropertyName("limit")] int Limit
);using System.Text.Json.Serialization;
namespace ErpPortal.Api.Core.Domain;
public sealed record Todo(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("todo")] string TodoText,
[property: JsonPropertyName("completed")] bool Completed,
[property: JsonPropertyName("userId")] int UserId
);
public sealed record TodosResponse(
[property: JsonPropertyName("todos")] List<Todo> Todos,
[property: JsonPropertyName("total")] int Total,
[property: JsonPropertyName("skip")] int Skip,
[property: JsonPropertyName("limit")] int Limit
);namespace ErpPortal.Api.Core.Contracts;
/// <summary>
/// Manages the DummyJSON JWT lifecycle:
/// acquire on first call, cache in memory, refresh before expiry.
/// </summary>
public interface ITokenService
{
/// <summary>
/// Returns a valid access token. Authenticates or refreshes automatically.
/// </summary>
Task<string> GetAccessTokenAsync(CancellationToken ct = default);
/// <summary>
/// Forces a fresh login, discarding cached tokens.
/// </summary>
Task InvalidateAsync();
}using ErpPortal.Api.Core.Domain;
namespace ErpPortal.Api.Core.Contracts;
/// <summary>
/// Typed HttpClient wrapper for DummyJSON's protected /auth/* endpoints.
/// The Bearer token is injected transparently by <see cref="DummyJsonAuthHandler"/>.
/// </summary>
public interface IDummyJsonClient
{
Task<ProductsResponse> GetProductsAsync(int skip = 0, int limit = 30, CancellationToken ct = default);
Task<Product> GetProductByIdAsync(int id, CancellationToken ct = default);
Task<TodosResponse> GetTodosAsync(int skip = 0, int limit = 30, CancellationToken ct = default);
Task<Todo> GetTodoByIdAsync(int id, CancellationToken ct = default);
}This is the core piece — a singleton service that handles the full JWT lifecycle against DummyJSON.
// Infrastructure/Services/TokenService.cs
using System.Net.Http.Json;
using System.Text.Json;
using ErpPortal.Api.Core.Config;
using ErpPortal.Api.Core.Contracts;
using ErpPortal.Api.Core.Domain;
using Microsoft.Extensions.Options;
namespace ErpPortal.Api.Infrastructure.Services;
/// <summary>
/// Singleton service that acquires a DummyJSON JWT via /auth/login,
/// caches both tokens in memory, and transparently refreshes via /auth/refresh
/// before the access token expires.
///
/// This is the .NET equivalent of an Axios interceptor that silently
/// refreshes a token on 401 — but proactive rather than reactive.
/// </summary>
public sealed class TokenService : ITokenService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly DummyJsonSettings _settings;
private readonly ILogger<TokenService> _logger;
private readonly SemaphoreSlim _semaphore = new(1, 1);
private string? _accessToken;
private string? _refreshToken;
private DateTimeOffset _expiresAt = DateTimeOffset.MinValue;
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public TokenService(
IHttpClientFactory httpClientFactory,
IOptions<DummyJsonSettings> settings,
ILogger<TokenService> logger)
{
_httpClientFactory = httpClientFactory;
_settings = settings.Value;
_logger = logger;
}
public async Task<string> GetAccessTokenAsync(CancellationToken ct = default)
{
// Fast path: token is still valid (with 60-second safety margin)
if (_accessToken is not null && DateTimeOffset.UtcNow.AddSeconds(60) < _expiresAt)
{
return _accessToken;
}
// Serialize access: only one thread can acquire/refresh at a time
await _semaphore.WaitAsync(ct);
try
{
// Double-check after acquiring the lock
if (_accessToken is not null && DateTimeOffset.UtcNow.AddSeconds(60) < _expiresAt)
{
return _accessToken;
}
// Try refresh first if we have a refresh token
if (_refreshToken is not null)
{
try
{
await RefreshAsync(ct);
return _accessToken!;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Token refresh failed — falling back to full login");
}
}
// Full login
await LoginAsync(ct);
return _accessToken!;
}
finally
{
_semaphore.Release();
}
}
public Task InvalidateAsync()
{
_accessToken = null;
_refreshToken = null;
_expiresAt = DateTimeOffset.MinValue;
_logger.LogInformation("Token cache invalidated");
return Task.CompletedTask;
}
private async Task LoginAsync(CancellationToken ct)
{
_logger.LogInformation(
"Authenticating to DummyJSON as {Username}", _settings.Username);
using HttpClient http = _httpClientFactory.CreateClient("DummyJsonRaw");
HttpResponseMessage response = await http.PostAsJsonAsync(
"/auth/login",
new
{
username = _settings.Username,
password = _settings.Password,
expiresInMins = _settings.TokenExpiryMinutes,
},
_jsonOptions,
ct);
response.EnsureSuccessStatusCode();
AuthTokens tokens = await response.Content
.ReadFromJsonAsync<AuthTokens>(_jsonOptions, ct)
?? throw new InvalidOperationException("Null response from /auth/login");
_accessToken = tokens.AccessToken;
_refreshToken = tokens.RefreshToken;
_expiresAt = DateTimeOffset.UtcNow.AddMinutes(_settings.TokenExpiryMinutes);
_logger.LogInformation(
"DummyJSON login successful. Token expires at {ExpiresAt:u}", _expiresAt);
}
private async Task RefreshAsync(CancellationToken ct)
{
_logger.LogInformation("Refreshing DummyJSON access token");
using HttpClient http = _httpClientFactory.CreateClient("DummyJsonRaw");
HttpResponseMessage response = await http.PostAsJsonAsync(
"/auth/refresh",
new
{
refreshToken = _refreshToken,
expiresInMins = _settings.TokenExpiryMinutes,
},
_jsonOptions,
ct);
response.EnsureSuccessStatusCode();
AuthTokens tokens = await response.Content
.ReadFromJsonAsync<AuthTokens>(_jsonOptions, ct)
?? throw new InvalidOperationException("Null response from /auth/refresh");
_accessToken = tokens.AccessToken;
_refreshToken = tokens.RefreshToken;
_expiresAt = DateTimeOffset.UtcNow.AddMinutes(_settings.TokenExpiryMinutes);
_logger.LogInformation(
"Token refreshed. New expiry: {ExpiresAt:u}", _expiresAt);
}
}Note
Thread-Safety with SemaphoreSlim
The SemaphoreSlim(1, 1) ensures that only one thread at a time can acquire or refresh the token. Without this, a burst of concurrent requests hitting an expired token would all race to call /auth/login simultaneously — wasting upstream quota and risking rate-limit errors. The double-check pattern after acquiring the lock prevents redundant logins.
This handler automatically injects the Bearer token into every outgoing request made by the IDummyJsonClient. It is the exact equivalent of the AuthTokenHandler in the Blazor SSR project, but instead of reading the token from cookie claims, it reads from ITokenService.
// Infrastructure/Http/DummyJsonAuthHandler.cs
using System.Net;
using System.Net.Http.Headers;
using ErpPortal.Api.Core.Contracts;
namespace ErpPortal.Api.Infrastructure.Http;
/// <summary>
/// DelegatingHandler that injects "Authorization: Bearer {token}" into
/// every request made by the typed DummyJsonClient.
///
/// If the upstream returns 401, the handler invalidates the cached token
/// and retries the request once with a fresh token — the "retry on 401" pattern.
/// </summary>
public sealed class DummyJsonAuthHandler : DelegatingHandler
{
private readonly ITokenService _tokenService;
private readonly ILogger<DummyJsonAuthHandler> _logger;
public DummyJsonAuthHandler(
ITokenService tokenService,
ILogger<DummyJsonAuthHandler> logger)
{
_tokenService = tokenService;
_logger = logger;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
// Attach the current access token
string token = await _tokenService.GetAccessTokenAsync(cancellationToken);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
HttpResponseMessage response = await base.SendAsync(request, cancellationToken);
// If 401 → token may have expired between our check and the upstream call.
// Invalidate and retry exactly once.
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
_logger.LogWarning(
"Received 401 from {Url} — invalidating token and retrying",
request.RequestUri);
await _tokenService.InvalidateAsync();
// Clone the request (original is disposed after first send)
using HttpRequestMessage retryRequest = await CloneRequestAsync(request);
string freshToken = await _tokenService.GetAccessTokenAsync(cancellationToken);
retryRequest.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", freshToken);
response = await base.SendAsync(retryRequest, cancellationToken);
}
return response;
}
/// <summary>
/// Clones an HttpRequestMessage because a sent message cannot be reused.
/// </summary>
private static async Task<HttpRequestMessage> CloneRequestAsync(
HttpRequestMessage original)
{
HttpRequestMessage clone = new(original.Method, original.RequestUri);
if (original.Content is not null)
{
byte[] content = await original.Content.ReadAsByteArrayAsync();
clone.Content = new ByteArrayContent(content);
foreach (KeyValuePair<string, IEnumerable<string>> header
in original.Content.Headers)
{
clone.Content.Headers.TryAddWithoutValidation(
header.Key, header.Value);
}
}
foreach (KeyValuePair<string, IEnumerable<string>> header
in original.Headers)
{
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
return clone;
}
}Tip
Retry-on-401 Pattern
The handler retries exactly once on a 401 Unauthorized response. This covers the edge case where the token expires between the GetAccessTokenAsync call and the upstream receiving the request (clock skew, network latency). The retry uses a fresh token after invalidating the cache. If the retry also returns 401, the error propagates to the caller — preventing infinite loops.
// Infrastructure/Http/DummyJsonClient.cs
using System.Net.Http.Json;
using System.Text.Json;
using ErpPortal.Api.Core.Contracts;
using ErpPortal.Api.Core.Domain;
namespace ErpPortal.Api.Infrastructure.Http;
/// <summary>
/// Typed HttpClient for DummyJSON's authenticated endpoints.
/// The Bearer token is injected by <see cref="DummyJsonAuthHandler"/> —
/// this class never touches tokens directly.
/// </summary>
public sealed class DummyJsonClient : IDummyJsonClient
{
private readonly HttpClient _http;
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public DummyJsonClient(HttpClient http) => _http = http;
public async Task<ProductsResponse> GetProductsAsync(
int skip = 0, int limit = 30, CancellationToken ct = default)
{
return await _http.GetFromJsonAsync<ProductsResponse>(
$"/auth/products?limit={limit}&skip={skip}", _jsonOptions, ct)
?? throw new InvalidOperationException("Null response from /auth/products");
}
public async Task<Product> GetProductByIdAsync(
int id, CancellationToken ct = default)
{
return await _http.GetFromJsonAsync<Product>(
$"/auth/products/{id}", _jsonOptions, ct)
?? throw new InvalidOperationException($"Null response from /auth/products/{id}");
}
public async Task<TodosResponse> GetTodosAsync(
int skip = 0, int limit = 30, CancellationToken ct = default)
{
return await _http.GetFromJsonAsync<TodosResponse>(
$"/auth/todos?limit={limit}&skip={skip}", _jsonOptions, ct)
?? throw new InvalidOperationException("Null response from /auth/todos");
}
public async Task<Todo> GetTodoByIdAsync(
int id, CancellationToken ct = default)
{
return await _http.GetFromJsonAsync<Todo>(
$"/auth/todos/{id}", _jsonOptions, ct)
?? throw new InvalidOperationException($"Null response from /auth/todos/{id}");
}
}using ErpPortal.Api.Core.Contracts;
using Microsoft.AspNetCore.Mvc;
namespace ErpPortal.Api.Controllers;
/// <summary>
/// Exposes a login endpoint that triggers the DummyJSON JWT acquisition.
/// Useful for health checks and explicit token refresh requests.
/// </summary>
[ApiController]
[Route("api/[controller]")]
public sealed class AuthController : ControllerBase
{
private readonly ITokenService _tokenService;
private readonly ILogger<AuthController> _logger;
public AuthController(
ITokenService tokenService,
ILogger<AuthController> logger)
{
_tokenService = tokenService;
_logger = logger;
}
/// <summary>
/// POST /api/auth/login — acquires a fresh DummyJSON token.
/// Returns 200 with the token (for debugging/testing) or 500 on failure.
/// </summary>
[HttpPost("login")]
public async Task<IActionResult> Login(CancellationToken ct)
{
try
{
string token = await _tokenService.GetAccessTokenAsync(ct);
_logger.LogInformation("Token acquired via API login endpoint");
return Ok(new { message = "Authenticated", tokenPreview = token[..20] + "..." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to acquire DummyJSON token");
return StatusCode(500, new { error = "Authentication failed", detail = ex.Message });
}
}
/// <summary>
/// POST /api/auth/invalidate — forces token cache invalidation.
/// </summary>
[HttpPost("invalidate")]
public async Task<IActionResult> Invalidate()
{
await _tokenService.InvalidateAsync();
return Ok(new { message = "Token cache cleared" });
}
}using ErpPortal.Api.Core.Contracts;
using ErpPortal.Api.Core.Domain;
using Microsoft.AspNetCore.Mvc;
namespace ErpPortal.Api.Controllers;
/// <summary>
/// Proxies requests to DummyJSON's /auth/products (protected endpoint).
/// The DummyJSON JWT is managed transparently by TokenService + DummyJsonAuthHandler.
/// </summary>
[ApiController]
[Route("api/[controller]")]
public sealed class ProductsController : ControllerBase
{
private readonly IDummyJsonClient _client;
public ProductsController(IDummyJsonClient client) => _client = client;
[HttpGet]
public async Task<ActionResult<ProductsResponse>> GetAll(
[FromQuery] int skip = 0,
[FromQuery] int limit = 30,
CancellationToken ct = default)
{
ProductsResponse result = await _client.GetProductsAsync(skip, limit, ct);
return Ok(result);
}
[HttpGet("{id:int}")]
public async Task<ActionResult<Product>> GetById(
int id, CancellationToken ct = default)
{
Product product = await _client.GetProductByIdAsync(id, ct);
return Ok(product);
}
}using ErpPortal.Api.Core.Contracts;
using ErpPortal.Api.Core.Domain;
using Microsoft.AspNetCore.Mvc;
namespace ErpPortal.Api.Controllers;
/// <summary>
/// Proxies requests to DummyJSON's /auth/todos (protected endpoint).
/// </summary>
[ApiController]
[Route("api/[controller]")]
public sealed class TodosController : ControllerBase
{
private readonly IDummyJsonClient _client;
public TodosController(IDummyJsonClient client) => _client = client;
[HttpGet]
public async Task<ActionResult<TodosResponse>> GetAll(
[FromQuery] int skip = 0,
[FromQuery] int limit = 30,
CancellationToken ct = default)
{
TodosResponse result = await _client.GetTodosAsync(skip, limit, ct);
return Ok(result);
}
[HttpGet("{id:int}")]
public async Task<ActionResult<Todo>> GetById(
int id, CancellationToken ct = default)
{
Todo todo = await _client.GetTodoByIdAsync(id, ct);
return Ok(todo);
}
}// ErpPortal.Api/Program.cs
using ErpPortal.Api.Core.Config;
using ErpPortal.Api.Core.Contracts;
using ErpPortal.Api.Infrastructure.Http;
using ErpPortal.Api.Infrastructure.Services;
using Microsoft.Extensions.Options;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// ─── Configuration Validation ─────────────────────────────────────────────────
builder.Services
.AddOptions<DummyJsonSettings>()
.BindConfiguration(DummyJsonSettings.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
// ─── Token Management (Singleton — shared across all requests) ────────────────
builder.Services.AddSingleton<ITokenService, TokenService>();
// ─── "Raw" HttpClient (no auth handler) — used by TokenService for login/refresh
builder.Services.AddHttpClient("DummyJsonRaw", (sp, client) =>
{
DummyJsonSettings settings = sp.GetRequiredService<IOptions<DummyJsonSettings>>().Value;
client.BaseAddress = new Uri(settings.BaseUrl);
client.DefaultRequestHeaders.Add("Accept", "application/json");
});
// ─── Typed HttpClient with Auth Handler (for protected /auth/* endpoints) ─────
builder.Services.AddTransient<DummyJsonAuthHandler>();
builder.Services
.AddHttpClient<IDummyJsonClient, DummyJsonClient>((sp, client) =>
{
DummyJsonSettings settings = sp.GetRequiredService<IOptions<DummyJsonSettings>>().Value;
client.BaseAddress = new Uri(settings.BaseUrl);
client.DefaultRequestHeaders.Add("Accept", "application/json");
})
.AddHttpMessageHandler<DummyJsonAuthHandler>();
// ─── Controllers & Swagger ────────────────────────────────────────────────────
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// ─── Build ────────────────────────────────────────────────────────────────────
WebApplication app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.MapControllers();
app.Run();Important
Two Named HttpClients — Why?
The "DummyJsonRaw" named client has no DummyJsonAuthHandler attached. It is used exclusively by TokenService to call /auth/login and /auth/refresh — endpoints that don't require (and shouldn't have) a Bearer token. The typed IDummyJsonClient registration uses the auth handler for all /auth/products and /auth/todos calls. This prevents a circular dependency where the handler needs a token but the token service needs an HttpClient.
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>ErpPortal.Api</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.*" />
</ItemGroup>
</Project># Start the API
cd ErpPortal.Api
dotnet run
# → Listening on https://localhost:5002
# Open Swagger UI
# https://localhost:5002/swagger# 1. Trigger login (acquires and caches the DummyJSON JWT)
curl -X POST https://localhost:5002/api/auth/login
# → { "message": "Authenticated", "tokenPreview": "eyJhbGciOiJIUzI1Ni..." }
# 2. Fetch protected products (JWT is injected automatically by the handler)
curl https://localhost:5002/api/products?limit=3
# → { "products": [...], "total": 194, "skip": 0, "limit": 3 }
# 3. Fetch protected todos
curl https://localhost:5002/api/todos?limit=5
# → { "todos": [...], "total": 254, "skip": 0, "limit": 5 }
# 4. Force token invalidation (next request will re-authenticate)
curl -X POST https://localhost:5002/api/auth/invalidate
# → { "message": "Token cache cleared" }To have the main Blazor SSR app talk to this API gateway instead of DummyJSON directly, update its appsettings.json:
{
"ApiSettings": {
"BaseUrl": "https://localhost:5002/api"
}
}And update the repository endpoints from /users to /products, /todos, etc. The Blazor app no longer manages any DummyJSON JWT — the API gateway handles it entirely.
| Symptom | Cause | Fix |
|---|---|---|
401 from DummyJSON on first request |
Credentials in appsettings.json are wrong |
Verify DummyJson:Username and DummyJson:Password match a valid user from dummyjson.com/users |
| Infinite retry loop on 401 | DummyJsonAuthHandler retries forever |
Handler retries exactly once; check that TokenService.InvalidateAsync() clears the cache |
InvalidOperationException: Null response |
DummyJSON returned unexpected JSON shape | Verify [JsonPropertyName] attributes match the actual API response fields |
| Circular dependency at startup | DummyJsonAuthHandler → ITokenService → IHttpClientFactory → DummyJsonAuthHandler |
TokenService uses the "DummyJsonRaw" named client (no handler); the typed client uses the handler. No cycle. |
| Token expires during long-running batch | Proactive refresh margin too small | Increase the 60-second safety margin in TokenService.GetAccessTokenAsync or decrease TokenExpiryMinutes |
SemaphoreSlim deadlock |
await inside a lock statement |
The code correctly uses SemaphoreSlim.WaitAsync() (async-safe). Never use lock with await. |
Tip
Extending the Gateway
To add more DummyJSON protected routes (e.g., /auth/carts, /auth/users), simply:
- Add the domain record in
Core/Domain/. - Add the method signature to
IDummyJsonClient. - Implement it in
DummyJsonClientpointing to/auth/{resource}. - Create a new controller in
Controllers/.
The token lifecycle, handler, and retry logic are fully reusable — zero changes needed in the infrastructure layer.