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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TODO.md b/TODO.md
index 604256d..3e3af21 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1303,8 +1303,22 @@ Grundgerüst existiert in [LehrerApp.Sync](LehrerApp.Sync/) und [LehrerApp.Api](
ist aber nur aktiv, wenn eine Server-URL konfiguriert ist.
### 10.1 Client
-- [ ] **10.1.1** Sync-Einrichtung in den Einstellungen: Server-URL, Login, Token speichern.
-- [ ] **10.1.2** Verbindungstest mit klarer Fehlermeldung (nicht erreichbar / Token ungültig).
+- [x] **10.1.1** Sync-Einrichtung in den Einstellungen: Server-URL, Login, Token speichern.
+
+ **Umsetzung:** Neuer Tab "Synchronisation" in den Einstellungen. Neu
+ `LehrerApp.Desktop/Services/SyncSettingsService.cs` (Muster `AiSettingsService`: Token
+ AES-256-verschlüsselt über `SyncCrypto`, eigener rein lokaler Schlüssel — ersetzt die
+ bisherigen Klartext-Helfer `AppBootstrapper.LoadServerUrl`/`SaveServerUrl` und die
+ unverschlüsselte `auth.token`-Datei). Neu `SyncAuthService` für den Login-HTTP-Aufruf gegen
+ `/api/auth/login`. Speichern/Anmelden startet die App neu (`AppBootstrapper.
+ RestartApplication`, gleiches Muster wie bei DB-Passwort/AppLock-Änderungen) — `SyncEngine`/
+ `SnapshotService` werden nur einmalig beim Start registriert, es gibt keinen
+ Live-Re-Registrierungspfad.
+- [x] **10.1.2** Verbindungstest mit klarer Fehlermeldung (nicht erreichbar / Token ungültig).
+
+ **Umsetzung:** `SyncAuthService.TestConnectionAsync` unterscheidet drei Zustände (erreichbar
+ & angemeldet / erreichbar aber nicht angemeldet bzw. Token ungültig / nicht erreichbar) über
+ einen GET auf `/api/sync/status` mit optionalem Bearer-Token.
- [ ] **10.1.3** Konfliktanzeige in der UI — was `ConflictResolver` entscheidet, muss sichtbar sein.
- [x] **10.1.4** Manuelles Auslösen einer vollständigen Synchronisation.