diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index e574611..53f589c 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -26,6 +26,18 @@ public static class TestSupport public static AiPlanningService BuildAiPlanningService() => new( new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]), new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); + + /// Analog zu , eigenes Temp-Verzeichnis je Aufruf. + public static SyncSettingsService BuildSyncSettingsService() + { + var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-syncsettings-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempPath); + return new SyncSettingsService(tempPath); + } + + /// Kein echter HTTP-Aufruf, solange SyncSettingsService.IsLoggedIn false ist (siehe + /// BuildAiPlanningService). + public static SyncAuthService BuildSyncAuthService() => new(new HttpClient()); } public class FakeStudents(List all) : IStudentRepository diff --git a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs index 8d228b2..122d41c 100644 --- a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs @@ -26,7 +26,8 @@ public sealed class SettingsViewModelTests new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(), - new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService()); + new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), + TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService()); } [Fact] @@ -102,7 +103,8 @@ public sealed class SettingsViewModelTests new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), - new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService()); + new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), + TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService()); vm.SelectedStateName = "Bayern"; @@ -124,7 +126,8 @@ public sealed class SettingsViewModelTests new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, - new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService()); + new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), + TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService()); vm.PeriodTimes[0].StartText = "08:00"; vm.PeriodTimes[0].EndText = "08:45"; @@ -150,7 +153,8 @@ public sealed class SettingsViewModelTests new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, - new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService()); + new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), + TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService()); vm.PeriodTimes[0].StartText = "08:45"; vm.PeriodTimes[0].EndText = "08:00"; diff --git a/LehrerApp.Desktop.Tests/SyncSettingsServiceTests.cs b/LehrerApp.Desktop.Tests/SyncSettingsServiceTests.cs new file mode 100644 index 0000000..e0ad193 --- /dev/null +++ b/LehrerApp.Desktop.Tests/SyncSettingsServiceTests.cs @@ -0,0 +1,76 @@ +using LehrerApp.Desktop.Services; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class SyncSettingsServiceTests +{ + private static string BuildTempPath() + { + var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-syncsettingssvc-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + return path; + } + + [Fact] + public void SetServerUrl_PersistiertUeberNeueInstanz() + { + var path = BuildTempPath(); + new SyncSettingsService(path).SetServerUrl("https://sync.example.com"); + + var reloaded = new SyncSettingsService(path); + + Assert.Equal("https://sync.example.com", reloaded.ServerUrl); + } + + [Fact] + public void SetCredentialsAndToken_TokenIstVerschluesseltAbrufbar() + { + var service = new SyncSettingsService(BuildTempPath()); + + service.SetCredentialsAndToken("sebastian", "geheimes-token-123"); + + Assert.True(service.IsLoggedIn); + Assert.Equal("sebastian", service.Username); + Assert.Equal("geheimes-token-123", service.GetToken()); + } + + [Fact] + public void Token_UeberlebtNeueInstanzMitDemselbenPfad() + { + var path = BuildTempPath(); + new SyncSettingsService(path).SetCredentialsAndToken("sebastian", "token-abc"); + + var reloaded = new SyncSettingsService(path); + + Assert.True(reloaded.IsLoggedIn); + Assert.Equal("token-abc", reloaded.GetToken()); + } + + [Fact] + public void TokenDateiEnthaeltNichtDenKlartext() + { + var path = BuildTempPath(); + var service = new SyncSettingsService(path); + service.SetCredentialsAndToken("sebastian", "geheimes-token-123"); + + var raw = File.ReadAllText(Path.Combine(path, "sync-settings.json")); + + Assert.DoesNotContain("geheimes-token-123", raw); + } + + [Fact] + public void Logout_EntferntTokenBehaeltAberServerUrlUndUsername() + { + var service = new SyncSettingsService(BuildTempPath()); + service.SetServerUrl("https://sync.example.com"); + service.SetCredentialsAndToken("sebastian", "token-abc"); + + service.Logout(); + + Assert.False(service.IsLoggedIn); + Assert.Null(service.GetToken()); + Assert.Equal("https://sync.example.com", service.ServerUrl); + Assert.Equal("sebastian", service.Username); + } +} diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index b13e076..09640b0 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -164,6 +164,10 @@ public static class AppBootstrapper services.AddSingleton(); // ── Sync (optional – nur wenn Server konfiguriert) ──────────────────── + var syncSettings = new SyncSettingsService(appData); + services.AddSingleton(syncSettings); + services.AddSingleton(_ => new SyncAuthService(new HttpClient())); + services.AddSingleton(_ => new EventQueue(queuePath)); services.AddSingleton(sp => new ConflictResolver(sp.GetRequiredService())); services.AddSingleton(_ => @@ -173,18 +177,18 @@ public static class AppBootstrapper return key; }); - var serverUrl = LoadServerUrl(appData); + var serverUrl = syncSettings.ServerUrl; var deviceId = LoadOrCreateDeviceId(appData); if (!string.IsNullOrEmpty(serverUrl)) { services.AddSingleton(sp => new EventApplier( sp.GetRequiredService(), sp.GetRequiredService(), - BuildHttp(serverUrl, appData))); + BuildHttp(serverUrl, syncSettings))); services.AddSingleton(sp => new SyncEventPublisher( sp.GetRequiredService(), deviceId, sp.GetRequiredService())); services.AddSingleton(sp => new AttachmentSyncer( - sp.GetRequiredService(), BuildHttp(serverUrl, appData), + sp.GetRequiredService(), BuildHttp(serverUrl, syncSettings), sp.GetRequiredService())); services.AddSingleton(sp => new SyncEngine( @@ -192,7 +196,7 @@ public static class AppBootstrapper sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - BuildHttp(serverUrl, appData), + BuildHttp(serverUrl, syncSettings), new SyncConfig { ServerUrl = serverUrl, @@ -202,7 +206,7 @@ public static class AppBootstrapper })); services.AddSingleton(sp => new SnapshotService( - BuildHttp(serverUrl, appData), + BuildHttp(serverUrl, syncSettings), sp.GetRequiredService(), sp.GetRequiredService(), DeviceType.Desktop, DbPath, keyPath)); @@ -247,25 +251,16 @@ public static class AppBootstrapper // ── Hilfsmethoden ───────────────────────────────────────────────────────── - private static HttpClient BuildHttp(string url, string appData) + private static HttpClient BuildHttp(string url, SyncSettingsService syncSettings) { var http = new HttpClient { BaseAddress = new Uri(url) }; - var tokenPath = Path.Combine(appData, "auth.token"); - if (File.Exists(tokenPath)) + var token = syncSettings.GetToken(); + if (token is not null) http.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue( - "Bearer", File.ReadAllText(tokenPath).Trim()); + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); return http; } - public static string LoadServerUrl(string? path = null) => - File.Exists(Path.Combine(path ?? AppDataPath, "server.txt")) - ? File.ReadAllText(Path.Combine(path ?? AppDataPath, "server.txt")).Trim() - : ""; - - public static void SaveServerUrl(string url) => - File.WriteAllText(Path.Combine(AppDataPath, "server.txt"), url); - private static string LoadOrCreateDeviceId(string appData) { var p = Path.Combine(appData, "device.id"); diff --git a/LehrerApp.Desktop/Services/SyncAuthService.cs b/LehrerApp.Desktop/Services/SyncAuthService.cs new file mode 100644 index 0000000..a28aaa8 --- /dev/null +++ b/LehrerApp.Desktop/Services/SyncAuthService.cs @@ -0,0 +1,59 @@ +using System.Net; +using System.Net.Http.Json; + +namespace LehrerApp.Desktop.Services; + +public class SyncAuthException(string userMessage) : Exception(userMessage); + +public enum SyncConnectionTestResult { Ok, Unauthorized, Unreachable } + +/// +/// Login/Verbindungstest gegen den eigenen Sync-Server (LehrerApp.Api) — getrennt vom bereits +/// bestehenden (anderes Backend, anderer Wire-Vertrag). Nimmt die +/// Server-URL je Aufruf entgegen statt fest im Konstruktor, weil sie über die Einstellungen zur +/// Laufzeit geändert werden kann (anders als die feste KI-Backend-URL). +/// +public class SyncAuthService(HttpClient http) +{ + public async Task LoginAsync(string serverUrl, string username, string password) + { + HttpResponseMessage resp; + try + { + resp = await http.PostAsJsonAsync(CombineUrl(serverUrl, "/api/auth/login"), new { username, password }); + } + catch (HttpRequestException) + { + throw new SyncAuthException( + "Der Sync-Server ist nicht erreichbar. Bitte Adresse und Internetverbindung prüfen."); + } + + if (resp.StatusCode == HttpStatusCode.Unauthorized) + throw new SyncAuthException("Benutzername oder Passwort ist falsch."); + if (!resp.IsSuccessStatusCode) + throw new SyncAuthException("Anmeldung fehlgeschlagen. Bitte später erneut versuchen."); + + var result = await resp.Content.ReadFromJsonAsync(); + return result?.Token ?? throw new SyncAuthException("Unerwartete Antwort des Sync-Servers."); + } + + public async Task TestConnectionAsync(string serverUrl, string? token) + { + using var req = new HttpRequestMessage(HttpMethod.Get, CombineUrl(serverUrl, "/api/sync/status")); + if (token is not null) + req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + + try + { + var resp = await http.SendAsync(req); + if (resp.StatusCode == HttpStatusCode.Unauthorized) return SyncConnectionTestResult.Unauthorized; + return resp.IsSuccessStatusCode ? SyncConnectionTestResult.Ok : SyncConnectionTestResult.Unreachable; + } + catch (HttpRequestException) { return SyncConnectionTestResult.Unreachable; } + } + + private static string CombineUrl(string serverUrl, string path) => + new Uri(new Uri(serverUrl), path).ToString(); + + private record LoginResult(string Token, string UserId); +} diff --git a/LehrerApp.Desktop/Services/SyncSettingsService.cs b/LehrerApp.Desktop/Services/SyncSettingsService.cs new file mode 100644 index 0000000..2d7f427 --- /dev/null +++ b/LehrerApp.Desktop/Services/SyncSettingsService.cs @@ -0,0 +1,82 @@ +using LehrerApp.Sync.Crypto; +using System.Text.Json; + +namespace LehrerApp.Desktop.Services; + +internal class SyncSettingsConfig +{ + public string ServerUrl { get; set; } = ""; + public string Username { get; set; } = ""; + public string? EncryptedToken { get; set; } +} + +/// +/// Ersetzt die früheren Klartext-Helfer (AppBootstrapper.LoadServerUrl/SaveServerUrl, +/// unverschlüsselte auth.token-Datei). Token liegt wie beim KI-Backend-Token (siehe +/// ) AES-256-verschlüsselt über , mit +/// eigenem, rein lokalem Schlüssel — nicht zu verwechseln mit dem in AppBootstrapper +/// separat verwalteten Sync-Schlüssel (sync.key), der die Ereignis-Payloads zwischen den +/// gepaarten Geräten verschlüsselt. +/// +public class SyncSettingsService +{ + private readonly string _configPath; + private readonly string _keyPath; + private readonly byte[] _tokenKey; + private SyncSettingsConfig _config; + + public string ServerUrl => _config.ServerUrl; + public string Username => _config.Username; + public bool IsLoggedIn => _config.EncryptedToken is not null; + + public SyncSettingsService(string appDataPath) + { + _configPath = Path.Combine(appDataPath, "sync-settings.json"); + _keyPath = Path.Combine(appDataPath, "sync-token.key"); + _tokenKey = SyncCrypto.LoadKey(_keyPath) ?? GenerateAndSaveKey(); + _config = Load(); + } + + public void SetServerUrl(string url) + { + _config.ServerUrl = url.Trim(); + Save(); + } + + public void SetCredentialsAndToken(string username, string token) + { + _config.Username = username; + _config.EncryptedToken = SyncCrypto.EncryptObject(token, _tokenKey); + Save(); + } + + public string? GetToken() => + _config.EncryptedToken is null ? null : SyncCrypto.DecryptObject(_config.EncryptedToken, _tokenKey); + + public void Logout() + { + _config.EncryptedToken = null; + Save(); + } + + private byte[] GenerateAndSaveKey() + { + var key = SyncCrypto.GenerateKey(); + SyncCrypto.SaveKey(key, _keyPath); + return key; + } + + private void Save() => File.WriteAllText(_configPath, JsonSerializer.Serialize(_config)); + + private SyncSettingsConfig Load() + { + try + { + if (File.Exists(_configPath)) + return JsonSerializer.Deserialize(File.ReadAllText(_configPath)) + ?? new SyncSettingsConfig(); + } + catch { /* beschädigte Konfiguration -> Standardwert */ } + return new SyncSettingsConfig(); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs index 8e7a88f..2ab0cab 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs @@ -164,6 +164,15 @@ public partial class SettingsViewModel : ObservableObject [ObservableProperty] private bool _aiIsLoggedIn; [ObservableProperty] private string _aiBalanceDisplay = ""; + // ── 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 = ""; + // ── Konstruktor ─────────────────────────────────────────────────────────── private readonly ISchoolHolidayRepository _schoolHolidays; @@ -172,6 +181,8 @@ public partial class SettingsViewModel : ObservableObject private readonly ISupervisionDutyRepository _supervisionDuties; private readonly AiSettingsService _aiSettings; private readonly AiPlanningService _aiPlanning; + private readonly SyncSettingsService _syncSettings; + private readonly SyncAuthService _syncAuth; private readonly CompetencyCatalogImportService _catalogImport; public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo, @@ -182,7 +193,8 @@ public partial class SettingsViewModel : ObservableObject IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays, SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule, ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates, - AiSettingsService aiSettings, AiPlanningService aiPlanning) + AiSettingsService aiSettings, AiPlanningService aiPlanning, + SyncSettingsService syncSettings, SyncAuthService syncAuth) { _subjects = subjects; _domainRepo = domainRepo; @@ -204,6 +216,8 @@ public partial class SettingsViewModel : ObservableObject _letterTemplates = letterTemplates; _aiSettings = aiSettings; _aiPlanning = aiPlanning; + _syncSettings = syncSettings; + _syncAuth = syncAuth; _catalogImport = new CompetencyCatalogImportService(domainRepo); LoadSubjects(); LoadShorthandCodes(); @@ -221,6 +235,7 @@ public partial class SettingsViewModel : ObservableObject LoadSupervisionDuties(); LoadLetterTemplates(); LoadAiSettings(); + LoadSyncSettings(); } // ── Word-Briefvorlagen: Import und Validierung ────────────────────────── @@ -362,6 +377,65 @@ public partial class SettingsViewModel : ObservableObject AiBalanceDisplay = ""; } + // ── 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.", + _ => "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 = await _syncAuth.LoginAsync(SyncServerUrl, SyncUsername, SyncPassword); + _syncSettings.SetServerUrl(SyncServerUrl); + _syncSettings.SetCredentialsAndToken(SyncUsername, token); + SyncPassword = ""; + AppBootstrapper.RestartApplication(); + } + catch (SyncAuthException ex) { SyncLoginError = ex.Message; } + } + + [RelayCommand] + private void SyncLogout() + { + _syncSettings.Logout(); + AppBootstrapper.RestartApplication(); + } + // ── Stundenraster: Laden / Speichern ───────────────────────────────────── private void LoadPeriodTimes() diff --git a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml index f0caf03..c722e8b 100644 --- a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml +++ b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml @@ -759,6 +759,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + +