Compare commits

...

22 Commits

Author SHA1 Message Date
OlgunR
ed3e7d4043 Add cookie/JWT-based authentication and user context headers
Introduce a new authentication mechanism using JWT tokens stored in cookies, with a custom CookieAuthHandler for API request authentication. Add AuthServiceSettings for configuration and UserHeaderHandler to propagate user context in outgoing HTTP requests. Update service registrations and configuration files to support the new authentication flow. Refactor CurrentUserService for simplicity. This enables stateless, cookie-based authentication and consistent user context across API calls.
2026-05-13 13:46:45 +02:00
OlgunR
de5d1b666c Add current user service and use for catalog audit fields
Introduce ICurrentUserService and its implementation to access the current user's username. Inject this service into CreateCatalogHandler and UpdateCatalogHandler to set AddedWho and ChangedWho fields dynamically. Register the service and IHttpContextAccessor in DI, and enable authentication middleware. Update project and using statements accordingly.
2026-05-13 09:05:04 +02:00
OlgunR
91ee044b73 HAKAN'S REQUIREMENT - Refactor API base URL configuration structure
Replaced ApiBaseUrl and DataApiBaseUrl with BaseUrl, ApiDefaultUrl, and service-specific configuration sections (AuthService, UserManagerService, DbFirstService). Updated AppSettings and all code references to use the new structure. Revised logic for HTTP client base URL selection to prefer service-specific settings. Updated appsettings files and added an example configuration for clarity and maintainability.
2026-05-12 16:34:26 +02:00
OlgunR
1ad267e409 Add authentication support with login/logout UI
- Introduced AuthService, IAuthApiClient, and AuthApiClient for managing authentication state and API calls (login, logout, session restore).
- Added Login.razor and LoginLayout.razor for the login page, including styling and logic.
- MainLayout.razor now checks authentication on load, restores sessions from sessionStorage, and redirects to /login if unauthenticated. Displays username and logout button when logged in.
- Implemented JS interop (authStorage) for persisting authentication info in sessionStorage.
- Registered AuthService, CookieContainer, and API clients in Program.cs to share cookies and support authentication.
- Updated AppSettings and appsettings files to support separate ApiBaseUrl and DataApiBaseUrl.
- Minor CSS improvements for username display in the top bar.
2026-05-12 16:32:46 +02:00
OlgunR
45011122b2 Refactor API clients to use primary constructor for HttpClient
Refactored CatalogApiClient, DashboardApiClient, LayoutApiClient, and MassDataApiClient to use C# primary constructor syntax for injecting HttpClient. Removed private _httpClient fields and updated all usages to reference the constructor parameter directly. This change simplifies the code and modernizes dependency injection without altering any API logic.
2026-05-11 17:08:52 +02:00
OlgunR
1b67d0472e Refactor EditFormTemplate context initialization
Refactored the EditFormTemplate block to separate SetEditContext and editModel initialization for improved clarity. Removed the call to SetPopupHeaderText(editModel.IsNew) from this section to streamline context setup and avoid potential logic issues.
2026-05-11 16:39:45 +02:00
OlgunR
a007842ab0 Refactor Amount field to use decimal and DxSpinEdit
Replaced string-based Amount handling with a decimal property in the edit model. Switched input from DxTextBox to DxSpinEdit, removing manual parsing and validation logic for Amount. This improves data binding, input reliability, and code clarity.
2026-05-11 16:28:55 +02:00
OlgunR
bf98432e20 Refactor query string construction in GetAllAsync
Refactored the GetAllAsync method to use a Dictionary and QueryHelpers.AddQueryString for building query strings, replacing manual string concatenation. This improves code clarity and reduces the risk of formatting errors.
2026-05-11 15:59:07 +02:00
OlgunR
d4b7f02c5e Move band layout init to OnAfterRenderAsync on first render
Moved InitializeBandLayoutAsync() from OnInitializedAsync() to OnAfterRenderAsync() in CatalogsGrid.razor and MassDataGrid.razor. Now initialization occurs only on first render, followed by StateHasChanged(), to ensure proper layout setup after initial rendering. This addresses potential timing or rendering issues.
2026-05-11 15:47:00 +02:00
OlgunR
a0297d40a8 Refactor API base URL config to use AppSettings object
Replaced direct configuration access for the API base URL with retrieval from an AppSettings object. Updated HttpClient configuration to use appSettings.ApiBaseUrl, improving consistency and maintainability. Existing AppSettings DI registration is retained.
2026-05-11 15:13:28 +02:00
OlgunR
1c00449186 Improve error handling for problem details deserialization
Explicitly catch JsonException and NotSupportedException when reading problem details from HTTP responses. Add comments to clarify that these errors are ignored since problem details are optional, making error handling more precise and avoiding unintended exception swallowing.
2026-05-11 14:24:46 +02:00
OlgunR
8a22217866 Refactor BandGridBase methods to async for UI reliability
Refactored several methods in BandGridBase<TItem> to async Task and updated their invocations to use await. EventCallbacks for date filter changes now use async lambdas. Awaited InvokeAsync(StateHasChanged) to ensure UI updates after async operations. These changes improve UI state consistency and reliability in Blazor.
2026-05-11 13:57:01 +02:00
OlgunR
a6a17991bb Refactor theme change handler for safe async updates
Refactored OnThemeChanged in MainLayout.razor to use InvokeAsync for proper synchronization of UI updates and async logic, preventing threading issues. Also added a blank line after app.Run() in Program.cs (no functional impact).
2026-05-11 13:36:44 +02:00
OlgunR
1112fa215c Sync date filter UI with filter criteria in BandGridBase
Added logic to synchronize the date filter UI with the current
filter criteria by updating _filterFrom and _filterTo based on
the CriteriaOperator. Introduced SyncDateFilterFromContext and
ParseDateOperand helpers to extract and apply "from" and "to"
date values, ensuring UI and filter state remain consistent.
2026-05-11 10:27:13 +02:00
OlgunR
f0259e3f78 Sync date filter UI with criteria; restore dropdown footer
Add _filterContexts to track filter menu contexts per field and update their FilterCriteria when criteria change, ensuring the date filter UI stays in sync. Remove CSS that hid the native Apply/Clear footer in the date filter dropdown.
2026-05-11 10:00:02 +02:00
OlgunR
d9785baf5b Add custom date range filter UI for grid date columns
Introduced a custom date range filter menu for date columns in BandGridBase<TItem> using DevExpress Blazor grids. The new UI provides "from" and "to" date pickers, applies filters immediately on selection, and hides the default filter dropdown footer for a smoother user experience. State management and filter criteria logic were added to support this feature.
2026-05-11 09:49:17 +02:00
OlgunR
9dc65ab92f Adjust dark theme dropdown colors and toolbar spacing
Added custom colors for .dxbl-btn-dropdown-popup in dark mode to improve dropdown item visibility. Also added Bootstrap me-2 class to the "Spalten" toolbar button for better spacing.
2026-05-08 11:06:55 +02:00
OlgunR
8933deec96 Add dark mode override for non-native DevExpress themes
Implements a dark mode override system for DevExpress Blazor themes lacking native dark support. Adds a JS function to toggle a dx-dark class on <html>, updates ThemeState to detect native dark themes, and applies targeted CSS variable overrides for consistent dark styling. Disables prerendering to ensure JS interop, and improves theme switching logic and documentation.
2026-05-05 16:41:15 +02:00
OlgunR
2010673eba Remove unused menu state and toggle logic from NavMenu
Removed the private menuOpen field and ToggleMenu() method from NavMenu.razor, as they are no longer needed for menu state management.
2026-04-30 15:41:05 +02:00
OlgunR
b75e7d730c Modernize sidebar with DxTreeView and new responsive styles
Refactor NavMenu to use DevExpress DxTreeView for navigation, replacing the old NavLink-based menu. Update sidebar and navigation row styling to use CSS variables, remove Bootstrap-specific and SVG icon CSS, and add a responsive hamburger menu for small screens. Improve dark mode support and overall maintainability.
2026-04-30 15:37:04 +02:00
OlgunR
075433c780 Improve spacing for Dark Mode button in top row
Wrapped the Dark Mode toggle button in a span with left margin
for better separation from the theme combo box. Added a new
.btn-gap CSS class to standardize button spacing in the top row.
2026-04-23 15:59:50 +02:00
OlgunR
35e39ff979 Add theme selection dropdown and refactor theme handling
Introduce a DxComboBox in MainLayout for selecting between multiple themes. Update ThemeState to manage the current theme, provide a list of available themes, and apply the selected theme via a new SetTheme method. Refactor dark mode handling to work with the new theme system, and ensure UI updates on theme or mode changes.
2026-04-23 15:44:26 +02:00
41 changed files with 1240 additions and 278 deletions

View File

@@ -28,7 +28,7 @@ public static class DashboardConfiguratorFactory
} }
var dashboardBaseUrl = configuration["Dashboard:BaseUrl"] var dashboardBaseUrl = configuration["Dashboard:BaseUrl"]
?? configuration["ApiBaseUrl"] ?? configuration["BaseUrl"]
?? configuration["ASPNETCORE_URLS"]?.Split(';', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? configuration["ASPNETCORE_URLS"]?.Split(';', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()
?? "https://localhost:7204"; ?? "https://localhost:7204";

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>

View File

@@ -1,11 +1,14 @@
using DbFirst.API.Dashboards; using DbFirst.API.Dashboards;
using DbFirst.API.Hubs; using DbFirst.API.Hubs;
using DbFirst.API.Middleware; using DbFirst.API.Middleware;
using DbFirst.API.Services;
using DbFirst.Application; using DbFirst.Application;
using DbFirst.Application.Abstractions;
using DbFirst.Infrastructure; using DbFirst.Infrastructure;
using DevExpress.AspNetCore; using DevExpress.AspNetCore;
using DevExpress.DashboardAspNetCore; using DevExpress.DashboardAspNetCore;
using DevExpress.DashboardWeb; using DevExpress.DashboardWeb;
using Microsoft.AspNetCore.Authentication;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -45,12 +48,20 @@ builder.Services.AddCors(options =>
builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddApplication(); builder.Services.AddApplication();
builder.Services.Configure<AuthServiceSettings>(
builder.Configuration.GetSection("AuthService"));
builder.Services.AddAuthentication("CookieAuth")
.AddScheme<AuthenticationSchemeOptions, CookieAuthHandler>("CookieAuth", _ => { });
builder.Services.AddDevExpressControls(); builder.Services.AddDevExpressControls();
builder.Services.AddSignalR(); builder.Services.AddSignalR();
builder.Services.AddSingleton<IDashboardChangeNotifier, DashboardChangeNotifier>(); builder.Services.AddSingleton<IDashboardChangeNotifier, DashboardChangeNotifier>();
builder.Services.AddScoped<DashboardConfigurator>(sp => builder.Services.AddScoped<DashboardConfigurator>(sp =>
DashboardConfiguratorFactory.Create(sp, builder.Configuration, builder.Environment)); DashboardConfiguratorFactory.Create(sp, builder.Configuration, builder.Environment));
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
var app = builder.Build(); var app = builder.Build();
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
@@ -65,6 +76,7 @@ app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseDevExpressControls(); app.UseDevExpressControls();
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseCors(); app.UseCors();
app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.MapDashboardRoute("api/dashboard", "DefaultDashboard"); app.MapDashboardRoute("api/dashboard", "DefaultDashboard");

View File

@@ -0,0 +1,10 @@
namespace DbFirst.API.Services
{
public class AuthServiceSettings
{
public string BaseUrl { get; set; } = string.Empty;
public string Login { get; set; } = string.Empty;
public string Logout { get; set; } = string.Empty;
public string Check { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,112 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
namespace DbFirst.API.Services;
/// <summary>
/// Authentifiziert eingehende API-Requests anhand des JWT-Tokens im Cookie.
/// Das Token wird lokal dekodiert ohne Rückruf zum Auth-Service da es
/// self-contained ist (Claim "unique_name" enthält den Benutzernamen).
/// </summary>
public class CookieAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
System.Text.Encodings.Web.UrlEncoder encoder)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var log = logger.CreateLogger<CookieAuthHandler>();
// 1. Cookie-Header lesen
var cookieHeader = Request.Headers.Cookie.ToString();
if (string.IsNullOrEmpty(cookieHeader))
{
log.LogDebug("CookieAuthHandler: Kein Cookie-Header vorhanden.");
return Task.FromResult(AuthenticateResult.Fail("Kein Cookie vorhanden."));
}
// 2. JWT aus dem "AuthToken"-Cookie extrahieren und Benutzernamen dekodieren
var userName = TryExtractUserNameFromCookieJwt(cookieHeader);
// 3. Fallback: X-Authenticated-User-Header
if (string.IsNullOrEmpty(userName))
userName = Request.Headers["X-Authenticated-User"].ToString();
if (string.IsNullOrEmpty(userName))
{
log.LogDebug("CookieAuthHandler: Kein Benutzername aus Token oder Header ermittelbar.");
return Task.FromResult(AuthenticateResult.Fail("Kein Benutzername ermittelbar."));
}
log.LogDebug("CookieAuthHandler: Authentifizierung erfolgreich für '{User}'.", userName);
var claims = new[] { new Claim(ClaimTypes.Name, userName) };
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
/// <summary>
/// Liest den "AuthToken"-Wert aus dem Cookie-Header und extrahiert
/// den "unique_name"-Claim aus dem JWT-Payload (Base64Url-dekodiert).
/// Keine Signaturprüfung das Token wurde bereits beim Login/Restore
/// durch den Auth-Service validiert.
/// </summary>
private static string? TryExtractUserNameFromCookieJwt(string cookieHeader)
{
foreach (var segment in cookieHeader.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
var eqIdx = segment.IndexOf('=');
if (eqIdx < 0) continue;
var name = segment[..eqIdx].Trim();
if (!name.Equals("AuthToken", StringComparison.OrdinalIgnoreCase))
continue;
var token = segment[(eqIdx + 1)..].Trim();
return DecodeJwtClaim(token, "unique_name");
}
return null;
}
/// <summary>
/// Dekodiert den Payload-Teil eines JWT (Base64Url) und gibt den Wert
/// des angegebenen Claims zurück.
/// </summary>
private static string? DecodeJwtClaim(string jwt, string claimName)
{
try
{
var parts = jwt.Split('.');
if (parts.Length != 3) return null;
// Base64Url → Base64 → bytes → UTF-8 string
var base64 = parts[1].Replace('-', '+').Replace('_', '/');
base64 = (base64.Length % 4) switch
{
2 => base64 + "==",
3 => base64 + "=",
_ => base64
};
var payloadJson = Encoding.UTF8.GetString(Convert.FromBase64String(base64));
using var doc = JsonDocument.Parse(payloadJson);
if (doc.RootElement.TryGetProperty(claimName, out var prop))
return prop.GetString();
}
catch
{
// Fehlerhafte Token stillschweigend ignorieren
}
return null;
}
}

View File

@@ -0,0 +1,9 @@
using DbFirst.Application.Abstractions;
namespace DbFirst.API.Services;
public class CurrentUserService(IHttpContextAccessor httpContextAccessor) : ICurrentUserService
{
public string UserName =>
httpContextAccessor.HttpContext?.User.Identity?.Name ?? "unknown";
}

View File

@@ -2,7 +2,31 @@
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning",
"DbFirst.API.Services.CookieAuthHandler": "Debug"
} }
} },
"AuthService": {
"BaseUrl": "http://172.24.12.39:9090/",
"Login": "api/Auth/db-first/login",
"Logout": "api/Auth/logout",
"Check": "api/Auth/check"
},
"ConnectionStrings": {
"DefaultConnection": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;TrustServerCertificate=True;",
"MassDataConnection": "Server=SDD-VMP04-SQL19\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;TrustServerCertificate=True;"
},
"Cors": {
"AllowedOrigins": [
"https://localhost:7276",
"http://localhost:5101"
]
},
"Dashboard": {
"BaseUrl": "https://localhost:7204"
},
"BrowserLink": {
"Enabled": false
},
"DetailedErrors": true
} }

View File

@@ -1,34 +1,25 @@
{ {
"ConnectionStrings": {
"DefaultConnection": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;TrustServerCertificate=True;",
"MassDataConnection": "Server=SDD-VMP04-SQL19\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;TrustServerCertificate=True;"
},
"Dashboard": {
"BaseUrl": "https://localhost:7204"
},
"Cors": {
"AllowedOrigins": [
"https://localhost:7276",
"http://localhost:5101"
]
},
"TableConfigurations": {
"VwmyCatalog": {
"ViewName": "VWMY_CATALOG",
"GuidColumnName": "GUID",
"CatTitleColumnName": "CAT_TITLE",
"CatStringColumnName": "CAT_STRING",
"AddedWhoColumnName": "ADDED_WHO",
"AddedWhenColumnName": "ADDED_WHEN",
"ChangedWhoColumnName": "CHANGED_WHO",
"ChangedWhenColumnName": "CHANGED_WHEN"
}
},
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"AllowedHosts": "*" "AllowedHosts": "*",
"AuthService": {
"BaseUrl": null,
"Login": "api/Auth/db-first/login",
"Logout": "api/Auth/logout",
"Check": "api/Auth/check"
},
"ConnectionStrings": {
"DefaultConnection": null,
"MassDataConnection": null
},
"Cors": {
"AllowedOrigins": []
},
"Dashboard": {
"BaseUrl": null
}
} }

View File

@@ -0,0 +1,7 @@
namespace DbFirst.Application.Abstractions
{
public interface ICurrentUserService
{
string UserName { get; }
}
}

View File

@@ -1,4 +1,5 @@
using AutoMapper; using AutoMapper;
using DbFirst.Application.Abstractions;
using DbFirst.Application.Repositories; using DbFirst.Application.Repositories;
using DbFirst.Contracts.Catalogs; using DbFirst.Contracts.Catalogs;
using DbFirst.Domain.Entities; using DbFirst.Domain.Entities;
@@ -10,11 +11,13 @@ public class CreateCatalogHandler : IRequestHandler<CreateCatalogCommand, Catalo
{ {
private readonly ICatalogRepository _repository; private readonly ICatalogRepository _repository;
private readonly IMapper _mapper; private readonly IMapper _mapper;
private readonly ICurrentUserService _currentUserService;
public CreateCatalogHandler(ICatalogRepository repository, IMapper mapper) public CreateCatalogHandler(ICatalogRepository repository, IMapper mapper, ICurrentUserService currentUserService)
{ {
_repository = repository; _repository = repository;
_mapper = mapper; _mapper = mapper;
_currentUserService = currentUserService;
} }
public async Task<CatalogReadDto?> Handle(CreateCatalogCommand request, CancellationToken cancellationToken) public async Task<CatalogReadDto?> Handle(CreateCatalogCommand request, CancellationToken cancellationToken)
@@ -26,9 +29,9 @@ public class CreateCatalogHandler : IRequestHandler<CreateCatalogCommand, Catalo
} }
var entity = _mapper.Map<VwmyCatalog>(request.Dto); var entity = _mapper.Map<VwmyCatalog>(request.Dto);
entity.AddedWho = "system"; entity.AddedWho = _currentUserService.UserName;
entity.AddedWhen = DateTime.UtcNow; entity.AddedWhen = DateTime.UtcNow;
entity.ChangedWho = "system"; entity.ChangedWho = _currentUserService.UserName;
entity.ChangedWhen = DateTime.UtcNow; entity.ChangedWhen = DateTime.UtcNow;
var created = await _repository.InsertAsync(entity, cancellationToken); var created = await _repository.InsertAsync(entity, cancellationToken);

View File

