From 5fd97deadae3cd34a0e33da86788934c79147902 Mon Sep 17 00:00:00 2001 From: Daniel Pichler Date: Thu, 18 Dec 2025 15:35:39 +0100 Subject: [PATCH] LoginFixes --- .../Infrastructure/ApiClient.cs | 126 +++++----- Jugenddienst Stunden/Services/AuthService.cs | 2 +- .../ViewModels/LoginViewModel.cs | 220 +++++++++--------- Jugenddienst Stunden/Views/LoginPage.xaml | 2 +- 4 files changed, 177 insertions(+), 173 deletions(-) diff --git a/Jugenddienst Stunden/Infrastructure/ApiClient.cs b/Jugenddienst Stunden/Infrastructure/ApiClient.cs index 6f10a26..4d3c73f 100644 --- a/Jugenddienst Stunden/Infrastructure/ApiClient.cs +++ b/Jugenddienst Stunden/Infrastructure/ApiClient.cs @@ -13,62 +13,62 @@ internal sealed class ApiClient : IApiClient { private readonly ApiOptions _options; private readonly IAppSettings _settings; - public ApiClient(HttpClient http, ApiOptions options, ITokenProvider tokenProvider, IAppSettings settings) { - _http = http; - _options = options; - _settings = settings; + public ApiClient(HttpClient http, ApiOptions options, ITokenProvider tokenProvider, IAppSettings settings) { + _http = http; + _options = options; + _settings = settings; - // Timeout nur einmalig beim Erstellen setzen – spätere Änderungen an HttpClient.Timeout - // nach der ersten Verwendung führen zu InvalidOperationException. - if (_http.Timeout != options.Timeout) - _http.Timeout = options.Timeout; - // Standardmäßig JSON akzeptieren; doppelte Einträge vermeiden - if (!_http.DefaultRequestHeaders.Accept.Any(h => h.MediaType?.Equals("application/json", StringComparison.OrdinalIgnoreCase) == true)) - _http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + // Timeout nur einmalig beim Erstellen setzen – spätere Änderungen an HttpClient.Timeout + // nach der ersten Verwendung führen zu InvalidOperationException. + if (_http.Timeout != options.Timeout) + _http.Timeout = options.Timeout; + // Standardmäßig JSON akzeptieren; doppelte Einträge vermeiden + if (!_http.DefaultRequestHeaders.Accept.Any(h => h.MediaType?.Equals("application/json", StringComparison.OrdinalIgnoreCase) == true)) + _http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - // KEINE globalen Header/Properties mehr dynamisch setzen. Authorization wird pro Request gesetzt. + // KEINE globalen Header/Properties mehr dynamisch setzen. Authorization wird pro Request gesetzt. - _json = new JsonSerializerOptions { - PropertyNameCaseInsensitive = true, - WriteIndented = false, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull - }; + _json = new JsonSerializerOptions { + PropertyNameCaseInsensitive = true, + WriteIndented = false, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; // Globale Converter: erlauben numerische Felder auch als Strings (z.B. user.id) _json.Converters.Add(new Jugenddienst_Stunden.Models.JsonFlexibleInt32Converter()); _json.Converters.Add(new Jugenddienst_Stunden.Models.JsonFlexibleNullableInt32Converter()); - // WICHTIG: HttpClient.BaseAddress NICHT dynamisch setzen oder ändern – das verursacht Exceptions, - // sobald bereits Requests gestartet wurden. Wir bauen stattdessen absolute URIs pro Request. - } + // WICHTIG: HttpClient.BaseAddress NICHT dynamisch setzen oder ändern – das verursacht Exceptions, + // sobald bereits Requests gestartet wurden. Wir bauen stattdessen absolute URIs pro Request. + } public Task GetAsync(string path, IDictionary? query = null, CancellationToken ct = default) => SendAsync(HttpMethod.Get, path, null, query, ct); - public async Task SendAsync(HttpMethod method, string path, object? body = null, - IDictionary? query = null, CancellationToken ct = default) { - // Absolute URI aus aktuellem Settings‑BaseUrl bauen, ohne HttpClient.BaseAddress zu nutzen. - var uri = BuildAbsoluteUri(_settings.ApiUrl, path, query); - using var req = new HttpRequestMessage(method, uri); - // Authorization PRO REQUEST setzen (immer, wenn Token vorhanden ist) - // Hinweis: Das QR-Token kann RFC-unzulässige Zeichen (z. B. '|') enthalten. - // AuthenticationHeaderValue würde solche Werte ablehnen. Daher ohne Validierung setzen. - var currentToken = _settings.ApiKey; - if (!string.IsNullOrWhiteSpace(currentToken)) { - // Vorherige Header (falls vorhanden) entfernen, um Duplikate zu vermeiden - req.Headers.Remove("Authorization"); + public async Task SendAsync(HttpMethod method, string path, object? body = null, + IDictionary? query = null, CancellationToken ct = default) { + // Absolute URI aus aktuellem Settings‑BaseUrl bauen, ohne HttpClient.BaseAddress zu nutzen. + var uri = BuildAbsoluteUri(_settings.ApiUrl, path, query); + using var req = new HttpRequestMessage(method, uri); + // Authorization PRO REQUEST setzen (immer, wenn Token vorhanden ist) + // Hinweis: Das QR-Token kann RFC-unzulässige Zeichen (z. B. '|') enthalten. + // AuthenticationHeaderValue würde solche Werte ablehnen. Daher ohne Validierung setzen. + var currentToken = _settings.ApiKey; + if (!string.IsNullOrWhiteSpace(currentToken)) { + // Vorherige Header (falls vorhanden) entfernen, um Duplikate zu vermeiden + req.Headers.Remove("Authorization"); req.Headers.TryAddWithoutValidation("Authorization", $"Bearer {currentToken}"); } - if (body is HttpContent httpContent) { - req.Content = httpContent; - } else if (body is not null) { - req.Content = JsonContent.Create(body, options: _json); - } + if (body is HttpContent httpContent) { + req.Content = httpContent; + } else if (body is not null) { + req.Content = JsonContent.Create(body, options: _json); + } - // Sicherstellen, dass Accept: application/json auch auf Request-Ebene vorhanden ist (z. B. für LoginWithToken GET) - if (!req.Headers.Accept.Any(h => h.MediaType?.Equals("application/json", StringComparison.OrdinalIgnoreCase) == true)) { - req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - } + // Sicherstellen, dass Accept: application/json auch auf Request-Ebene vorhanden ist (z. B. für LoginWithToken GET) + if (!req.Headers.Accept.Any(h => h.MediaType?.Equals("application/json", StringComparison.OrdinalIgnoreCase) == true)) { + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + } using var res = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); var text = await res.Content.ReadAsStringAsync(ct).ConfigureAwait(false); @@ -109,7 +109,7 @@ internal sealed class ApiClient : IApiClient { public Task DeleteAsync(string path, IDictionary? query = null, CancellationToken ct = default) => SendAsync(HttpMethod.Delete, path, null, query, ct); - // Entfernt: EnsureBaseAddress – wir ändern BaseAddress nicht mehr dynamisch. + // Entfernt: EnsureBaseAddress – wir ändern BaseAddress nicht mehr dynamisch. private static string TryGetMessage(string text) { try { @@ -122,29 +122,29 @@ internal sealed class ApiClient : IApiClient { return text; } - private static Uri BuildAbsoluteUri(string baseUrl, string path, IDictionary? query) { - if (string.IsNullOrWhiteSpace(baseUrl)) - throw new InvalidOperationException( - "ApiUrl ist leer. Bitte zuerst eine gültige Server-URL setzen (Preferences key 'apiUrl')."); + private static Uri BuildAbsoluteUri(string baseUrl, string path, IDictionary? query) { + if (string.IsNullOrWhiteSpace(baseUrl)) + throw new InvalidOperationException( + "ApiUrl ist leer. Bitte zuerst eine gültige Server-URL setzen (Preferences key 'apiUrl')."); - // Basis muss absolut sein (z. B. https://host/appapi/) - var baseUri = new Uri(baseUrl, UriKind.Absolute); + // Basis muss absolut sein (z. B. https://host/appapi/) + var baseUri = new Uri(baseUrl, UriKind.Absolute); - // Pfad relativ zur Basis aufbauen - string relativePath = path ?? string.Empty; - if (query is not null && query.Count > 0) { - var sb = new StringBuilder(relativePath); - sb.Append(relativePath.Contains('?') ? '&' : '?'); - sb.Append(string.Join('&', query - .Where(kv => kv.Value is not null) - .Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value!)}"))); - relativePath = sb.ToString(); - } + // Pfad relativ zur Basis aufbauen + string relativePath = path ?? string.Empty; + if (query is not null && query.Count > 0) { + var sb = new StringBuilder(relativePath); + sb.Append(relativePath.Contains('?') ? '&' : '?'); + sb.Append(string.Join('&', query + .Where(kv => kv.Value is not null) + .Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value!)}"))); + relativePath = sb.ToString(); + } - // Wenn path bereits absolut ist, direkt verwenden - if (Uri.TryCreate(relativePath, UriKind.Absolute, out var absoluteFromPath)) - return absoluteFromPath; + // Wenn path bereits absolut ist, direkt verwenden + if (Uri.TryCreate(relativePath, UriKind.Absolute, out var absoluteFromPath)) + return absoluteFromPath; - return new Uri(baseUri, relativePath); - } + return new Uri(baseUri, relativePath); + } } \ No newline at end of file diff --git a/Jugenddienst Stunden/Services/AuthService.cs b/Jugenddienst Stunden/Services/AuthService.cs index 539fc8b..3d6aac5 100644 --- a/Jugenddienst Stunden/Services/AuthService.cs +++ b/Jugenddienst Stunden/Services/AuthService.cs @@ -87,7 +87,7 @@ internal sealed class AuthService : IAuthService { //} if (url.EndsWith("/", StringComparison.Ordinal)) { - url = url.Remove(url.Length - 1, 1); + url = url.TrimEnd('/'); } return url; } diff --git a/Jugenddienst Stunden/ViewModels/LoginViewModel.cs b/Jugenddienst Stunden/ViewModels/LoginViewModel.cs index f05c76d..2b2082b 100644 --- a/Jugenddienst Stunden/ViewModels/LoginViewModel.cs +++ b/Jugenddienst Stunden/ViewModels/LoginViewModel.cs @@ -8,138 +8,142 @@ namespace Jugenddienst_Stunden.ViewModels; /// ViewModel für die Loginseite (MVVM) /// public partial class LoginViewModel : ObservableObject { - private readonly IAuthService _auth; - private readonly IAppSettings _settings; - private readonly IAlertService? _alerts; - private DateTime _lastDetectionTime = DateTime.MinValue; - private readonly TimeSpan _detectionInterval = TimeSpan.FromSeconds(5); + private readonly IAuthService _auth; + private readonly IAppSettings _settings; + private readonly IAlertService? _alerts; + private DateTime _lastDetectionTime = DateTime.MinValue; + private readonly TimeSpan _detectionInterval = TimeSpan.FromSeconds(5); - public event EventHandler? AlertEvent; - public event EventHandler? InfoEvent; + public event EventHandler? AlertEvent; + public event EventHandler? InfoEvent; - /// - /// Name der Anwendung - /// - public string AppTitle => AppInfo.Name; + /// + /// Name der Anwendung + /// + public string AppTitle => AppInfo.Name; - /// - /// Programmversion - /// - public string Version => AppInfo.VersionString; + /// + /// Programmversion + /// + public string Version => AppInfo.VersionString; - [ObservableProperty] - private string message = "Scanne den QR-Code von deinem Benutzerprofil auf der Stundenseite."; + [ObservableProperty] + private string message = "Scanne den QR-Code von deinem Benutzerprofil auf der Stundenseite."; - [ObservableProperty] - private string? server; + [ObservableProperty] + private string? server; - [ObservableProperty] - private string title = Preferences.Default.Get("name", "Nicht") + " " + Preferences.Default.Get("surname", "eingeloggt"); + [ObservableProperty] + private string? serverLabel; - [ObservableProperty] - private string? username; + [ObservableProperty] + private string title = Preferences.Default.Get("name", "Nicht") + " " + Preferences.Default.Get("surname", "eingeloggt"); - [ObservableProperty] - private string? password; + [ObservableProperty] + private string? username; - [ObservableProperty] - private bool isManualMode; + [ObservableProperty] + private string? password; - [ObservableProperty] - private bool isBusy; + [ObservableProperty] + private bool isManualMode; - [ObservableProperty] - private bool isDetecting; + [ObservableProperty] + private bool isBusy; - // Explizite Command-Property für den QR-Scanner-Event, damit das Binding in XAML zuverlässig greift - public IAsyncRelayCommand QrDetectedCommand { get; } + [ObservableProperty] + private bool isDetecting; - public LoginViewModel(IAuthService auth, IAppSettings settings) { - _auth = auth; - _settings = settings; + // Explizite Command-Property für den QR-Scanner-Event, damit das Binding in XAML zuverlässig greift + public IAsyncRelayCommand QrDetectedCommand { get; } - // gespeicherte Präferenz für Logintyp laden - var lt = Preferences.Default.Get("logintype", "qr"); - isManualMode = string.Equals(lt, "manual", StringComparison.OrdinalIgnoreCase); - // Scanner standardmäßig nur im QR-Modus aktivieren - IsDetecting = !isManualMode; + public LoginViewModel(IAuthService auth, IAppSettings settings) { + _auth = auth; + _settings = settings; - // Serveranzeige vorbereiten - var apiUrl = Preferences.Default.Get("apiUrl", string.Empty); - if (!string.IsNullOrWhiteSpace(apiUrl)) { - Server = "Server: " + apiUrl.Replace("/appapi", "").Replace("https://", "").Replace("http://", ""); - } + // gespeicherte Präferenz für Logintyp laden + var lt = Preferences.Default.Get("logintype", "qr"); + isManualMode = string.Equals(lt, "manual", StringComparison.OrdinalIgnoreCase); + // Scanner standardmäßig nur im QR-Modus aktivieren + IsDetecting = !isManualMode; - // Command initialisieren - QrDetectedCommand = new AsyncRelayCommand(QrDetectedAsync); - } + // Serveranzeige vorbereiten + var apiUrl = Preferences.Default.Get("apiUrl", string.Empty); + if (!string.IsNullOrWhiteSpace(apiUrl)) { + Server = apiUrl.Replace("/appapi", "").Replace("https://", "").Replace("http://", ""); + ServerLabel = "Server: " + Server; + } - // DI-Konstruktor: AlertService anbinden und Alerts an VM-Event weiterreichen (analog StundeViewModel) - internal LoginViewModel(IAuthService auth, IAppSettings settings, IAlertService alertService) : this(auth, settings) { - _alerts = alertService; - if (alertService is not null) { - alertService.AlertRaised += (s, msg) => AlertEvent?.Invoke(this, msg); - } - } + // Command initialisieren + QrDetectedCommand = new AsyncRelayCommand(QrDetectedAsync); + } - partial void OnIsManualModeChanged(bool value) { - Preferences.Default.Set("logintype", value ? "manual" : "qr"); - // Scanner nur aktiv, wenn QR-Modus aktiv ist - IsDetecting = !value; - } + // DI-Konstruktor: AlertService anbinden und Alerts an VM-Event weiterreichen (analog StundeViewModel) + internal LoginViewModel(IAuthService auth, IAppSettings settings, IAlertService alertService) : this(auth, settings) { + _alerts = alertService; + if (alertService is not null) { + alertService.AlertRaised += (s, msg) => AlertEvent?.Invoke(this, msg); + } + } - [RelayCommand] - private async Task LoginAsync() { - if (IsBusy) return; - try { - IsBusy = true; - var user = await _auth.LoginWithCredentials(Username?.Trim() ?? string.Empty, - Password ?? string.Empty, - (Server ?? string.Empty).Replace("Server:", string.Empty).Trim()); + partial void OnIsManualModeChanged(bool value) { + Preferences.Default.Set("logintype", value ? "manual" : "qr"); + // Scanner nur aktiv, wenn QR-Modus aktiv ist + IsDetecting = !value; + } - Title = $"{user.Name} {user.Surname}"; - InfoEvent?.Invoke(this, "Login erfolgreich"); + [RelayCommand] + private async Task LoginAsync() { + if (IsBusy) return; + try { + IsBusy = true; + var user = await _auth.LoginWithCredentials(Username?.Trim() ?? string.Empty, + Password ?? string.Empty, + (Server ?? string.Empty).Trim()); - await Shell.Current.GoToAsync("//StundenPage"); - } catch (Exception ex) { - if (_alerts is not null) { - _alerts.Raise(ex.Message); - } else { - AlertEvent?.Invoke(this, ex.Message); - } - } finally { - IsBusy = false; - } - } + Title = $"{user.Name} {user.Surname}"; + InfoEvent?.Invoke(this, "Login erfolgreich"); - private async Task QrDetectedAsync(object? args) { - var now = DateTime.Now; - if ((now - _lastDetectionTime) <= _detectionInterval) return; - _lastDetectionTime = now; + await Shell.Current.GoToAsync("//StundenPage"); + } catch (Exception ex) { + if (_alerts is not null) { + _alerts.Raise(ex.Message); + } else { + AlertEvent?.Invoke(this, ex.Message); + } + } finally { + IsBusy = false; + } + } - try { - var token = ExtractFirstBarcodeValue(args); - if (string.IsNullOrWhiteSpace(token)) return; + private async Task QrDetectedAsync(object? args) { + var now = DateTime.Now; + if ((now - _lastDetectionTime) <= _detectionInterval) return; + _lastDetectionTime = now; - var user = await _auth.LoginWithToken(token); - Title = $"{user.Name} {user.Surname}"; + try { + var token = ExtractFirstBarcodeValue(args); + if (string.IsNullOrWhiteSpace(token)) return; - await Shell.Current.GoToAsync("//StundenPage"); - } catch (Exception ex) { - if (_alerts is not null) { - _alerts.Raise(ex.Message); - } else { - AlertEvent?.Invoke(this, ex.Message); - } - } - } + var user = await _auth.LoginWithToken(token); + Title = $"{user.Name} {user.Surname}"; - private static string? ExtractFirstBarcodeValue(object? args) { - try { - if (args is ZXing.Net.Maui.BarcodeDetectionEventArgs e && e.Results is not null) { - return e.Results.FirstOrDefault()?.Value; - } - } catch { } - return null; - } + await Shell.Current.GoToAsync("//StundenPage"); + } catch (Exception ex) { + if (_alerts is not null) { + _alerts.Raise(ex.Message); + } else { + AlertEvent?.Invoke(this, ex.Message); + } + } + } + + private static string? ExtractFirstBarcodeValue(object? args) { + try { + if (args is ZXing.Net.Maui.BarcodeDetectionEventArgs e && e.Results is not null) { + return e.Results.FirstOrDefault()?.Value; + } + } catch { } + return null; + } } \ No newline at end of file diff --git a/Jugenddienst Stunden/Views/LoginPage.xaml b/Jugenddienst Stunden/Views/LoginPage.xaml index 13537c9..92f6677 100644 --- a/Jugenddienst Stunden/Views/LoginPage.xaml +++ b/Jugenddienst Stunden/Views/LoginPage.xaml @@ -44,7 +44,7 @@ -