using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Data; using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Sync; using LehrerApp.Sync.Crypto; using System.Collections.ObjectModel; using System.Globalization; using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Serialization; namespace LehrerApp.Desktop.ViewModels.Settings; public partial class SettingsViewModel { // ── Synchronisation (Kapitel 10) ────────────────────────────────────────── [ObservableProperty] private string _syncServerUrl = ""; [ObservableProperty] private string _syncUsername = ""; [ObservableProperty] private string _syncPassword = ""; [ObservableProperty] private string _syncLoginError = ""; [ObservableProperty] private bool _syncIsLoggedIn; [ObservableProperty] private string _syncConnectionStatus = ""; [ObservableProperty] private string _syncForceResyncStatus = ""; [ObservableProperty] private bool _syncForceResyncBusy; [ObservableProperty] private bool _syncKeyWasRegenerated; [ObservableProperty] private string _recoveryCode = ""; [ObservableProperty] private string _recoveryCodeInput = ""; [ObservableProperty] private string _recoveryStatus = ""; [ObservableProperty] private bool _recoveryBusy; /// Vom Code-Behind gesetzt: lässt den Nutzer die Wiederherstellungsdatei selbst speichern /// (z.B. USB-Stick, eigene Cloud). Liefert false bei Abbruch. public Func>? OnSaveRecoveryFile { get; set; } /// Vom Code-Behind gesetzt: lässt den Nutzer eine zuvor gesicherte Wiederherstellungsdatei /// auswählen. Liefert null bei Abbruch. public Func>? OnPickRecoveryFile { get; set; } /// Vom Code-Behind gesetzt: bestätigt vor dem Überschreiben des aktuellen Sync-Schlüssels /// dieses Geräts (die App startet danach neu, siehe RedeemRecoveryCode). public Func>? OnConfirmRecoveryRestore { get; set; } public ObservableCollection SyncConflicts { get; } = []; // ── Synchronisation: Laden / Anmelden / Abmelden / Verbindungstest ─────── // // Server-URL, Zugangsdaten und Token werden erst nach erfolgreichem Login zusammen // gespeichert (ein Restart deckt beides ab) — SyncEngine/SnapshotService werden nur einmalig // beim Start registriert (siehe AppBootstrapper), es gibt keinen Live-Re-Registrierungspfad. private void LoadSyncSettings() { SyncServerUrl = _syncSettings.ServerUrl; SyncUsername = _syncSettings.Username; SyncIsLoggedIn = _syncSettings.IsLoggedIn; } [RelayCommand] private async Task SyncTestConnection() { SyncConnectionStatus = "Teste Verbindung…"; if (string.IsNullOrWhiteSpace(SyncServerUrl)) { SyncConnectionStatus = "Bitte Server-Adresse eingeben."; return; } var result = await _syncAuth.TestConnectionAsync(SyncServerUrl, _syncSettings.GetToken()); SyncConnectionStatus = result switch { SyncConnectionTestResult.Ok => "Verbindung erfolgreich.", SyncConnectionTestResult.Unauthorized => "Server erreichbar, aber nicht angemeldet oder Anmeldung abgelaufen.", SyncConnectionTestResult.IncompatibleVersion => "Diese App-Version ist veraltet oder nicht mehr mit dem Sync-Server kompatibel. " + "Bitte aktualisiere die LehrerApp.", _ => "Server nicht erreichbar. Bitte Adresse und Internetverbindung prüfen.", }; } [RelayCommand] private async Task SyncLogin() { SyncLoginError = ""; var valid = true; if (string.IsNullOrWhiteSpace(SyncServerUrl)) { SyncLoginError = "Server-Adresse erforderlich."; valid = false; } if (string.IsNullOrWhiteSpace(SyncUsername)) { SyncLoginError = "Benutzername erforderlich."; valid = false; } if (string.IsNullOrWhiteSpace(SyncPassword)) { SyncLoginError = "Passwort erforderlich."; valid = false; } if (!valid) return; try { var (token, userId) = await _syncAuth.LoginAsync(SyncServerUrl, SyncUsername, SyncPassword); // Kontowechsel-Erkennung anhand der KANONISCHEN userId aus der Server-Antwort, nicht // anhand von SyncUsername: LiteDBs case-insensitive Standard-Collation lässt einen // Login mit abweichender Groß-/Kleinschreibung erfolgreich durch (TODO 10.2.5), meldet // serverseitig aber trotzdem eine ANDERE userId zurück - die lokale Versionsverfolgung // (BasedOnServerSeq je Entität, Pull-Cursor) bezieht sich dann auf ein fremdes Konto // und muss verworfen werden, sonst lehnt der Server jeden weiteren Push mit einem // dauerhaften 404 beim Nachladen ab (siehe TODO 10.3.5). var accountChanged = _syncSettings.LastUserId is not null && _syncSettings.LastUserId != userId; _syncSettings.SetServerUrl(SyncServerUrl); _syncSettings.SetCredentialsAndToken(SyncUsername, token, userId); if (accountChanged) { _eventQueue.SetLastServerSeq(0); _eventQueue.ResetKnownServerSeqs(); } SyncPassword = ""; AppBootstrapper.RestartApplication(); } catch (SyncAuthException ex) { SyncLoginError = ex.Message; } } [RelayCommand] private void SyncLogout() { _syncSettings.Logout(); AppBootstrapper.RestartApplication(); } /// /// Setzt den lokalen Sync-Fortschritt (Pull-Cursor UND die per-Entität-Versionsverfolgung für /// Push) vollständig zurück und lädt danach alle Ereignisse dieses Nutzers erneut vom Server - /// manueller Reparaturweg, falls dieses Gerät wiederholt keine Änderungen eines anderen Geräts /// erhält (z.B. nach einem lokal bereits zu weit vorgerückten Cursor, TODO 10.1.10) oder eigene /// Pushes dauerhaft mit 404 abgelehnt werden (z.B. nach einem Kontowechsel, TODO 10.3.5). /// Sicher wiederholbar: EventApplier wendet jedes Ereignis über Upsert/Delete-by-Id idempotent /// an, und ein Push ohne bekannten Vorstand wird vom Server als Neuanlage behandelt. /// [RelayCommand] private async Task SyncForceFullResync() { if (_syncEngine is null) { SyncForceResyncStatus = "Sync nicht konfiguriert."; return; } SyncForceResyncBusy = true; SyncForceResyncStatus = "Lade alle Ereignisse erneut…"; try { _eventQueue.SetLastServerSeq(0); _eventQueue.ResetKnownServerSeqs(); var result = await _syncEngine.SyncNowAsync(); SyncForceResyncStatus = result.Success ? $"Abgeschlossen - {result.EventsPulled} Ereignis(se) erneut geladen." : $"Fehlgeschlagen: {result.Reason}"; LoadSyncConflicts(); } finally { SyncForceResyncBusy = false; } } // ── Sync-Schlüssel: selbstverwalteter Wiederherstellungscode (10.3.2) ──────────────────── // // Der Sync-Schlüssel verlässt nie den Server im Klartext (siehe SyncCrypto) - eine echte // Wiederherstellung nach Verlust des einzigen Geräts mit dem Schlüssel ist deshalb nur // möglich, wenn vorher proaktiv ein Rettungsanker angelegt wurde. "Code erzeugen" // verschlüsselt den aktuellen Schlüssel mit einem einmalig angezeigten, zufälligen Code // (SyncCrypto.EncryptKeyWithRecoveryCode) und lässt den Nutzer die verschlüsselte Datei // selbst sichern (USB-Stick, eigene Cloud o.ä.) - Datei und Code getrennt aufbewahren, erst // beides zusammen ergibt den Schlüssel. Läuft komplett offline, ohne Server-Beteiligung. [RelayCommand] private async Task CreateRecoveryCode() { if (OnSaveRecoveryFile is null) return; RecoveryBusy = true; RecoveryStatus = ""; RecoveryCode = ""; try { var code = SyncCrypto.GenerateRecoveryCode(); var fileContent = _syncKeyRecovery.CreateRecoveryFile(code); if (!await OnSaveRecoveryFile(fileContent)) { RecoveryStatus = "Abgebrochen."; return; } RecoveryCode = code; RecoveryStatus = "Datei gespeichert. Den Code getrennt von der Datei notieren und " + "sicher aufbewahren - er wird nirgends gespeichert und lässt sich " + "nicht erneut anzeigen."; } finally { RecoveryBusy = false; } } [RelayCommand] private async Task RedeemRecoveryCode() { RecoveryStatus = ""; if (string.IsNullOrWhiteSpace(RecoveryCodeInput)) { RecoveryStatus = "Bitte Code eingeben."; return; } if (OnPickRecoveryFile is null) return; var fileContent = await OnPickRecoveryFile(); if (fileContent is null) return; if (OnConfirmRecoveryRestore is not null && !await OnConfirmRecoveryRestore()) return; try { _syncKeyRecovery.RestoreFromFile(fileContent, RecoveryCodeInput.Trim()); } catch (Exception ex) when (ex is CryptographicException or InvalidOperationException or JsonException) { RecoveryStatus = "Fehlgeschlagen: Code und Datei passen nicht zusammen."; _logger.Error("Sync-Schlüssel-Wiederherstellung fehlgeschlagen", ex); return; } // Der neue Schlüssel liegt jetzt auf der Platte, aber der aktuell im Speicher gehaltene // (falsche) Schlüssel ist ein DI-Singleton und kann zur Laufzeit nicht getauscht werden - // gleiches Muster wie RedeemPairingCode. Lokale Cursor/Versionsverfolgung zurücksetzen, // falls in der Zwischenzeit (mit dem gerade ersetzten, falschen Schlüssel) bereits etwas // synchronisiert wurde - sicher wiederholbar, siehe SyncForceFullResync. _eventQueue.SetLastServerSeq(0); _eventQueue.ResetKnownServerSeqs(); AppBootstrapper.RestartApplication(); } // ── Synchronisation: Konflikte ──────────────────────────────────────────── // // Zeigt, was ConflictResolver bereits entschieden hat (welche Seite gewonnen hat) — kein // Feld-Diff für v1, die Payloads sind clientseitig verschlüsselt und würden hier ohnehin nur // rohes JSON zeigen. Minimal: Entität, Zeitpunkt, Ergebnis, "gesehen"-Aktion. private void LoadSyncConflicts() { SyncConflicts.Clear(); foreach (var c in _eventQueue.GetUnreviewed().OrderByDescending(c => c.DetectedAt)) SyncConflicts.Add(new SyncConflictListItem(c)); } [RelayCommand] private void MarkConflictReviewed(SyncConflictListItem? item) { if (item is null) return; _eventQueue.MarkReviewed(item.Id); SyncConflicts.Remove(item); } } public class SyncConflictListItem(ConflictEntry c) { public Guid Id { get; } = c.Id; public string EntityDisplay { get; } = $"{c.RemoteEvent.EntityType} ({c.RemoteEvent.EntityId[..Math.Min(8, c.RemoteEvent.EntityId.Length)]}…)"; public string DetectedAtDisplay { get; } = c.DetectedAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm"); public string ResolutionDisplay { get; } = c.Resolution switch { "LocalWon" => "Lokale Änderung übernommen (dieses Gerät)", "RemoteWon" => "Änderung vom anderen Gerät übernommen", _ => c.Resolution, }; }