@@ -1,4 +1,5 @@
using AutoMapper; using AutoMapper;
using DbFirst.Application.Abstractions;
using DbFirst.Application.Repositories; using DbFirst.Application.Repositories;
using DbFirst.Contracts.Catalogs; using DbFirst.Contracts.Catalogs;
using DbFirst.Domain; using DbFirst.Domain;
@@ -11,11 +12,13 @@ public class UpdateCatalogHandler : IRequestHandler<UpdateCatalogCommand, Catalo
{ {
private readonly ICatalogRepository _repository; private readonly ICatalogRepository _repository;
private readonly IMapper _mapper; private readonly IMapper _mapper;
private readonly ICurrentUserService _currentUserService;
public UpdateCatalogHandler(ICatalogRepository repository, IMapper mapper) public UpdateCatalogHandler(ICatalogRepository repository, IMapper mapper, ICurrentUserService currentUserService)
{ {
_repository = repository; _repository = repository;
_mapper = mapper; _mapper = mapper;
_currentUserService = currentUserService;
} }
public async Task<CatalogReadDto?> Handle(UpdateCatalogCommand request, CancellationToken cancellationToken) public async Task<CatalogReadDto?> Handle(UpdateCatalogCommand request, CancellationToken cancellationToken)
@@ -36,7 +39,7 @@ public class UpdateCatalogHandler : IRequestHandler<UpdateCatalogCommand, Catalo
entity.Guid = request.Id; entity.Guid = request.Id;
entity.AddedWho = existing.AddedWho; entity.AddedWho = existing.AddedWho;
entity.AddedWhen = existing.AddedWhen; entity.AddedWhen = existing.AddedWhen;
entity.ChangedWho = "system"; entity.ChangedWho = _currentUserService.UserName;
entity.ChangedWhen = DateTime.UtcNow; entity.ChangedWhen = DateTime.UtcNow;
var updated = await _repository.UpdateAsync(request.Id, entity, request.Dto.UpdateProcedure, cancellationToken); var updated = await _repository.UpdateAsync(request.Id, entity, request.Dto.UpdateProcedure, cancellationToken);

View File

@@ -2,5 +2,15 @@
public class AppSettings public class AppSettings
{ {
public string ApiBaseUrl { get; set; } = string.Empty; public string BaseUrl { get; set; } = string.Empty;
public string ApiDefaultUrl { get; set; } = string.Empty;
public AuthServiceSettings AuthService { get; set; } = new();
}
public class AuthServiceSettings
{
public string BaseUrl { get; set; } = string.Empty;
public string Login { get; set; } = string.Empty;
public string Logout { get; set; } = string.Empty;
public string Check { get; set; } = string.Empty;
} }

View File

@@ -24,6 +24,26 @@
<link rel="stylesheet" href="DbFirst.BlazorWebApp.styles.css" /> <link rel="stylesheet" href="DbFirst.BlazorWebApp.styles.css" />
<link rel="icon" type="image/png" href="favicon.png" /> <link rel="icon" type="image/png" href="favicon.png" />
<script src="js/size-manager.js"></script> <script src="js/size-manager.js"></script>
<script>
window.setDxDarkOverride = function (enabled) {
if (enabled)
document.documentElement.classList.add('dx-dark');
else
document.documentElement.classList.remove('dx-dark');
};
window.authStorage = {
get: function (key) { return sessionStorage.getItem(key); },
set: function (username, cookie) {
sessionStorage.setItem('auth_user', username);
sessionStorage.setItem('auth_cookie', cookie);
},
clear: function () {
sessionStorage.removeItem('auth_user');
sessionStorage.removeItem('auth_cookie');
}
};
</script>
<HeadOutlet /> <HeadOutlet />
</head> </head>

View File

@@ -1,6 +1,7 @@
using DbFirst.BlazorWebApp.Models.Grid; using DbFirst.BlazorWebApp.Models.Grid;
using DbFirst.BlazorWebApp.Services; using DbFirst.BlazorWebApp.Services;
using DevExpress.Blazor; using DevExpress.Blazor;
using DevExpress.Data.Filtering;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms; using Microsoft.AspNetCore.Components.Forms;
using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Rendering;
@@ -24,6 +25,16 @@ public abstract class BandGridBase<TItem> : ComponentBase
protected bool gridLayoutApplied; protected bool gridLayoutApplied;
protected IGrid? gridRef; protected IGrid? gridRef;
// --- Datumsfilter-Zustand ---
private readonly Dictionary<string, DateTime?> _filterFrom = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, DateTime?> _filterTo = new(StringComparer.OrdinalIgnoreCase);
// Stabile Referenzen: werden einmal pro FieldName erstellt und wiederverwendet
private readonly Dictionary<string, EventCallback<DateTime?>> _fromCallbacks = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, EventCallback<DateTime?>> _toCallbacks = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, RenderFragment<GridDataColumnFilterMenuTemplateContext>> _dateFilterTemplates = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, GridDataColumnFilterMenuTemplateContext> _filterContexts = new(StringComparer.OrdinalIgnoreCase);
// --- SizeMode --- // --- SizeMode ---
protected SizeMode _sizeMode = SizeMode.Medium; protected SizeMode _sizeMode = SizeMode.Medium;
protected static readonly List<SizeMode> _sizeModes = Enum.GetValues<SizeMode>().ToList(); protected static readonly List<SizeMode> _sizeModes = Enum.GetValues<SizeMode>().ToList();
@@ -121,13 +132,13 @@ public abstract class BandGridBase<TItem> : ComponentBase
UpdateBandOptions(); UpdateBandOptions();
} }
protected void RemoveBand(BandDefinition band) protected async Task RemoveBand(BandDefinition band)
{ {
bandLayout.Bands.Remove(band); bandLayout.Bands.Remove(band);
foreach (var key in columnBandAssignments.Where(p => p.Value == band.Id).Select(p => p.Key).ToList()) foreach (var key in columnBandAssignments.Where(p => p.Value == band.Id).Select(p => p.Key).ToList())
columnBandAssignments.Remove(key); columnBandAssignments.Remove(key);
UpdateBandOptions(); UpdateBandOptions();
SyncBandsFromAssignments(); await SyncBandsFromAssignments();
} }
protected void UpdateBandCaption(BandDefinition band, string value) protected void UpdateBandCaption(BandDefinition band, string value)
@@ -136,19 +147,19 @@ public abstract class BandGridBase<TItem> : ComponentBase
UpdateBandOptions(); UpdateBandOptions();
} }
protected void UpdateColumnBand(string fieldName, string? bandId) protected async Task UpdateColumnBand(string fieldName, string? bandId)
{ {
if (string.IsNullOrWhiteSpace(bandId)) if (string.IsNullOrWhiteSpace(bandId))
columnBandAssignments.Remove(fieldName); columnBandAssignments.Remove(fieldName);
else else
columnBandAssignments[fieldName] = bandId; columnBandAssignments[fieldName] = bandId;
SyncBandsFromAssignments(); await SyncBandsFromAssignments();
} }
protected string GetColumnBand(string fieldName) protected string GetColumnBand(string fieldName)
=> columnBandAssignments.TryGetValue(fieldName, out var bandId) ? bandId : string.Empty; => columnBandAssignments.TryGetValue(fieldName, out var bandId) ? bandId : string.Empty;
protected void SyncBandsFromAssignments() protected async Task SyncBandsFromAssignments()
{ {
foreach (var band in bandLayout.Bands) foreach (var band in bandLayout.Bands)
{ {
@@ -157,7 +168,7 @@ public abstract class BandGridBase<TItem> : ComponentBase
.Select(c => c.FieldName) .Select(c => c.FieldName)
.ToList(); .ToList();
} }
_ = InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
protected void UpdateBandOptions() protected void UpdateBandOptions()
@@ -224,9 +235,134 @@ public abstract class BandGridBase<TItem> : ComponentBase
builder.AddAttribute(seq++, "DisplayFormat", column.DisplayFormat); builder.AddAttribute(seq++, "DisplayFormat", column.DisplayFormat);
if (column.ReadOnly) if (column.ReadOnly)
builder.AddAttribute(seq++, "ReadOnly", true); builder.AddAttribute(seq++, "ReadOnly", true);
if (column.FilterType == ColumnFilterType.Date)
builder.AddAttribute(seq++, "FilterMenuTemplate", GetOrCreateDateFilterTemplate(column.FieldName));
builder.CloseComponent(); builder.CloseComponent();
} }
private RenderFragment<GridDataColumnFilterMenuTemplateContext> GetOrCreateDateFilterTemplate(string fieldName)
{
if (!_dateFilterTemplates.TryGetValue(fieldName, out var template))
{
// EventCallbacks einmalig erstellen stabile Referenzen über alle Renders
_fromCallbacks[fieldName] = EventCallback.Factory.Create<DateTime?>(this, async (DateTime? v) => await OnFilterFromChanged(fieldName, v));
_toCallbacks[fieldName] = EventCallback.Factory.Create<DateTime?>(this, async (DateTime? v) => await OnFilterToChanged(fieldName, v));
template = BuildDateFilterTemplate(fieldName);
_dateFilterTemplates[fieldName] = template;
}
return template;
}
private RenderFragment<GridDataColumnFilterMenuTemplateContext> BuildDateFilterTemplate(string fieldName) =>
ctx => b =>
{
_filterContexts[fieldName] = ctx;
SyncDateFilterFromContext(fieldName, ctx.FilterCriteria);
int s = 0;
b.OpenElement(s++, "div");
b.AddAttribute(s++, "class", "date-filter-menu p-2");
// Ab Datum
b.OpenElement(s++, "div");
b.AddAttribute(s++, "class", "mb-2");
b.OpenElement(s++, "label");
b.AddAttribute(s++, "class", "form-label small fw-semibold");
b.AddContent(s++, "Ab Datum");
b.CloseElement();
b.OpenComponent<DxDateEdit<DateTime?>>(s++);
b.AddAttribute(s++, "Date", _filterFrom.GetValueOrDefault(fieldName));
b.AddAttribute(s++, "DateChanged", _fromCallbacks[fieldName]);
b.AddAttribute(s++, "ClearButtonDisplayMode", DataEditorClearButtonDisplayMode.Auto);
b.AddAttribute(s++, "NullText", "Kein Startdatum");
b.AddAttribute(s++, "Width", "100%");
b.CloseComponent();
b.CloseElement();
// Bis Datum
b.OpenElement(s++, "div");
b.AddAttribute(s++, "class", "mb-0");
b.OpenElement(s++, "label");
b.AddAttribute(s++, "class", "form-label small fw-semibold");
b.AddContent(s++, "Bis Datum");
b.CloseElement();
b.OpenComponent<DxDateEdit<DateTime?>>(s++);
b.AddAttribute(s++, "Date", _filterTo.GetValueOrDefault(fieldName));
b.AddAttribute(s++, "DateChanged", _toCallbacks[fieldName]);
b.AddAttribute(s++, "ClearButtonDisplayMode", DataEditorClearButtonDisplayMode.Auto);
b.AddAttribute(s++, "NullText", "Kein Enddatum");
b.AddAttribute(s++, "Width", "100%");
b.CloseComponent();
b.CloseElement();
b.CloseElement();
};
private void SyncDateFilterFromContext(string fieldName, CriteriaOperator? criteria)
{
if (criteria == null)
{
_filterFrom[fieldName] = null;
_filterTo[fieldName] = null;
return;
}
DateTime? from = null;
DateTime? to = null;
if (criteria is GroupOperator group)
{
foreach (var op in group.Operands.OfType<BinaryOperator>())
ParseDateOperand(op, ref from, ref to);
}
else if (criteria is BinaryOperator binary)
{
ParseDateOperand(binary, ref from, ref to);
}
_filterFrom[fieldName] = from;
_filterTo[fieldName] = to;
}
private static void ParseDateOperand(BinaryOperator op, ref DateTime? from, ref DateTime? to)
{
if (op.RightOperand is not OperandValue val || val.Value is not DateTime dt) return;
if (op.OperatorType == BinaryOperatorType.GreaterOrEqual) from = dt;
else if (op.OperatorType == BinaryOperatorType.Less) to = dt.AddDays(-1);
}
private async Task OnFilterFromChanged(string fieldName, DateTime? value)
{
_filterFrom[fieldName] = value;
await ApplyDateFilter(fieldName);
}
private async Task OnFilterToChanged(string fieldName, DateTime? value)
{
_filterTo[fieldName] = value;
await ApplyDateFilter(fieldName);
}
private async Task ApplyDateFilter(string fieldName)
{
var ops = new List<CriteriaOperator>();
if (_filterFrom.TryGetValue(fieldName, out var from) && from.HasValue)
ops.Add(new BinaryOperator(fieldName, from.Value.Date, BinaryOperatorType.GreaterOrEqual));
if (_filterTo.TryGetValue(fieldName, out var to) && to.HasValue)
ops.Add(new BinaryOperator(fieldName, to.Value.Date.AddDays(1), BinaryOperatorType.Less));
CriteriaOperator? criteria = ops.Count switch
{
0 => null,
1 => ops[0],
_ => new GroupOperator(GroupOperatorType.And, ops)
};
gridRef?.SetFieldFilterCriteria(fieldName, criteria);
if (_filterContexts.TryGetValue(fieldName, out var ctx))
ctx.FilterCriteria = criteria;
await InvokeAsync(StateHasChanged);
}
protected void SetEditContext(EditContext context) protected void SetEditContext(EditContext context)
{ {
if (editContext == context) return; if (editContext == context) return;

View File

@@ -87,7 +87,7 @@ else
Click="DeleteFocusedRow" /> Click="DeleteFocusedRow" />
</Template> </Template>
</DxToolbarItem> </DxToolbarItem>
<DxToolbarItem Alignment="ToolbarItemAlignment.Right"> <DxToolbarItem Alignment="ToolbarItemAlignment.Right" CssClass="me-2">
<Template Context="_"> <Template Context="_">
<DxButton Text="Spalten" <DxButton Text="Spalten"
RenderStyle="ButtonRenderStyle.Secondary" RenderStyle="ButtonRenderStyle.Secondary"
@@ -117,7 +117,8 @@ else
</Columns> </Columns>
<EditFormTemplate Context="editFormContext"> <EditFormTemplate Context="editFormContext">
@{ @{
SetEditContext(editFormContext.EditContext); var editModel = (CatalogEditModel)editFormContext.EditModel; SetPopupHeaderText(editModel.IsNew); SetEditContext(editFormContext.EditContext);
var editModel = (CatalogEditModel)editFormContext.EditModel;
} }
<DxFormLayout ColCount="2"> <DxFormLayout ColCount="2">
<DxFormLayoutItem Caption="Titel"> <DxFormLayoutItem Caption="Titel">
@@ -173,12 +174,16 @@ else
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await InitializeBandLayoutAsync();
await LoadCatalogs(); await LoadCatalogs();
} }
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender)
{
await InitializeBandLayoutAsync();
StateHasChanged();
}
await ApplyGridLayoutAfterRenderAsync(); await ApplyGridLayoutAfterRenderAsync();
} }

