LoginFixes
This commit is contained in:
@@ -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<T> GetAsync<T>(string path, IDictionary<string, string?>? query = null, CancellationToken ct = default)
|
||||
=> SendAsync<T>(HttpMethod.Get, path, null, query, ct);
|
||||
|
||||
public async Task<T> SendAsync<T>(HttpMethod method, string path, object? body = null,
|
||||
IDictionary<string, string?>? 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<T> SendAsync<T>(HttpMethod method, string path, object? body = null,
|
||||
IDictionary<string, string?>? 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<string, string?>? query = null, CancellationToken ct = default)
|
||||
=> 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) {
|
||||
try {
|
||||
@@ -122,29 +122,29 @@ internal sealed class ApiClient : IApiClient {
|
||||
return text;
|
||||
}
|
||||
|
||||
private static Uri BuildAbsoluteUri(string baseUrl, string path, IDictionary<string, string?>? 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<string, string?>? 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -8,138 +8,142 @@ namespace Jugenddienst_Stunden.ViewModels;
|
||||
/// ViewModel für die Loginseite (MVVM)
|
||||
/// </summary>
|
||||
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<string>? AlertEvent;
|
||||
public event EventHandler<string>? InfoEvent;
|
||||
public event EventHandler<string>? AlertEvent;
|
||||
public event EventHandler<string>? InfoEvent;
|
||||
|
||||
/// <summary>
|
||||
/// Name der Anwendung
|
||||
/// </summary>
|
||||
public string AppTitle => AppInfo.Name;
|
||||
/// <summary>
|
||||
/// Name der Anwendung
|
||||
/// </summary>
|
||||
public string AppTitle => AppInfo.Name;
|
||||
|
||||
/// <summary>
|
||||
/// Programmversion
|
||||
/// </summary>
|
||||
public string Version => AppInfo.VersionString;
|
||||
/// <summary>
|
||||
/// Programmversion
|
||||
/// </summary>
|
||||
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<object?> 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<object?> 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<object?>(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<object?>(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;
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Label x:Name="ServerLabel" Text="{Binding Server}"
|
||||
<Label x:Name="ServerLabel" Text="{Binding ServerLabel}"
|
||||
IsVisible="{Binding Server, Converter={StaticResource StringVisibilityConverter}}" />
|
||||
|
||||
<VerticalStackLayout x:Name="LoginQR" Margin="0,20,0,0" IsVisible="{Binding IsManualMode, Converter={StaticResource InverseBoolConverter}}">
|
||||
|
||||
Reference in New Issue
Block a user