LoginFixes

This commit is contained in:
2025-12-18 15:35:39 +01:00
parent c11b361655
commit 5fd97deada
4 changed files with 177 additions and 173 deletions

View File

@@ -13,62 +13,62 @@ internal sealed class ApiClient : IApiClient {
private readonly ApiOptions _options; private readonly ApiOptions _options;
private readonly IAppSettings _settings; private readonly IAppSettings _settings;
public ApiClient(HttpClient http, ApiOptions options, ITokenProvider tokenProvider, IAppSettings settings) { public ApiClient(HttpClient http, ApiOptions options, ITokenProvider tokenProvider, IAppSettings settings) {
_http = http; _http = http;
_options = options; _options = options;
_settings = settings; _settings = settings;
// Timeout nur einmalig beim Erstellen setzen spätere Änderungen an HttpClient.Timeout // Timeout nur einmalig beim Erstellen setzen spätere Änderungen an HttpClient.Timeout
// nach der ersten Verwendung führen zu InvalidOperationException. // nach der ersten Verwendung führen zu InvalidOperationException.
if (_http.Timeout != options.Timeout) if (_http.Timeout != options.Timeout)
_http.Timeout = options.Timeout; _http.Timeout = options.Timeout;
// Standardmäßig JSON akzeptieren; doppelte Einträge vermeiden // Standardmäßig JSON akzeptieren; doppelte Einträge vermeiden
if (!_http.DefaultRequestHeaders.Accept.Any(h => h.MediaType?.Equals("application/json", StringComparison.OrdinalIgnoreCase) == true)) if (!_http.DefaultRequestHeaders.Accept.Any(h => h.MediaType?.Equals("application/json", StringComparison.OrdinalIgnoreCase) == true))
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); _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 { _json = new JsonSerializerOptions {
PropertyNameCaseInsensitive = true, PropertyNameCaseInsensitive = true,
WriteIndented = false, WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
}; };
// Globale Converter: erlauben numerische Felder auch als Strings (z.B. user.id) // 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.JsonFlexibleInt32Converter());
_json.Converters.Add(new Jugenddienst_Stunden.Models.JsonFlexibleNullableInt32Converter()); _json.Converters.Add(new Jugenddienst_Stunden.Models.JsonFlexibleNullableInt32Converter());
// WICHTIG: HttpClient.BaseAddress NICHT dynamisch setzen oder ändern das verursacht Exceptions, // WICHTIG: HttpClient.BaseAddress NICHT dynamisch setzen oder ändern das verursacht Exceptions,
// sobald bereits Requests gestartet wurden. Wir bauen stattdessen absolute URIs pro Request. // sobald bereits Requests gestartet wurden. Wir bauen stattdessen absolute URIs pro Request.
} }
public Task<T> GetAsync<T>(string path, IDictionary<string, string?>? query = null, CancellationToken ct = default) public Task<T> GetAsync<T>(string path, IDictionary<string, string?>? query = null, CancellationToken ct = default)
=> SendAsync<T>(HttpMethod.Get, path, null, query, ct); => SendAsync<T>(HttpMethod.Get, path, null, query, ct);
public async Task<T> SendAsync<T>(HttpMethod method, string path, object? body = null, public async Task<T> SendAsync<T>(HttpMethod method, string path, object? body = null,
IDictionary<string, string?>? query = null, CancellationToken ct = default) { IDictionary<string, string?>? query = null, CancellationToken ct = default) {
// Absolute URI aus aktuellem SettingsBaseUrl bauen, ohne HttpClient.BaseAddress zu nutzen. // Absolute URI aus aktuellem SettingsBaseUrl bauen, ohne HttpClient.BaseAddress zu nutzen.
var uri = BuildAbsoluteUri(_settings.ApiUrl, path, query); var uri = BuildAbsoluteUri(_settings.ApiUrl, path, query);
using var req = new HttpRequestMessage(method, uri); using var req = new HttpRequestMessage(method, uri);
// Authorization PRO REQUEST setzen (immer, wenn Token vorhanden ist) // Authorization PRO REQUEST setzen (immer, wenn Token vorhanden ist)
// Hinweis: Das QR-Token kann RFC-unzulässige Zeichen (z. B. '|') enthalten. // Hinweis: Das QR-Token kann RFC-unzulässige Zeichen (z. B. '|') enthalten.
// AuthenticationHeaderValue würde solche Werte ablehnen. Daher ohne Validierung setzen. // AuthenticationHeaderValue würde solche Werte ablehnen. Daher ohne Validierung setzen.
var currentToken = _settings.ApiKey; var currentToken = _settings.ApiKey;
if (!string.IsNullOrWhiteSpace(currentToken)) { if (!string.IsNullOrWhiteSpace(currentToken)) {
// Vorherige Header (falls vorhanden) entfernen, um Duplikate zu vermeiden // Vorherige Header (falls vorhanden) entfernen, um Duplikate zu vermeiden
req.Headers.Remove("Authorization"); req.Headers.Remove("Authorization");
req.Headers.TryAddWithoutValidation("Authorization", $"Bearer {currentToken}"); req.Headers.TryAddWithoutValidation("Authorization", $"Bearer {currentToken}");
} }
if (body is HttpContent httpContent) { if (body is HttpContent httpContent) {
req.Content = httpContent; req.Content = httpContent;
} else if (body is not null) { } else if (body is not null) {
req.Content = JsonContent.Create(body, options: _json); req.Content = JsonContent.Create(body, options: _json);
} }
// Sicherstellen, dass Accept: application/json auch auf Request-Ebene vorhanden ist (z. B. für LoginWithToken GET) // 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)) { if (!req.Headers.Accept.Any(h => h.MediaType?.Equals("application/json", StringComparison.OrdinalIgnoreCase) == true)) {
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
} }
using var res = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); using var res = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
var text = await res.Content.ReadAsStringAsync(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<string, string?>? query = null, CancellationToken ct = default) public Task DeleteAsync(string path, IDictionary<string, string?>? query = null, CancellationToken ct = default)
=> SendAsync<object>(HttpMethod.Delete, path, null, query, ct); => SendAsync<object>(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) { private static string TryGetMessage(string text) {
try { try {
@@ -122,29 +122,29 @@ internal sealed class ApiClient : IApiClient {
return text; return text;
} }
private static Uri BuildAbsoluteUri(string baseUrl, string path, IDictionary<string, string?>? query) { private static Uri BuildAbsoluteUri(string baseUrl, string path, IDictionary<string, string?>? query) {
if (string.IsNullOrWhiteSpace(baseUrl)) if (string.IsNullOrWhiteSpace(baseUrl))
throw new InvalidOperationException( throw new InvalidOperationException(
"ApiUrl ist leer. Bitte zuerst eine gültige Server-URL setzen (Preferences key 'apiUrl')."); "ApiUrl ist leer. Bitte zuerst eine gültige Server-URL setzen (Preferences key 'apiUrl').");
// Basis muss absolut sein (z. B. https://host/appapi/) // Basis muss absolut sein (z. B. https://host/appapi/)
var baseUri = new Uri(baseUrl, UriKind.Absolute); var baseUri = new Uri(baseUrl, UriKind.Absolute);
// Pfad relativ zur Basis aufbauen // Pfad relativ zur Basis aufbauen
string relativePath = path ?? string.Empty; string relativePath = path ?? string.Empty;
if (query is not null && query.Count > 0) { if (query is not null && query.Count > 0) {
var sb = new StringBuilder(relativePath); var sb = new StringBuilder(relativePath);
sb.Append(relativePath.Contains('?') ? '&' : '?'); sb.Append(relativePath.Contains('?') ? '&' : '?');
sb.Append(string.Join('&', query sb.Append(string.Join('&', query
.Where(kv => kv.Value is not null) .Where(kv => kv.Value is not null)
.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value!)}"))); .Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value!)}")));
relativePath = sb.ToString(); relativePath = sb.ToString();
} }
// Wenn path bereits absolut ist, direkt verwenden // Wenn path bereits absolut ist, direkt verwenden
if (Uri.TryCreate(relativePath, UriKind.Absolute, out var absoluteFromPath)) if (Uri.TryCreate(relativePath, UriKind.Absolute, out var absoluteFromPath))
return absoluteFromPath; return absoluteFromPath;
return new Uri(baseUri, relativePath); return new Uri(baseUri, relativePath);
} }
} }

View File

@@ -87,7 +87,7 @@ internal sealed class AuthService : IAuthService {
//} //}
if (url.EndsWith("/", StringComparison.Ordinal)) { if (url.EndsWith("/", StringComparison.Ordinal)) {
url = url.Remove(url.Length - 1, 1); url = url.TrimEnd('/');
} }
return url; return url;
} }

View File

@@ -8,138 +8,142 @@ namespace Jugenddienst_Stunden.ViewModels;
/// ViewModel für die Loginseite (MVVM) /// ViewModel für die Loginseite (MVVM)
/// </summary> /// </summary>
public partial class LoginViewModel : ObservableObject { public partial class LoginViewModel : ObservableObject {
private readonly IAuthService _auth; private readonly IAuthService _auth;
private readonly IAppSettings _settings; private readonly IAppSettings _settings;
private readonly IAlertService? _alerts; private readonly IAlertService? _alerts;
private DateTime _lastDetectionTime = DateTime.MinValue; private DateTime _lastDetectionTime = DateTime.MinValue;
private readonly TimeSpan _detectionInterval = TimeSpan.FromSeconds(5); private readonly TimeSpan _detectionInterval = TimeSpan.FromSeconds(5);
public event EventHandler<string>? AlertEvent; public event EventHandler<string>? AlertEvent;
public event EventHandler<string>? InfoEvent; public event EventHandler<string>? InfoEvent;
/// <summary> /// <summary>
/// Name der Anwendung /// Name der Anwendung
/// </summary> /// </summary>
public string AppTitle => AppInfo.Name; public string AppTitle => AppInfo.Name;
/// <summary> /// <summary>
/// Programmversion /// Programmversion
/// </summary> /// </summary>
public string Version => AppInfo.VersionString; public string Version => AppInfo.VersionString;
[ObservableProperty] [ObservableProperty]
private string message = "Scanne den QR-Code von deinem Benutzerprofil auf der Stundenseite."; private string message = "Scanne den QR-Code von deinem Benutzerprofil auf der Stundenseite.";
[ObservableProperty] [ObservableProperty]
private string? server; private string? server;
[ObservableProperty] [ObservableProperty]
private string title = Preferences.Default.Get("name", "Nicht") + " " + Preferences.Default.Get("surname", "eingeloggt"); private string? serverLabel;
[ObservableProperty] [ObservableProperty]
private string? username; private string title = Preferences.Default.Get("name", "Nicht") + " " + Preferences.Default.Get("surname", "eingeloggt");
[ObservableProperty] [ObservableProperty]
private string? password; private string? username;
[ObservableProperty] [ObservableProperty]
private bool isManualMode; private string? password;
[ObservableProperty] [ObservableProperty]
private bool isBusy; private bool isManualMode;
[ObservableProperty] [ObservableProperty]
private bool isDetecting; private bool isBusy;
// Explizite Command-Property für den QR-Scanner-Event, damit das Binding in XAML zuverlässig greift [ObservableProperty]
public IAsyncRelayCommand<object?> QrDetectedCommand { get; } private bool isDetecting;
public LoginViewModel(IAuthService auth, IAppSettings settings) { // Explizite Command-Property für den QR-Scanner-Event, damit das Binding in XAML zuverlässig greift
_auth = auth; public IAsyncRelayCommand<object?> QrDetectedCommand { get; }
_settings = settings;
// gespeicherte Präferenz für Logintyp laden public LoginViewModel(IAuthService auth, IAppSettings settings) {
var lt = Preferences.Default.Get("logintype", "qr"); _auth = auth;
isManualMode = string.Equals(lt, "manual", StringComparison.OrdinalIgnoreCase); _settings = settings;
// Scanner standardmäßig nur im QR-Modus aktivieren
IsDetecting = !isManualMode;
// Serveranzeige vorbereiten // gespeicherte Präferenz für Logintyp laden
var apiUrl = Preferences.Default.Get("apiUrl", string.Empty); var lt = Preferences.Default.Get("logintype", "qr");
if (!string.IsNullOrWhiteSpace(apiUrl)) { isManualMode = string.Equals(lt, "manual", StringComparison.OrdinalIgnoreCase);
Server = "Server: " + apiUrl.Replace("/appapi", "").Replace("https://", "").Replace("http://", ""); // Scanner standardmäßig nur im QR-Modus aktivieren
} IsDetecting = !isManualMode;
// Command initialisieren // Serveranzeige vorbereiten
QrDetectedCommand = new AsyncRelayCommand<object?>(QrDetectedAsync); 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) // Command initialisieren
internal LoginViewModel(IAuthService auth, IAppSettings settings, IAlertService alertService) : this(auth, settings) { QrDetectedCommand = new AsyncRelayCommand<object?>(QrDetectedAsync);
_alerts = alertService; }
if (alertService is not null) {
alertService.AlertRaised += (s, msg) => AlertEvent?.Invoke(this, msg);
}
}
partial void OnIsManualModeChanged(bool value) { // DI-Konstruktor: AlertService anbinden und Alerts an VM-Event weiterreichen (analog StundeViewModel)
Preferences.Default.Set("logintype", value ? "manual" : "qr"); internal LoginViewModel(IAuthService auth, IAppSettings settings, IAlertService alertService) : this(auth, settings) {
// Scanner nur aktiv, wenn QR-Modus aktiv ist _alerts = alertService;
IsDetecting = !value; if (alertService is not null) {
} alertService.AlertRaised += (s, msg) => AlertEvent?.Invoke(this, msg);
}
}
[RelayCommand] partial void OnIsManualModeChanged(bool value) {
private async Task LoginAsync() { Preferences.Default.Set("logintype", value ? "manual" : "qr");
if (IsBusy) return; // Scanner nur aktiv, wenn QR-Modus aktiv ist
try { IsDetecting = !value;
IsBusy = true; }
var user = await _auth.LoginWithCredentials(Username?.Trim() ?? string.Empty,
Password ?? string.Empty,
(Server ?? string.Empty).Replace("Server:", string.Empty).Trim());
Title = $"{user.Name} {user.Surname}"; [RelayCommand]
InfoEvent?.Invoke(this, "Login erfolgreich"); 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"); Title = $"{user.Name} {user.Surname}";
} catch (Exception ex) { InfoEvent?.Invoke(this, "Login erfolgreich");
if (_alerts is not null) {
_alerts.Raise(ex.Message);
} else {
AlertEvent?.Invoke(this, ex.Message);
}
} finally {
IsBusy = false;
}
}
private async Task QrDetectedAsync(object? args) { await Shell.Current.GoToAsync("//StundenPage");
var now = DateTime.Now; } catch (Exception ex) {
if ((now - _lastDetectionTime) <= _detectionInterval) return; if (_alerts is not null) {
_lastDetectionTime = now; _alerts.Raise(ex.Message);
} else {
AlertEvent?.Invoke(this, ex.Message);
}
} finally {
IsBusy = false;
}
}
try { private async Task QrDetectedAsync(object? args) {
var token = ExtractFirstBarcodeValue(args); var now = DateTime.Now;
if (string.IsNullOrWhiteSpace(token)) return; if ((now - _lastDetectionTime) <= _detectionInterval) return;
_lastDetectionTime = now;
var user = await _auth.LoginWithToken(token); try {
Title = $"{user.Name} {user.Surname}"; var token = ExtractFirstBarcodeValue(args);
if (string.IsNullOrWhiteSpace(token)) return;
await Shell.Current.GoToAsync("//StundenPage"); var user = await _auth.LoginWithToken(token);
} catch (Exception ex) { Title = $"{user.Name} {user.Surname}";
if (_alerts is not null) {
_alerts.Raise(ex.Message);
} else {
AlertEvent?.Invoke(this, ex.Message);
}
}
}
private static string? ExtractFirstBarcodeValue(object? args) { await Shell.Current.GoToAsync("//StundenPage");
try { } catch (Exception ex) {
if (args is ZXing.Net.Maui.BarcodeDetectionEventArgs e && e.Results is not null) { if (_alerts is not null) {
return e.Results.FirstOrDefault()?.Value; _alerts.Raise(ex.Message);
} } else {
} catch { } AlertEvent?.Invoke(this, ex.Message);
return null; }
} }
}
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;
}
} }

View File

@@ -44,7 +44,7 @@
</Grid> </Grid>
</Grid> </Grid>
<Label x:Name="ServerLabel" Text="{Binding Server}" <Label x:Name="ServerLabel" Text="{Binding ServerLabel}"
IsVisible="{Binding Server, Converter={StaticResource StringVisibilityConverter}}" /> IsVisible="{Binding Server, Converter={StaticResource StringVisibilityConverter}}" />
<VerticalStackLayout x:Name="LoginQR" Margin="0,20,0,0" IsVisible="{Binding IsManualMode, Converter={StaticResource InverseBoolConverter}}"> <VerticalStackLayout x:Name="LoginQR" Margin="0,20,0,0" IsVisible="{Binding IsManualMode, Converter={StaticResource InverseBoolConverter}}">