View File

@@ -0,0 +1,10 @@
@inherits LayoutComponentBase
@inject ThemeState ThemeState
<div class="page @(ThemeState.IsDarkMode ? "app-dark" : "app-light") @(ThemeState.IsNativeDarkTheme ? "native-dark" : "")">
<main>
<article class="content">
@Body
</article>
</main>
</div>

View File

@@ -1,20 +1,49 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@implements IDisposable @implements IDisposable
@inject ThemeState ThemeState @inject ThemeState ThemeState
@inject IJSRuntime JS
@inject AuthService AuthService
@inject IAuthApiClient AuthApiClient
@inject NavigationManager Navigation
<div class="page @(ThemeState.IsDarkMode ? "app-dark" : "app-light")"> <div class="page @(ThemeState.IsDarkMode ? "app-dark" : "app-light") @(ThemeState.IsNativeDarkTheme ? "native-dark" : "")">
<div class="sidebar"> <div class="sidebar">
<NavMenu /> <NavMenu />
</div> </div>
<main> <main>
<div class="top-row px-4"> <div class="top-row px-4">
<DxButton Text="@(ThemeState.IsDarkMode ? "Dark Mode aus" : "Dark Mode an")" Click="ToggleTheme" /> <DxComboBox Data="@ThemeState.AvailableThemes"
Value="@ThemeState.CurrentThemeName"
ValueChanged="@((string t) => ThemeState.SetTheme(t))"
style="width: 130px;" />
<span style="margin-left: 12px;">
<DxButton Text="@(ThemeState.IsDarkMode ? "Dark Mode aus" : "Dark Mode an")"
Click="ToggleTheme" />
</span>
@if (AuthService.IsAuthenticated)
{
<span class="ms-auto d-flex align-items-center gap-2">
<span class="top-row-username">@AuthService.UserName</span>
<DxButton Text="Abmelden"
Click="LogoutAsync"
RenderStyle="ButtonRenderStyle.Secondary" />
</span>
}
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a> <a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</div> </div>
<article class="content px-4"> <article class="content px-4">
@Body @if (_checkingAuth)
{
<div class="loading-container">
<span>Wird geladen…</span>
</div>
}
else
{
@Body
}
</article> </article>
</main> </main>
</div> </div>
@@ -26,9 +55,100 @@
</div> </div>
@code { @code {
private bool _isInteractive;
private bool _checkingAuth = true;
protected override void OnInitialized() protected override void OnInitialized()
{ {
ThemeState.OnChange += StateHasChanged; ThemeState.OnChange += OnThemeChanged;
AuthService.OnChange += OnAuthChanged;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_isInteractive = true;
if (!AuthService.IsAuthenticated)
{
try
{
var username = await JS.InvokeAsync<string?>("authStorage.get", "auth_user");
var cookie = await JS.InvokeAsync<string?>("authStorage.get", "auth_cookie");
if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(cookie))
{
var restored = await AuthApiClient.RestoreAsync(username, cookie);
if (!restored)
{
await JS.InvokeVoidAsync("authStorage.clear");
Navigation.NavigateTo("/login", replace: true);
return;
}
}
else
{
Navigation.NavigateTo("/login", replace: true);
return;
}
}
catch
{
Navigation.NavigateTo("/login", replace: true);
return;
}
}
_checkingAuth = false;
StateHasChanged();
}
await ApplyDxDarkOverrideAsync();
}
private async Task LogoutAsync()
{
try
{
await AuthApiClient.LogoutAsync();
await JS.InvokeVoidAsync("authStorage.clear");
}
catch
{
// Fehler beim Abmelden ignorieren, trotzdem weiterleiten
}
Navigation.NavigateTo("/login", replace: true);
}
private void OnAuthChanged()
{
InvokeAsync(StateHasChanged);
}
private void OnThemeChanged()
{
InvokeAsync(async () =>
{
StateHasChanged();
if (_isInteractive)
await ApplyDxDarkOverrideAsync();
});
}
private async Task ApplyDxDarkOverrideAsync()
{
if (!_isInteractive) return;
try
{
bool needsOverride = ThemeState.IsDarkMode && !ThemeState.IsNativeDarkTheme;
await JS.InvokeVoidAsync("setDxDarkOverride", needsOverride);
}
catch (JSException)
{
// JS-Funktion noch nicht verfügbar kein Circuit-Crash
}
} }
private void ToggleTheme() private void ToggleTheme()
@@ -38,6 +158,7 @@
public void Dispose() public void Dispose()
{ {
ThemeState.OnChange -= StateHasChanged; ThemeState.OnChange -= OnThemeChanged;
AuthService.OnChange -= OnAuthChanged;
} }
} }

View File

