- Dashboard: Fehlzeiten-Warnung lädt Mitarbeitssitzungen einmal vorab statt pro Schüler/Eintrag einzeln nachzuschlagen (N+1 vermieden); neues IParticipationSessionRepository.GetAll() dafür. - CI: .gitea/workflows/ci.yml baut und testet bei jedem Push/PR. Dabei fehlende Release|Any CPU-Konfiguration für 6 Projekte in der .sln behoben (LehrerApp.Data.Tests wurde bei Release-Builds der Solution bislang stillschweigend übersprungen). TreatWarningsAsErrors jetzt aktiv. - SettingsViewModel (1986 Zeilen) als partial class auf 20 Themen- dateien aufgeteilt, Verhalten unverändert. - Backup: optionaler zweiter Sicherungsordner (USB-Stick/Netzlaufwerk, best-effort) und Integritätsprüfung nach jedem Backup (DatabaseEncryptionService.CanOpenAndRead). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
246 lines
12 KiB
C#
246 lines
12 KiB
C#
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<string, Task<bool>>? OnSaveRecoveryFile { get; set; }
|
|
/// Vom Code-Behind gesetzt: lässt den Nutzer eine zuvor gesicherte Wiederherstellungsdatei
|
|
/// auswählen. Liefert null bei Abbruch.
|
|
public Func<Task<string?>>? 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<Task<bool>>? OnConfirmRecoveryRestore { get; set; }
|
|
|
|
public ObservableCollection<SyncConflictListItem> 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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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,
|
|
};
|
|
}
|