diff --git a/DbFirst.BlazorWebApp/AppSettings.cs b/DbFirst.BlazorWebApp/AppSettings.cs index 39ee9c9..3841630 100644 --- a/DbFirst.BlazorWebApp/AppSettings.cs +++ b/DbFirst.BlazorWebApp/AppSettings.cs @@ -3,4 +3,5 @@ public class AppSettings { public string ApiBaseUrl { get; set; } = string.Empty; + public string DataApiBaseUrl { get; set; } = string.Empty; } \ No newline at end of file diff --git a/DbFirst.BlazorWebApp/Components/App.razor b/DbFirst.BlazorWebApp/Components/App.razor index 3a4ea34..1fd5afc 100644 --- a/DbFirst.BlazorWebApp/Components/App.razor +++ b/DbFirst.BlazorWebApp/Components/App.razor @@ -31,6 +31,18 @@ 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'); + } + }; diff --git a/DbFirst.BlazorWebApp/Components/Layout/LoginLayout.razor b/DbFirst.BlazorWebApp/Components/Layout/LoginLayout.razor new file mode 100644 index 0000000..56830dd --- /dev/null +++ b/DbFirst.BlazorWebApp/Components/Layout/LoginLayout.razor @@ -0,0 +1,10 @@ +@inherits LayoutComponentBase +@inject ThemeState ThemeState + +
+
+
+ @Body +
+
+
diff --git a/DbFirst.BlazorWebApp/Components/Layout/MainLayout.razor b/DbFirst.BlazorWebApp/Components/Layout/MainLayout.razor index f7ce51c..8f8573d 100644 --- a/DbFirst.BlazorWebApp/Components/Layout/MainLayout.razor +++ b/DbFirst.BlazorWebApp/Components/Layout/MainLayout.razor @@ -2,6 +2,9 @@ @implements IDisposable @inject ThemeState ThemeState @inject IJSRuntime JS +@inject AuthService AuthService +@inject IAuthApiClient AuthApiClient +@inject NavigationManager Navigation
- @Body + @if (_checkingAuth) + { +
+ Wird geladen… +
+ } + else + { + @Body + }
@@ -35,10 +56,12 @@ @code { private bool _isInteractive; + private bool _checkingAuth = true; protected override void OnInitialized() { ThemeState.OnChange += OnThemeChanged; + AuthService.OnChange += OnAuthChanged; } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -46,17 +69,71 @@ if (firstRender) { _isInteractive = true; + + if (!AuthService.IsAuthenticated) + { + try + { + var username = await JS.InvokeAsync("authStorage.get", "auth_user"); + var cookie = await JS.InvokeAsync("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(); + await ApplyDxDarkOverrideAsync(); }); } @@ -82,5 +159,6 @@ public void Dispose() { ThemeState.OnChange -= OnThemeChanged; + AuthService.OnChange -= OnAuthChanged; } } diff --git a/DbFirst.BlazorWebApp/Components/Pages/Login.razor b/DbFirst.BlazorWebApp/Components/Pages/Login.razor new file mode 100644 index 0000000..6dde548 --- /dev/null +++ b/DbFirst.BlazorWebApp/Components/Pages/Login.razor @@ -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 + +Anmelden – DbFirst + + + +@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; + } + } +} diff --git a/DbFirst.BlazorWebApp/Components/Pages/Login.razor.css b/DbFirst.BlazorWebApp/Components/Pages/Login.razor.css new file mode 100644 index 0000000..30106d9 --- /dev/null +++ b/DbFirst.BlazorWebApp/Components/Pages/Login.razor.css @@ -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); + } diff --git a/DbFirst.BlazorWebApp/Program.cs b/DbFirst.BlazorWebApp/Program.cs index ef6ed42..0f7c66f 100644 --- a/DbFirst.BlazorWebApp/Program.cs +++ b/DbFirst.BlazorWebApp/Program.cs @@ -2,6 +2,7 @@ using DbFirst.BlazorWebApp; using DbFirst.BlazorWebApp.Components; using DbFirst.BlazorWebApp.Services; using DevExpress.Blazor; +using System.Net; var builder = WebApplication.CreateBuilder(args); @@ -12,19 +13,45 @@ builder.Services.AddRazorComponents() builder.Services.AddDevExpressBlazor(options => options.BootstrapVersion = BootstrapVersion.v5); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.Configure(builder.Configuration); var appSettings = builder.Configuration.Get() ?? new AppSettings(); -void ConfigureClient(HttpClient client) + +// 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. +static HttpClient CreateHttpClientWithCookies(CookieContainer cookieContainer, string? baseUrl) { - if (!string.IsNullOrWhiteSpace(appSettings.ApiBaseUrl)) - client.BaseAddress = new Uri(appSettings.ApiBaseUrl); + var handler = new HttpClientHandler { CookieContainer = cookieContainer, UseCookies = true }; + var client = new HttpClient(handler); + if (!string.IsNullOrWhiteSpace(baseUrl)) + client.BaseAddress = new Uri(baseUrl); + return client; } -builder.Services.AddHttpClient(ConfigureClient); -builder.Services.AddHttpClient(ConfigureClient); -builder.Services.AddHttpClient(ConfigureClient); -builder.Services.AddHttpClient(ConfigureClient); +builder.Services.AddScoped(sp => +{ + var cc = sp.GetRequiredService(); + var client = CreateHttpClientWithCookies(cc, appSettings.ApiBaseUrl); + return new AuthApiClient(client, sp.GetRequiredService(), cc); +}); + +var dataApiBaseUrl = !string.IsNullOrWhiteSpace(appSettings.DataApiBaseUrl) + ? appSettings.DataApiBaseUrl + : appSettings.ApiBaseUrl; + +builder.Services.AddScoped(sp => + new CatalogApiClient(CreateHttpClientWithCookies(sp.GetRequiredService(), dataApiBaseUrl))); + +builder.Services.AddScoped(sp => + new DashboardApiClient(CreateHttpClientWithCookies(sp.GetRequiredService(), dataApiBaseUrl))); + +builder.Services.AddScoped(sp => + new MassDataApiClient(CreateHttpClientWithCookies(sp.GetRequiredService(), dataApiBaseUrl))); + +builder.Services.AddScoped(sp => + new LayoutApiClient(CreateHttpClientWithCookies(sp.GetRequiredService(), dataApiBaseUrl))); var app = builder.Build(); diff --git a/DbFirst.BlazorWebApp/Services/AuthApiClient.cs b/DbFirst.BlazorWebApp/Services/AuthApiClient.cs new file mode 100644 index 0000000..cc950e4 --- /dev/null +++ b/DbFirst.BlazorWebApp/Services/AuthApiClient.cs @@ -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 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 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().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())); + } + } +} diff --git a/DbFirst.BlazorWebApp/Services/AuthService.cs b/DbFirst.BlazorWebApp/Services/AuthService.cs new file mode 100644 index 0000000..b55ef81 --- /dev/null +++ b/DbFirst.BlazorWebApp/Services/AuthService.cs @@ -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(); + } +} diff --git a/DbFirst.BlazorWebApp/Services/IAuthApiClient.cs b/DbFirst.BlazorWebApp/Services/IAuthApiClient.cs new file mode 100644 index 0000000..25d5532 --- /dev/null +++ b/DbFirst.BlazorWebApp/Services/IAuthApiClient.cs @@ -0,0 +1,8 @@ +namespace DbFirst.BlazorWebApp.Services; + +public interface IAuthApiClient +{ + Task LoginAsync(string username, string password, CancellationToken ct = default); + Task LogoutAsync(CancellationToken ct = default); + Task RestoreAsync(string username, string rawCookieHeader, CancellationToken ct = default); +} diff --git a/DbFirst.BlazorWebApp/appsettings.Development.json b/DbFirst.BlazorWebApp/appsettings.Development.json index be563e8..545137b 100644 --- a/DbFirst.BlazorWebApp/appsettings.Development.json +++ b/DbFirst.BlazorWebApp/appsettings.Development.json @@ -5,7 +5,8 @@ "Microsoft.AspNetCore": "Warning" } }, - "ApiBaseUrl": "https://localhost:7204/", + "ApiBaseUrl": "http://172.24.12.39:9090/", + "DataApiBaseUrl": "https://localhost:7204/", "BrowserLink": { "Enabled": false }, diff --git a/DbFirst.BlazorWebApp/appsettings.json b/DbFirst.BlazorWebApp/appsettings.json index 73c9700..7779dac 100644 --- a/DbFirst.BlazorWebApp/appsettings.json +++ b/DbFirst.BlazorWebApp/appsettings.json @@ -5,6 +5,7 @@ "Microsoft.AspNetCore": "Warning" } }, - "ApiBaseUrl": "https://localhost:7204/", + "ApiBaseUrl": "http://172.24.12.39:9090/", + "DataApiBaseUrl": "https://localhost:7204/", "AllowedHosts": "*" } diff --git a/DbFirst.BlazorWebApp/wwwroot/app.css b/DbFirst.BlazorWebApp/wwwroot/app.css index 72224cc..fe87cc6 100644 --- a/DbFirst.BlazorWebApp/wwwroot/app.css +++ b/DbFirst.BlazorWebApp/wwwroot/app.css @@ -294,4 +294,10 @@ html.dx-dark .dxbl-grid > .dxbl-grid-top-panel { .top-row .btn-gap { margin-left: 8px; +} + +.top-row-username { + font-size: 0.85rem; + color: #6c757d; + white-space: nowrap; } \ No newline at end of file