@@ -18,11 +18,13 @@ main {
} }
.sidebar { .sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); background-color: var(--dx-color-surface-container, #f4f4f4);
border-right: 1px solid var(--dx-color-outline-variant, #e0e0e0);
} }
.page.app-dark .sidebar { .page.app-dark .sidebar {
background-image: linear-gradient(180deg, #171717 0%, #0f2a46 70%); background-color: var(--dx-color-surface-container, #1e1e1e);
border-right-color: var(--dx-color-outline-variant, #333);
} }
.top-row { .top-row {

View File

@@ -1,36 +1,24 @@
<div class="top-row ps-3 navbar navbar-dark"> <div class="nav-brand-row">
<div class="container-fluid"> <a class="nav-brand-link" href="">DbFirst</a>
<a class="navbar-brand" href="">DbFirst.BlazorWebApp</a> <button class="nav-toggle-btn" @onclick="ToggleMenu" title="Navigation menu">&#9776;</button>
</div>
</div> </div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" /> <div class="nav-scrollable @(menuOpen ? "nav-open" : "")">
<DxTreeView CssClass="sidebar-tree">
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()"> <Nodes>
<nav class="flex-column"> <DxTreeViewNode Text="Home" NavigateUrl="/" IconCssClass="dxi dxi-home" />
<div class="nav-item px-3"> <DxTreeViewNode Text="Data Management" Expanded="true">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All"> <Nodes>
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home <DxTreeViewNode Text="Catalogs" NavigateUrl="/catalogs" IconCssClass="dxi dxi-folder" />
</NavLink> <DxTreeViewNode Text="Dashboards" NavigateUrl="/dashboards" IconCssClass="dxi dxi-chart-bar" />
</div> <DxTreeViewNode Text="Mass Data" NavigateUrl="/massdata" IconCssClass="dxi dxi-table" />
</Nodes>
<div class="nav-item px-3"> </DxTreeViewNode>
<NavLink class="nav-link" href="catalogs"> </Nodes>
<span class="bi bi-collection-nav-menu" aria-hidden="true"></span> Catalogs </DxTreeView>
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="dashboards">
<span class="bi bi-speedometer-nav-menu" aria-hidden="true"></span> Dashboards
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="massdata">
<span class="bi bi-table-nav-menu" aria-hidden="true"></span> MassData
</NavLink>
</div>
</nav>
</div> </div>
@code {
private bool menuOpen = false;
private void ToggleMenu() => menuOpen = !menuOpen;
}

View File

@@ -1,117 +1,52 @@
.navbar-toggler { .nav-brand-row {
appearance: none;
cursor: pointer;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 0.5rem;
right: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
}
.navbar-toggler:checked {
background-color: rgba(255, 255, 255, 0.5);
}
.top-row {
height: 3.5rem; height: 3.5rem;
background-color: rgba(0,0,0,0.4); display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
} }
.navbar-brand { .nav-brand-link {
font-size: 1.1rem; font-size: 1.05rem;
font-weight: 600;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.bi { .nav-toggle-btn {
display: inline-block; display: block;
position: relative; background: none;
width: 1.25rem; border: none;
height: 1.25rem; font-size: 1.4rem;
margin-right: 0.75rem; cursor: pointer;
top: -1px; padding: 0.25rem 0.5rem;
background-size: cover; line-height: 1;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.bi-collection-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M2 3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 .5.5v1H2V3z'/%3E%3Cpath d='M2 5h12v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5z'/%3E%3C/svg%3E");
}
.bi-speedometer-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M1 11a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v4zm5 0a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v8zm5 0a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v2z'/%3E%3C/svg%3E");
}
.bi-table-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M1 2a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2zm1 1v2h12V3H2zm12 3H2v2h12V6zm0 3H2v2h12V9zm0 3H2v1h12v-1z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep .nav-link {
color: #d7d7d7;
background: none;
border: none;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
width: 100%;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep .nav-link:hover {
background-color: rgba(255,255,255,0.1);
color: white;
} }
.nav-scrollable { .nav-scrollable {
display: none; display: none;
} }
.navbar-toggler:checked ~ .nav-scrollable { .nav-scrollable.nav-open {
display: block; display: block;
}
.sidebar-tree {
width: 100%;
} }
@media (min-width: 641px) { @media (min-width: 641px) {
.navbar-toggler { .nav-toggle-btn {
display: none; display: none;
} }
.nav-scrollable { .nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block; display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem); height: calc(100vh - 3.5rem);
overflow-y: auto; overflow-y: auto;
} }
} }

View File

@@ -137,7 +137,7 @@ else
<DxTextBox @bind-Text="editModel.CustomerName" Width="100%" /> <DxTextBox @bind-Text="editModel.CustomerName" Width="100%" />
</DxFormLayoutItem> </DxFormLayoutItem>
<DxFormLayoutItem Caption="Amount"> <DxFormLayoutItem Caption="Amount">
<DxTextBox @bind-Text="editModel.AmountText" Width="100%" /> <DxSpinEdit @bind-Value="editModel.Amount" MinValue="0" DisplayFormat="c2" Width="100%" />
</DxFormLayoutItem> </DxFormLayoutItem>
<DxFormLayoutItem Caption="Category"> <DxFormLayoutItem Caption="Category">
<DxTextBox @bind-Text="editModel.Category" Width="100%" ReadOnly="@(!editModel.IsNew)" /> <DxTextBox @bind-Text="editModel.Category" Width="100%" ReadOnly="@(!editModel.IsNew)" />
@@ -210,12 +210,16 @@ else
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await InitializeBandLayoutAsync();
await LoadPage(0); await LoadPage(0);
} }
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender)
{
await InitializeBandLayoutAsync();
StateHasChanged();
}
await ApplyGridLayoutAfterRenderAsync(); await ApplyGridLayoutAfterRenderAsync();
} }
@@ -281,7 +285,7 @@ else
{ {
Id = item.Id, Id = item.Id,
CustomerName = item.CustomerName, CustomerName = item.CustomerName,
AmountText = item.Amount.ToString("0.00"), Amount = item.Amount,
Category = item.Category, Category = item.Category,
StatusFlag = item.StatusFlag, StatusFlag = item.StatusFlag,
UpdateProcedure = procedureOptions[0].Value, UpdateProcedure = procedureOptions[0].Value,
@@ -298,12 +302,6 @@ else
validationMessageStore?.Clear(); validationMessageStore?.Clear();
editContext?.NotifyValidationStateChanged(); editContext?.NotifyValidationStateChanged();
var editModel = (MassDataEditModel)e.EditModel; var editModel = (MassDataEditModel)e.EditModel;
if (!decimal.TryParse(editModel.AmountText, out var amount))
{
AddValidationError(editModel, nameof(MassDataEditModel.AmountText), "Amount ist ungültig.");
e.Cancel = true;
return;
}
if (editModel.IsNew) if (editModel.IsNew)
{ {
var existing = await Api.GetByCustomerNameAsync(editModel.CustomerName); var existing = await Api.GetByCustomerNameAsync(editModel.CustomerName);
@@ -317,7 +315,7 @@ else
var dto = new MassDataWriteDto var dto = new MassDataWriteDto
{ {
CustomerName = editModel.CustomerName, CustomerName = editModel.CustomerName,
Amount = amount, Amount = editModel.Amount,
Category = editModel.Category, Category = editModel.Category,
StatusFlag = editModel.StatusFlag StatusFlag = editModel.StatusFlag
}; };
@@ -353,7 +351,7 @@ else
{ {
public int Id { get; set; } public int Id { get; set; }
public string CustomerName { get; set; } = string.Empty; public string CustomerName { get; set; } = string.Empty;
public string AmountText { get; set; } = string.Empty; public decimal Amount { get; set; }
public string Category { get; set; } = string.Empty; public string Category { get; set; } = string.Empty;
public bool StatusFlag { get; set; } public bool StatusFlag { get; set; }
public int UpdateProcedure { get; set; } public int UpdateProcedure { get; set; }

View File

@@ -45,8 +45,8 @@
private string SelectedDashboardId { get; set; } = string.Empty; private string SelectedDashboardId { get; set; } = string.Empty;
private string DashboardKey => $"{SelectedDashboardId}-{(IsDesigner ? "designer" : "viewer")}"; private string DashboardKey => $"{SelectedDashboardId}-{(IsDesigner ? "designer" : "viewer")}";
private string DashboardEndpoint => $"{AppSettingsOptions.Value.ApiBaseUrl.TrimEnd('/')}/api/dashboard"; private string DashboardEndpoint => $"{AppSettingsOptions.Value.BaseUrl.TrimEnd('/')}/api/dashboard";
private string HubEndpoint => $"{AppSettingsOptions.Value.ApiBaseUrl.TrimEnd('/')}/hubs/dashboards"; private string HubEndpoint => $"{AppSettingsOptions.Value.BaseUrl.TrimEnd('/')}/hubs/dashboards";
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {

View File

@@ -0,0 +1,106 @@
@page "/login"
@layout DbFirst.BlazorWebApp.Components.Layout.LoginLayout
@rendermode InteractiveServer
@inject IAuthApiClient AuthApiClient
@inject AuthService AuthService
@inject NavigationManager Navigation
@inject IJSRuntime JS
<PageTitle>Anmelden DbFirst</PageTitle>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<span class="login-brand">DbFirst</span>
<p class="login-subtitle">Bitte melden Sie sich an</p>
</div>
<form @onsubmit="HandleLoginAsync" @onsubmit:preventDefault>
<div class="login-field">
<label class="login-label" for="username">Benutzername</label>
<input id="username"
type="text"
class="login-text-input"
placeholder="Benutzername eingeben"
autocomplete="username"
@bind="_username"
@bind:event="oninput" />
</div>
<div class="login-field">
<label class="login-label" for="password">Passwort</label>
<input id="password"
type="password"
class="login-text-input"
placeholder="Passwort eingeben"
autocomplete="current-password"
@bind="_password"
@bind:event="oninput" />
</div>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="alert alert-danger mt-2 mb-1" role="alert">
@_errorMessage
</div>
}
<div class="login-actions">
<button type="submit"
class="login-submit-btn"
disabled="@_isLoading">
@(_isLoading ? "Wird angemeldet…" : "Anmelden")
</button>
</div>
</form>
</div>
</div>
@code {
private string _username = string.Empty;
private string _password = string.Empty;
private string _errorMessage = string.Empty;
private bool _isLoading;
protected override void OnInitialized()
{
if (AuthService.IsAuthenticated)
Navigation.NavigateTo("/");
}
private async Task HandleLoginAsync()
{
if (_isLoading) return;
_errorMessage = string.Empty;
if (string.IsNullOrWhiteSpace(_username) || string.IsNullOrWhiteSpace(_password))
{
_errorMessage = "Bitte Benutzername und Passwort eingeben.";
return;
}
_isLoading = true;
try
{
var success = await AuthApiClient.LoginAsync(_username, _password);
if (success)
{
await JS.InvokeVoidAsync("authStorage.set", AuthService.UserName, AuthService.RawCookieHeader);
Navigation.NavigateTo("/");
}
else
{
_errorMessage = "Anmeldung fehlgeschlagen. Bitte Benutzerdaten prüfen.";
}
}
catch
{
_errorMessage = "Verbindungsfehler. Bitte später erneut versuchen.";
}
finally
{
_isLoading = false;
}
}
}

View File

@@ -0,0 +1,111 @@
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 2rem);
}
.login-card {
width: 100%;
max-width: 400px;
padding: 2rem;
border-radius: 8px;
border: 1px solid var(--band-editor-border, #dee2e6);
background-color: var(--band-editor-bg, #fff);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.login-header {
text-align: center;
margin-bottom: 1.75rem;
}
.login-brand {
font-size: 1.8rem;
font-weight: 700;
color: #1b6ec2;
letter-spacing: 0.02em;
}
.login-subtitle {
margin-top: 0.35rem;
margin-bottom: 0;
font-size: 0.9rem;
color: #6c757d;
}
.login-field {
margin-bottom: 1rem;
}
.login-label {
display: block;
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 0.3rem;
}
.login-actions {
margin-top: 1.25rem;
}
/* Gemeinsamer Stil für Text- und Passwort-Eingabefelder */
.login-text-input {
display: block;
width: 100%;
height: 2.25rem;
padding: 0.25rem 0.65rem;
font-size: 1rem;
font-family: inherit;
line-height: 1.5;
color: inherit;
background-color: transparent;
border: 1px solid #ced4da;
border-radius: 4px;
box-sizing: border-box;
outline: none;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.login-text-input:focus {
border-color: #86b7fe;
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
}
/* Submit-Button */
.login-submit-btn {
display: block;
width: 100%;
padding: 0.45rem 1rem;
font-size: 1rem;
font-family: inherit;
font-weight: 500;
color: #fff;
background-color: #1b6ec2;
border: 1px solid #1861ac;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out;
}
.login-submit-btn:hover:not(:disabled) {
background-color: #1558a0;
border-color: #1456a0;
}
.login-submit-btn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
/* Dark-Mode */
.app-dark .login-text-input {
border-color: #555;
color: #e8e8e8;
background-color: #2d2d2d;
}
.app-dark .login-text-input:focus {
border-color: #6cb6ff;
box-shadow: 0 0 0 0.2rem rgba(108, 182, 255, 0.25);
}

View File

@@ -1,4 +1,5 @@
@rendermode InteractiveServer @rendermode @(new InteractiveServerRenderMode(prerender: false))
<Router AppAssembly="typeof(Program).Assembly"> <Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData"> <Found Context="routeData">

View File

@@ -2,6 +2,7 @@ using DbFirst.BlazorWebApp;
using DbFirst.BlazorWebApp.Components; using DbFirst.BlazorWebApp.Components;
using DbFirst.BlazorWebApp.Services; using DbFirst.BlazorWebApp.Services;
using DevExpress.Blazor; using DevExpress.Blazor;
using System.Net;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -12,19 +13,71 @@ builder.Services.AddRazorComponents()
builder.Services.AddDevExpressBlazor(options => options.BootstrapVersion = BootstrapVersion.v5); builder.Services.AddDevExpressBlazor(options => options.BootstrapVersion = BootstrapVersion.v5);
builder.Services.AddScoped<ThemeState>(); builder.Services.AddScoped<ThemeState>();
builder.Services.AddScoped<BandLayoutService>(); builder.Services.AddScoped<BandLayoutService>();
builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<CookieContainer>();
var apiBaseUrl = builder.Configuration["ApiBaseUrl"];
builder.Services.Configure<AppSettings>(builder.Configuration); builder.Services.Configure<AppSettings>(builder.Configuration);
void ConfigureClient(HttpClient client) var appSettings = builder.Configuration.Get<AppSettings>() ?? new AppSettings();
// Alle API-Clients teilen sich denselben scoped CookieContainer (pro Blazor-Circuit),
// damit das Auth-Cookie nach dem Login automatisch an alle Folgeanfragen angehängt wird.
// Der UserHeaderHandler ergänzt automatisch den X-Authenticated-User-Header.
static HttpClient CreateApiHttpClient(CookieContainer cookieContainer, AuthService authService, string? baseUrl)
{ {
if (!string.IsNullOrWhiteSpace(apiBaseUrl)) var inner = new HttpClientHandler { UseCookies = false };
client.BaseAddress = new Uri(apiBaseUrl); var handler = new UserHeaderHandler(authService) { InnerHandler = inner };
var client = new HttpClient(handler);
if (!string.IsNullOrWhiteSpace(baseUrl))
client.BaseAddress = new Uri(baseUrl);
return client;
} }
builder.Services.AddHttpClient<ICatalogApiClient, CatalogApiClient>(ConfigureClient); static HttpClient CreateAuthHttpClient(CookieContainer cookieContainer, string? baseUrl)
builder.Services.AddHttpClient<IDashboardApiClient, DashboardApiClient>(ConfigureClient); {
builder.Services.AddHttpClient<IMassDataApiClient, MassDataApiClient>(ConfigureClient); var handler = new HttpClientHandler { CookieContainer = cookieContainer, UseCookies = true };
builder.Services.AddHttpClient<ILayoutApiClient, LayoutApiClient>(ConfigureClient); var client = new HttpClient(handler);
if (!string.IsNullOrWhiteSpace(baseUrl))
client.BaseAddress = new Uri(baseUrl);
return client;
}
builder.Services.AddScoped<IAuthApiClient>(sp =>
{
var cc = sp.GetRequiredService<CookieContainer>();
var authBaseUrl = !string.IsNullOrWhiteSpace(appSettings.AuthService.BaseUrl)
? appSettings.AuthService.BaseUrl
: appSettings.BaseUrl;
var client = CreateAuthHttpClient(cc, authBaseUrl);
return new AuthApiClient(client, sp.GetRequiredService<AuthService>(), cc);
});
var apiDefaultUrl = !string.IsNullOrWhiteSpace(appSettings.ApiDefaultUrl)
? appSettings.ApiDefaultUrl
: appSettings.BaseUrl;
builder.Services.AddScoped<ICatalogApiClient>(sp =>
new CatalogApiClient(CreateApiHttpClient(
sp.GetRequiredService<CookieContainer>(),
sp.GetRequiredService<AuthService>(),
apiDefaultUrl)));
builder.Services.AddScoped<IDashboardApiClient>(sp =>
new DashboardApiClient(CreateApiHttpClient(
sp.GetRequiredService<CookieContainer>(),
sp.GetRequiredService<AuthService>(),
apiDefaultUrl)));
builder.Services.AddScoped<IMassDataApiClient>(sp =>
new MassDataApiClient(CreateApiHttpClient(
sp.GetRequiredService<CookieContainer>(),
sp.GetRequiredService<AuthService>(),
apiDefaultUrl)));
builder.Services.AddScoped<ILayoutApiClient>(sp =>
new LayoutApiClient(CreateApiHttpClient(
sp.GetRequiredService<CookieContainer>(),
sp.GetRequiredService<AuthService>(),
apiDefaultUrl)));
var app = builder.Build(); var app = builder.Build();

View File

@@ -1,4 +1,5 @@
using System.Net; using System.Net;
using System.Text.Json;
namespace DbFirst.BlazorWebApp.Services; namespace DbFirst.BlazorWebApp.Services;
@@ -25,7 +26,14 @@ internal static class ApiClientHelper
problemDetail = problem.Detail ?? problem.Type; problemDetail = problem.Detail ?? problem.Type;
} }
} }
catch { } catch (JsonException)
{
// Ignoriere Fehler beim Lesen der Problem-Details, da sie optional sind
}
catch (NotSupportedException)
{
// Ignoriere Fehler beim Lesen der Problem-Details, da sie optional sind
}
var status = response.StatusCode; var status = response.StatusCode;
var reason = response.ReasonPhrase; var reason = response.ReasonPhrase;

View File

@@ -0,0 +1,64 @@
using System.Net;
namespace DbFirst.BlazorWebApp.Services;
public class AuthApiClient(HttpClient httpClient, AuthService authService, CookieContainer cookieContainer) : IAuthApiClient
{
private const string LoginEndpoint = "api/Auth/db-first/login";
private const string LogoutEndpoint = "api/Auth/logout";
private const string CheckEndpoint = "api/Auth/check";
public async Task<bool> LoginAsync(string username, string password, CancellationToken ct = default)
{
var content = new MultipartFormDataContent();
content.Add(new StringContent(username), "Username");
content.Add(new StringContent(password), "Password");
content.Add(new StringContent(string.Empty), "UserId");
var response = await httpClient.PostAsync(LoginEndpoint, content, ct);
if (!response.IsSuccessStatusCode)
return false;
var rawCookie = ExtractCookies();
authService.SetAuthenticated(username, rawCookie);
return true;
}
public async Task LogoutAsync(CancellationToken ct = default)
{
await httpClient.PostAsync(LogoutEndpoint, null, ct);
authService.SetUnauthenticated();
}
public async Task<bool> RestoreAsync(string username, string rawCookieHeader, CancellationToken ct = default)
{
RestoreCookies(rawCookieHeader);
var response = await httpClient.GetAsync(CheckEndpoint, ct);
if (response.IsSuccessStatusCode)
{
authService.SetAuthenticated(username, rawCookieHeader);
return true;
}
authService.SetUnauthenticated();
return false;
}
private string ExtractCookies()
{
if (httpClient.BaseAddress is null) return string.Empty;
var cookies = cookieContainer.GetCookies(httpClient.BaseAddress);
return string.Join("; ", cookies.Cast<Cookie>().Select(c => $"{c.Name}={c.Value}"));
}
private void RestoreCookies(string rawCookieHeader)
{
if (httpClient.BaseAddress is null) return;
foreach (var part in rawCookieHeader.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
var eqIdx = part.IndexOf('=');
if (eqIdx > 0)
cookieContainer.Add(httpClient.BaseAddress, new Cookie(part[..eqIdx].Trim(), part[(eqIdx + 1)..].Trim()));
}
}
}

View File

@@ -0,0 +1,26 @@
namespace DbFirst.BlazorWebApp.Services;
public class AuthService
{
public bool IsAuthenticated { get; private set; }
public string UserName { get; private set; } = string.Empty;
public string? RawCookieHeader { get; private set; }
public event Action? OnChange;
public void SetAuthenticated(string userName, string rawCookieHeader)
{
IsAuthenticated = true;
UserName = userName;
RawCookieHeader = rawCookieHeader;
OnChange?.Invoke();
}
public void SetUnauthenticated()
{
IsAuthenticated = false;
UserName = string.Empty;
RawCookieHeader = null;
OnChange?.Invoke();
}
}

View File

@@ -3,30 +3,24 @@ using DbFirst.Contracts.Catalogs;
namespace DbFirst.BlazorWebApp.Services; namespace DbFirst.BlazorWebApp.Services;
public class CatalogApiClient : ICatalogApiClient public class CatalogApiClient(HttpClient httpClient) : ICatalogApiClient
{ {
private readonly HttpClient _httpClient;
private const string Endpoint = "api/catalogs"; private const string Endpoint = "api/catalogs";
public CatalogApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<List<CatalogReadDto>> GetAllAsync(CancellationToken ct = default) public async Task<List<CatalogReadDto>> GetAllAsync(CancellationToken ct = default)
{ {
var result = await _httpClient.GetFromJsonAsync<List<CatalogReadDto>>(Endpoint, ct); var result = await httpClient.GetFromJsonAsync<List<CatalogReadDto>>(Endpoint, ct);
return result ?? []; return result ?? [];
} }
public async Task<CatalogReadDto?> GetByIdAsync(int id, CancellationToken ct = default) public async Task<CatalogReadDto?> GetByIdAsync(int id, CancellationToken ct = default)
{ {
return await _httpClient.GetFromJsonAsync<CatalogReadDto>($"{Endpoint}/{id}", ct); return await httpClient.GetFromJsonAsync<CatalogReadDto>($"{Endpoint}/{id}", ct);
} }
public async Task<ApiResult<CatalogReadDto?>> CreateAsync(CatalogWriteDto dto, CancellationToken ct = default) public async Task<ApiResult<CatalogReadDto?>> CreateAsync(CatalogWriteDto dto, CancellationToken ct = default)
{ {
var response = await _httpClient.PostAsJsonAsync(Endpoint, dto, ct); var response = await httpClient.PostAsJsonAsync(Endpoint, dto, ct);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var payload = await response.Content.ReadFromJsonAsync<CatalogReadDto>(); var payload = await response.Content.ReadFromJsonAsync<CatalogReadDto>();
@@ -39,7 +33,7 @@ public class CatalogApiClient : ICatalogApiClient
public async Task<ApiResult<bool>> UpdateAsync(int id, CatalogWriteDto dto, CancellationToken ct = default) public async Task<ApiResult<bool>> UpdateAsync(int id, CatalogWriteDto dto, CancellationToken ct = default)
{ {
var response = await _httpClient.PutAsJsonAsync($"{Endpoint}/{id}", dto, ct); var response = await httpClient.PutAsJsonAsync($"{Endpoint}/{id}", dto, ct);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
return ApiResult<bool>.Ok(true); return ApiResult<bool>.Ok(true);
@@ -51,7 +45,7 @@ public class CatalogApiClient : ICatalogApiClient
public async Task<ApiResult<bool>> DeleteAsync(int id, CancellationToken ct = default) public async Task<ApiResult<bool>> DeleteAsync(int id, CancellationToken ct = default)
{ {
var response = await _httpClient.DeleteAsync($"{Endpoint}/{id}", ct); var response = await httpClient.DeleteAsync($"{Endpoint}/{id}", ct);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
return ApiResult<bool>.Ok(true); return ApiResult<bool>.Ok(true);

View File

@@ -2,19 +2,13 @@ using DbFirst.Contracts.Dashboards;
namespace DbFirst.BlazorWebApp.Services; namespace DbFirst.BlazorWebApp.Services;
public class DashboardApiClient : IDashboardApiClient public class DashboardApiClient(HttpClient httpClient) : IDashboardApiClient
{ {
private readonly HttpClient _httpClient;
private const string Endpoint = "api/dashboard/dashboards"; private const string Endpoint = "api/dashboard/dashboards";
public DashboardApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<List<DashboardInfoDto>> GetAllAsync(CancellationToken ct = default) public async Task<List<DashboardInfoDto>> GetAllAsync(CancellationToken ct = default)
{ {
var result = await _httpClient.GetFromJsonAsync<List<DashboardInfoDto>>(Endpoint, ct); var result = await httpClient.GetFromJsonAsync<List<DashboardInfoDto>>(Endpoint, ct);
return result ?? []; return result ?? [];
} }
} }

View File

@@ -0,0 +1,8 @@
namespace DbFirst.BlazorWebApp.Services;
public interface IAuthApiClient
{
Task<bool> LoginAsync(string username, string password, CancellationToken ct = default);
Task LogoutAsync(CancellationToken ct = default);
Task<bool> RestoreAsync(string username, string rawCookieHeader, CancellationToken ct = default);
}

View File

@@ -2,20 +2,14 @@ using DbFirst.Contracts.Layouts;
namespace DbFirst.BlazorWebApp.Services; namespace DbFirst.BlazorWebApp.Services;
public class LayoutApiClient : ILayoutApiClient public class LayoutApiClient(HttpClient httpClient) : ILayoutApiClient
{ {
private readonly HttpClient _httpClient;
private const string Endpoint = "api/layouts"; private const string Endpoint = "api/layouts";
public LayoutApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<LayoutDto?> GetAsync(string layoutType, string layoutKey, string userName, CancellationToken ct = default) public async Task<LayoutDto?> GetAsync(string layoutType, string layoutKey, string userName, CancellationToken ct = default)
{ {
var url = $"{Endpoint}?layoutType={Uri.EscapeDataString(layoutType)}&layoutKey={Uri.EscapeDataString(layoutKey)}&userName={Uri.EscapeDataString(userName)}"; var url = $"{Endpoint}?layoutType={Uri.EscapeDataString(layoutType)}&layoutKey={Uri.EscapeDataString(layoutKey)}&userName={Uri.EscapeDataString(userName)}";
var response = await _httpClient.GetAsync(url, ct); var response = await httpClient.GetAsync(url, ct);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound) if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{ {
return null; return null;
@@ -27,7 +21,7 @@ public class LayoutApiClient : ILayoutApiClient
public async Task<LayoutDto> UpsertAsync(LayoutDto dto, CancellationToken ct = default) public async Task<LayoutDto> UpsertAsync(LayoutDto dto, CancellationToken ct = default)
{ {
var response = await _httpClient.PostAsJsonAsync(Endpoint, dto, ct); var response = await httpClient.PostAsJsonAsync(Endpoint, dto, ct);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
var detail = await ApiClientHelper.ReadErrorAsync(response); var detail = await ApiClientHelper.ReadErrorAsync(response);
@@ -41,7 +35,7 @@ public class LayoutApiClient : ILayoutApiClient
public async Task DeleteAsync(string layoutType, string layoutKey, string userName, CancellationToken ct = default) public async Task DeleteAsync(string layoutType, string layoutKey, string userName, CancellationToken ct = default)
{ {
var url = $"{Endpoint}?layoutType={Uri.EscapeDataString(layoutType)}&layoutKey={Uri.EscapeDataString(layoutKey)}&userName={Uri.EscapeDataString(userName)}"; var url = $"{Endpoint}?layoutType={Uri.EscapeDataString(layoutType)}&layoutKey={Uri.EscapeDataString(layoutKey)}&userName={Uri.EscapeDataString(userName)}";
var response = await _httpClient.DeleteAsync(url, ct); var response = await httpClient.DeleteAsync(url, ct);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound) if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{ {
return; return;

View File

@@ -1,44 +1,33 @@
using DbFirst.BlazorWebApp.Models; using DbFirst.BlazorWebApp.Models;
using DbFirst.Contracts.MassData; using DbFirst.Contracts.MassData;
using Microsoft.AspNetCore.WebUtilities;
namespace DbFirst.BlazorWebApp.Services; namespace DbFirst.BlazorWebApp.Services;
public class MassDataApiClient : IMassDataApiClient public class MassDataApiClient(HttpClient httpClient) : IMassDataApiClient
{ {
private readonly HttpClient _httpClient;
private const string Endpoint = "api/massdata"; private const string Endpoint = "api/massdata";
public MassDataApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<int> GetCountAsync(CancellationToken ct = default) public async Task<int> GetCountAsync(CancellationToken ct = default)
{ {
var result = await _httpClient.GetFromJsonAsync<int?>("api/massdata/count", ct); var result = await httpClient.GetFromJsonAsync<int?>("api/massdata/count", ct);
return result ?? 0; return result ?? 0;
} }
public async Task<List<MassDataReadDto>> GetAllAsync(int? skip, int? take, CancellationToken ct = default) public async Task<List<MassDataReadDto>> GetAllAsync(int? skip, int? take, CancellationToken ct = default)
{ {
var query = new List<string>(); var query = new Dictionary<string, string?>();
if (skip.HasValue) if (skip.HasValue) query["skip"] = skip.Value.ToString();
{ if (take.HasValue) query["take"] = take.Value.ToString();
query.Add($"skip={skip.Value}");
}
if (take.HasValue)
{
query.Add($"take={take.Value}");
}
var url = query.Count == 0 ? Endpoint : $"{Endpoint}?{string.Join("&", query)}"; var url = QueryHelpers.AddQueryString(Endpoint, query);
var result = await _httpClient.GetFromJsonAsync<List<MassDataReadDto>>(url, ct); var result = await httpClient.GetFromJsonAsync<List<MassDataReadDto>>(url, ct);
return result ?? []; return result ?? [];
} }
public async Task<ApiResult<MassDataReadDto?>> UpsertAsync(MassDataWriteDto dto, CancellationToken ct = default) public async Task<ApiResult<MassDataReadDto?>> UpsertAsync(MassDataWriteDto dto, CancellationToken ct = default)
{ {
var response = await _httpClient.PostAsJsonAsync($"{Endpoint}/upsert", dto, ct); var response = await httpClient.PostAsJsonAsync($"{Endpoint}/upsert", dto, ct);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var payload = await response.Content.ReadFromJsonAsync<MassDataReadDto>(); var payload = await response.Content.ReadFromJsonAsync<MassDataReadDto>();
@@ -56,7 +45,7 @@ public class MassDataApiClient : IMassDataApiClient
return null; return null;
} }
var response = await _httpClient.GetAsync($"{Endpoint}/{Uri.EscapeDataString(customerName)}", ct); var response = await httpClient.GetAsync($"{Endpoint}/{Uri.EscapeDataString(customerName)}", ct);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound) if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{ {
return null; return null;

View File

@@ -12,24 +12,54 @@ public class ThemeState
} }
public bool IsDarkMode { get; private set; } public bool IsDarkMode { get; private set; }
public string CurrentThemeName { get; private set; } = "Fluent";
/// <summary>
/// Themes die eine native DevExpress Dark-Variante besitzen:
/// - Fluent ? Themes.Fluent.Clone(ThemeMode.Dark), verwendet --DS-* Token-System
/// - BlazingBerry ? Themes.BlazingDark
/// Alle anderen Themes (Purple, OfficeWhite, BootstrapExternal) haben keine offizielle
/// Dark-Variante; dort übernehmen CSS-Overrides auf --dxbl-grid-* Variablen die Arbeit.
/// </summary>
public bool IsNativeDarkTheme => IsDarkMode &&
(CurrentThemeName == "Fluent" || CurrentThemeName == "BlazingBerry");
public static readonly List<string> AvailableThemes = ["Fluent", "BlazingBerry", "Purple", "OfficeWhite", "BootstrapExternal"];
public event Action? OnChange; public event Action? OnChange;
public void SetDarkMode(bool isDarkMode) public void SetTheme(string themeName)
{ {
if (IsDarkMode == isDarkMode) if (CurrentThemeName == themeName) return;
{ CurrentThemeName = themeName;
return; ApplyTheme();
}
IsDarkMode = isDarkMode;
var theme = Themes.Fluent.Clone(properties =>
{
properties.Mode = isDarkMode ? ThemeMode.Dark : ThemeMode.Light;
properties.ApplyToPageElements = true;
});
themeChangeService.SetTheme(theme);
OnChange?.Invoke(); OnChange?.Invoke();
} }
public void SetDarkMode(bool isDarkMode)
{
if (IsDarkMode == isDarkMode) return;
IsDarkMode = isDarkMode;
ApplyTheme();
OnChange?.Invoke();
}
private void ApplyTheme()
{
if (CurrentThemeName == "Fluent")
{
var theme = Themes.Fluent.Clone(properties =>
{
properties.Mode = IsDarkMode ? ThemeMode.Dark : ThemeMode.Light;
properties.ApplyToPageElements = true;
});
themeChangeService.SetTheme(theme);
}
else if (CurrentThemeName == "BlazingBerry") themeChangeService.SetTheme(IsDarkMode ? Themes.BlazingDark : Themes.BlazingBerry);
else if (CurrentThemeName == "Purple") themeChangeService.SetTheme(Themes.Purple);
else if (CurrentThemeName == "OfficeWhite") themeChangeService.SetTheme(Themes.OfficeWhite);
else if (CurrentThemeName == "BootstrapExternal") themeChangeService.SetTheme(Themes.BootstrapExternal);
else
themeChangeService.SetTheme(Themes.Fluent);
}
} }

View File

@@ -0,0 +1,17 @@
namespace DbFirst.BlazorWebApp.Services;
public class UserHeaderHandler(AuthService authService) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
if (authService.IsAuthenticated)
{
request.Headers.TryAddWithoutValidation("X-Authenticated-User", authService.UserName);
if (!string.IsNullOrEmpty(authService.RawCookieHeader))
request.Headers.TryAddWithoutValidation("Cookie", authService.RawCookieHeader);
}
return base.SendAsync(request, cancellationToken);
}
}

View File

@@ -5,5 +5,25 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"ApiBaseUrl": "https://localhost:7204/" "ApiDefaultUrl": "https://localhost:7204/",
"AuthService": {
"BaseUrl": "http://172.24.12.39:9090/",
"Login": "/api/Auth/db-first/login",
"Logout": "auth/logout",
"Check": "auth/check"
},
"UserManagerService": {
"BaseUrl": null,
"GetUserInfo": "user/info",
"UpdateUserInfo": "user/update"
},
"DbFirstService": {
"BaseUrl": "https://localhost:7204/",
"GetData": "dbfirst/data",
"UpdateData": "dbfirst/update"
},
"BrowserLink": {
"Enabled": false
},
"DetailedErrors": true
} }

View File

@@ -0,0 +1,19 @@
{
"ApiDefaultUrl": "https://localhost:7204/",
"AuthService": {
"BaseUrl": "http://172.24.12.39:9090/",
"Login": "/api/Auth/db-first/login",
"Logout": "auth/logout",
"Check": "auth/check"
},
"UserManagerService": {
"BaseUrl": null,
"GetUserInfo": "user/info",
"UpdateUserInfo": "user/update"
},
"DbFirstService": {
"BaseUrl": "https://localhost:7204/",
"GetData": "dbfirst/data",
"UpdateData": "dbfirst/update"
}
}

View File

@@ -1,10 +1,25 @@
{ {
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
},
"ApiDefaultUrl": "https://localhost:7204/",
"AuthService": {
"BaseUrl": null,
"Login": "auth/login",
"Logout": "auth/logout",
"Check": "auth/check"
},
"UserManagerService": {
"BaseUrl": null,
"GetUserInfo": "user/info",
"UpdateUserInfo": "user/update"
}, },
"ApiBaseUrl": "https://localhost:7204/", "DbFirstService": {
"AllowedHosts": "*" "BaseUrl": null,
"GetData": "dbfirst/data",
"UpdateData": "dbfirst/update"
}
} }

View File

@@ -148,6 +148,112 @@ dxbl-grid tbody tr:nth-child(even) td {
background-color: var(--grid-stripe-bg) !important; background-color: var(--grid-stripe-bg) !important;
} }
/* ?? Dark-Mode-Overrides für nicht-native Themes ?????????????????????????????
Strategie: CSS-Custom-Properties werden von DevExpress DIREKT auf den
Komponenten-Elementen definiert (z. B. --dxbl-popup-bg:#fff auf .dxbl-modal).
Eine geerbte Variable aus html.dx-dark würde durch die direkte Zuweisung
überschrieben. Deshalb targeten wir exakt dieselben Elemente, aber mit einem
zusätzlichen Vorfahren-Selektor (html.dx-dark) für höhere Spezifizität:
html.dx-dark .dxbl-modal = (0,2,1) > .dxbl-modal = (0,1,0) ?
html.dx-dark wird per JS gesetzt, wenn IsDarkMode && !IsNativeDarkTheme.
?? */
/* Popup / Modal (CRUD-Dialoge) Variablen-Quelle: .dxbl-modal */
html.dx-dark .dxbl-modal {
--dxbl-popup-bg: #2d2d2d;
--dxbl-popup-color: #e8e8e8;
--dxbl-popup-border-color: #555;
--dxbl-popup-header-bg: #333;
--dxbl-popup-header-color: #e8e8e8;
--dxbl-popup-footer-bg: #333;
--dxbl-popup-footer-color: #e8e8e8;
}
/* Flyout (Column Chooser, Filter-Panel) Variablen-Quelle: .dxbl-flyout */
html.dx-dark .dxbl-flyout {
--dxbl-flyout-bg: #2d2d2d;
--dxbl-flyout-color: #e8e8e8;
--dxbl-flyout-border-color: #555;
--dxbl-flyout-header-bg: #333;
--dxbl-flyout-header-color: #e8e8e8;
--dxbl-flyout-footer-bg: #333;
}
/* Dropdown (ComboBox-Klappliste, Band-Dropdowns) Quelle: .dxbl-dropdown */
html.dx-dark .dxbl-dropdown,
html.dx-dark .dxbl-itemlist-dropdown {
--dxbl-dropdown-bg: #2d2d2d;
--dxbl-dropdown-color: #e8e8e8;
--dxbl-dropdown-border-color: #555;
--dxbl-dropdown-header-bg: #333;
--dxbl-dropdown-footer-bg: #333;
}
/* Edit-Dropdown (ComboBox-Popup wenn als Modal gerendert) Quelle: .dxbl-edit-dropdown */
html.dx-dark .dxbl-edit-dropdown {
--dxbl-edit-dropdown-bg: #2d2d2d;
--dxbl-edit-dropdown-color: #e8e8e8;
--dxbl-edit-dropdown-border-color: #555;
}
/* ListBox (Einträge in Dropdowns) Quelle: .dxbl-list-box */
html.dx-dark .dxbl-list-box,
html.dx-dark .dxbl-list-box-render-container {
--dxbl-list-box-bg: #2d2d2d;
--dxbl-list-box-color: #e8e8e8;
--dxbl-list-box-border-color: #555;
--dxbl-list-box-item-hover-bg: #3a3a3a;
--dxbl-list-box-item-hover-color: #e8e8e8;
}
/* TextEdit / ComboBox Eingabefeld Quelle: .dxbl-text-edit */
html.dx-dark .dxbl-text-edit {
--dxbl-text-edit-bg: #2d2d2d;
--dxbl-text-edit-color: #e8e8e8;
--dxbl-text-edit-border-color: #555;
--dxbl-text-edit-btn-bg: #3a3a3a;
--dxbl-text-edit-btn-color: #e8e8e8;
--dxbl-text-edit-btn-hover-bg: #444;
--dxbl-text-edit-btn-hover-color: #e8e8e8;
}
/* Button-Dropdown-Items (Sizemode-Dropdown, Band-Dropdowns) Quelle: .dxbl-btn-dropdown-popup */
html.dx-dark .dxbl-btn-dropdown-popup {
--dxbl-btn-dropdown-btn-color: #e8e8e8;
--dxbl-btn-dropdown-btn-hover-color: #e8e8e8;
--dxbl-btn-dropdown-btn-active-color: #e8e8e8;
}
/* Buttons */
html.dx-dark .dxbl-btn {
--dxbl-btn-color: #e8e8e8;
--dxbl-btn-bg: #3a3a3a;
--dxbl-btn-border-color: #555;
--dxbl-btn-hover-bg: #444;
--dxbl-btn-hover-color: #e8e8e8;
--dxbl-btn-hover-border-color: #666;
}
/* FormLayout */
html.dx-dark .dxbl-fl {
--dxbl-fl-caption-color: #bbb;
--dxbl-fl-group-bg: #242424;
--dxbl-fl-group-color: #e8e8e8;
}
/* Grid */
html.dx-dark .dxbl-grid {
background-color: #242424;
color: #e8e8e8;
border-color: #444;
}
html.dx-dark .dxbl-grid > .dxbl-scroll-viewer,
html.dx-dark .dxbl-grid > .dxbl-grid-top-panel {
background-color: #242424;
color: #e8e8e8;
}
/* MassData-spezifisch */ /* MassData-spezifisch */
.page-size-selector { .page-size-selector {
display: flex; display: flex;
@@ -185,3 +291,13 @@ dxbl-grid tbody tr:nth-child(even) td {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.top-row .btn-gap {
margin-left: 8px;
}
.top-row-username {
font-size: 0.85rem;
color: #6c757d;
white-space: nowrap;
}

View File

@@ -1,3 +1,4 @@
window.setSize = function (fontSize) { window.setSize = function (fontSize) {
document.documentElement.style.setProperty('--global-size', fontSize); document.documentElement.style.setProperty('--global-size', fontSize);
}; };