Baustein 8: Settings-Tab "Synchronisation" (Kapitel 10)

Neuer Tab in den Einstellungen: Server-URL, Login, Verbindungstest,
Logout.

- Neu SyncSettingsService (Muster AiSettingsService): Token
  AES-256-verschluesselt ueber SyncCrypto mit eigenem, rein lokalem
  Schluessel - ersetzt die bisherigen Klartext-Helfer
  AppBootstrapper.LoadServerUrl/SaveServerUrl und die unverschluesselte
  auth.token-Datei
- Neu SyncAuthService fuer den Login-HTTP-Aufruf gegen /api/auth/login
  und den Verbindungstest (unterscheidet erreichbar & angemeldet /
  erreichbar aber nicht angemeldet / nicht erreichbar)
- Speichern/Anmelden startet die App neu (AppBootstrapper.
  RestartApplication, gleiches Muster wie bei DB-Passwort/AppLock-
  Aenderungen) - SyncEngine/SnapshotService werden nur einmalig beim
  Start registriert, es gibt keinen Live-Re-Registrierungspfad

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 11:34:49 +02:00
co-authored by Claude Sonnet 5
parent 95345f9c46
commit de98b4ed54
9 changed files with 392 additions and 25 deletions
+12
View File
@@ -26,6 +26,18 @@ public static class TestSupport
public static AiPlanningService BuildAiPlanningService() => new( public static AiPlanningService BuildAiPlanningService() => new(
new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]), new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
/// Analog zu <see cref="BuildAiSettingsService"/>, 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<Student> all) : IStudentRepository public class FakeStudents(List<Student> all) : IStudentRepository
@@ -26,7 +26,8 @@ public sealed class SettingsViewModelTests
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(), 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] [Fact]
@@ -102,7 +103,8 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), 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"; vm.SelectedStateName = "Bayern";
@@ -124,7 +126,8 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, 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].StartText = "08:00";
vm.PeriodTimes[0].EndText = "08:45"; vm.PeriodTimes[0].EndText = "08:45";
@@ -150,7 +153,8 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, 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].StartText = "08:45";
vm.PeriodTimes[0].EndText = "08:00"; vm.PeriodTimes[0].EndText = "08:00";
@@ -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);
}
}
+13 -18
View File
@@ -164,6 +164,10 @@ public static class AppBootstrapper
services.AddSingleton<AiPlanningService>(); services.AddSingleton<AiPlanningService>();
// ── Sync (optional nur wenn Server konfiguriert) ──────────────────── // ── 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(_ => new EventQueue(queuePath));
services.AddSingleton(sp => new ConflictResolver(sp.GetRequiredService<EventQueue>())); services.AddSingleton(sp => new ConflictResolver(sp.GetRequiredService<EventQueue>()));
services.AddSingleton<byte[]>(_ => services.AddSingleton<byte[]>(_ =>
@@ -173,18 +177,18 @@ public static class AppBootstrapper
return key; return key;
}); });
var serverUrl = LoadServerUrl(appData); var serverUrl = syncSettings.ServerUrl;
var deviceId = LoadOrCreateDeviceId(appData); var deviceId = LoadOrCreateDeviceId(appData);
if (!string.IsNullOrEmpty(serverUrl)) if (!string.IsNullOrEmpty(serverUrl))
{ {
services.AddSingleton(sp => new EventApplier( services.AddSingleton(sp => new EventApplier(
sp.GetRequiredService<LiteDbContext>(), sp.GetRequiredService<byte[]>(), sp.GetRequiredService<LiteDbContext>(), sp.GetRequiredService<byte[]>(),
BuildHttp(serverUrl, appData))); BuildHttp(serverUrl, syncSettings)));
services.AddSingleton(sp => new SyncEventPublisher( services.AddSingleton(sp => new SyncEventPublisher(
sp.GetRequiredService<EventQueue>(), deviceId, sp.GetRequiredService<byte[]>())); sp.GetRequiredService<EventQueue>(), deviceId, sp.GetRequiredService<byte[]>()));
services.AddSingleton(sp => new AttachmentSyncer( services.AddSingleton(sp => new AttachmentSyncer(
sp.GetRequiredService<LiteDbContext>(), BuildHttp(serverUrl, appData), sp.GetRequiredService<LiteDbContext>(), BuildHttp(serverUrl, syncSettings),
sp.GetRequiredService<byte[]>())); sp.GetRequiredService<byte[]>()));
services.AddSingleton<SyncEngine>(sp => new SyncEngine( services.AddSingleton<SyncEngine>(sp => new SyncEngine(
@@ -192,7 +196,7 @@ public static class AppBootstrapper
sp.GetRequiredService<ConflictResolver>(), sp.GetRequiredService<ConflictResolver>(),
sp.GetRequiredService<EventApplier>(), sp.GetRequiredService<EventApplier>(),
sp.GetRequiredService<AttachmentSyncer>(), sp.GetRequiredService<AttachmentSyncer>(),
BuildHttp(serverUrl, appData), BuildHttp(serverUrl, syncSettings),
new SyncConfig new SyncConfig
{ {
ServerUrl = serverUrl, ServerUrl = serverUrl,
@@ -202,7 +206,7 @@ public static class AppBootstrapper
})); }));
services.AddSingleton<SnapshotService>(sp => new SnapshotService( services.AddSingleton<SnapshotService>(sp => new SnapshotService(
BuildHttp(serverUrl, appData), BuildHttp(serverUrl, syncSettings),
sp.GetRequiredService<LiteDbContext>(), sp.GetRequiredService<LiteDbContext>(),
sp.GetRequiredService<byte[]>(), sp.GetRequiredService<byte[]>(),
DeviceType.Desktop, DbPath, keyPath)); DeviceType.Desktop, DbPath, keyPath));
@@ -247,25 +251,16 @@ public static class AppBootstrapper
// ── Hilfsmethoden ───────────────────────────────────────────────────────── // ── 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 http = new HttpClient { BaseAddress = new Uri(url) };
var tokenPath = Path.Combine(appData, "auth.token"); var token = syncSettings.GetToken();
if (File.Exists(tokenPath)) if (token is not null)
http.DefaultRequestHeaders.Authorization = http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue( new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
"Bearer", File.ReadAllText(tokenPath).Trim());
return http; 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) private static string LoadOrCreateDeviceId(string appData)
{ {
var p = Path.Combine(appData, "device.id"); var p = Path.Combine(appData, "device.id");
@@ -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 }
/// <summary>
/// Login/Verbindungstest gegen den eigenen Sync-Server (LehrerApp.Api) — getrennt vom bereits
/// bestehenden <see cref="AiPlanningService"/> (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).
/// </summary>
public class SyncAuthService(HttpClient http)
{
public async Task<string> 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<LoginResult>();
return result?.Token ?? throw new SyncAuthException("Unerwartete Antwort des Sync-Servers.");
}
public async Task<SyncConnectionTestResult> 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);
}
@@ -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; }
}
/// <summary>
/// Ersetzt die früheren Klartext-Helfer (<c>AppBootstrapper.LoadServerUrl</c>/<c>SaveServerUrl</c>,
/// unverschlüsselte <c>auth.token</c>-Datei). Token liegt wie beim KI-Backend-Token (siehe
/// <see cref="AiSettingsService"/>) AES-256-verschlüsselt über <see cref="SyncCrypto"/>, mit
/// eigenem, rein lokalem Schlüssel — nicht zu verwechseln mit dem in <c>AppBootstrapper</c>
/// separat verwalteten Sync-Schlüssel (<c>sync.key</c>), der die Ereignis-Payloads zwischen den
/// gepaarten Geräten verschlüsselt.
/// </summary>
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<string>(_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<SyncSettingsConfig>(File.ReadAllText(_configPath))
?? new SyncSettingsConfig();
}
catch { /* beschädigte Konfiguration -> Standardwert */ }
return new SyncSettingsConfig();
}
}
@@ -164,6 +164,15 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private bool _aiIsLoggedIn; [ObservableProperty] private bool _aiIsLoggedIn;
[ObservableProperty] private string _aiBalanceDisplay = ""; [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 ─────────────────────────────────────────────────────────── // ── Konstruktor ───────────────────────────────────────────────────────────
private readonly ISchoolHolidayRepository _schoolHolidays; private readonly ISchoolHolidayRepository _schoolHolidays;
@@ -172,6 +181,8 @@ public partial class SettingsViewModel : ObservableObject
private readonly ISupervisionDutyRepository _supervisionDuties; private readonly ISupervisionDutyRepository _supervisionDuties;
private readonly AiSettingsService _aiSettings; private readonly AiSettingsService _aiSettings;
private readonly AiPlanningService _aiPlanning; private readonly AiPlanningService _aiPlanning;
private readonly SyncSettingsService _syncSettings;
private readonly SyncAuthService _syncAuth;
private readonly CompetencyCatalogImportService _catalogImport; private readonly CompetencyCatalogImportService _catalogImport;
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo, public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
@@ -182,7 +193,8 @@ public partial class SettingsViewModel : ObservableObject
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays, IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule, SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates, ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
AiSettingsService aiSettings, AiPlanningService aiPlanning) AiSettingsService aiSettings, AiPlanningService aiPlanning,
SyncSettingsService syncSettings, SyncAuthService syncAuth)
{ {
_subjects = subjects; _subjects = subjects;
_domainRepo = domainRepo; _domainRepo = domainRepo;
@@ -204,6 +216,8 @@ public partial class SettingsViewModel : ObservableObject
_letterTemplates = letterTemplates; _letterTemplates = letterTemplates;
_aiSettings = aiSettings; _aiSettings = aiSettings;
_aiPlanning = aiPlanning; _aiPlanning = aiPlanning;
_syncSettings = syncSettings;
_syncAuth = syncAuth;
_catalogImport = new CompetencyCatalogImportService(domainRepo); _catalogImport = new CompetencyCatalogImportService(domainRepo);
LoadSubjects(); LoadSubjects();
LoadShorthandCodes(); LoadShorthandCodes();
@@ -221,6 +235,7 @@ public partial class SettingsViewModel : ObservableObject
LoadSupervisionDuties(); LoadSupervisionDuties();
LoadLetterTemplates(); LoadLetterTemplates();
LoadAiSettings(); LoadAiSettings();
LoadSyncSettings();
} }
// ── Word-Briefvorlagen: Import und Validierung ────────────────────────── // ── Word-Briefvorlagen: Import und Validierung ──────────────────────────
@@ -362,6 +377,65 @@ public partial class SettingsViewModel : ObservableObject
AiBalanceDisplay = ""; 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 ───────────────────────────────────── // ── Stundenraster: Laden / Speichern ─────────────────────────────────────
private void LoadPeriodTimes() private void LoadPeriodTimes()
@@ -759,6 +759,57 @@
</ScrollViewer> </ScrollViewer>
</ContentPage> </ContentPage>
<!-- Tab: Synchronisation (Kapitel 10) -->
<ContentPage Header="Synchronisation">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
<TextBlock Text="Geräte-Synchronisation" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Gleicht Daten zwischen mehreren eigenen Geräten über einen selbst betriebenen Server ab. Erfordert einen bereits eingerichteten Server und einen dort angelegten Nutzer."/>
<StackPanel Spacing="4">
<TextBlock Text="Server-Adresse" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding SyncServerUrl}" PlaceholderText="https://sync.example.com"/>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding !SyncIsLoggedIn}">
<StackPanel Spacing="4">
<TextBlock Text="Benutzername" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding SyncUsername}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Passwort" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding SyncPassword}" PasswordChar="●"/>
</StackPanel>
<TextBlock Text="{Binding SyncLoginError}" Foreground="Red" FontSize="12"
IsVisible="{Binding SyncLoginError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Anmelden" Command="{Binding SyncLoginCommand}"/>
<Button Content="Verbindung testen" Command="{Binding SyncTestConnectionCommand}"/>
</StackPanel>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding SyncIsLoggedIn}">
<TextBlock FontSize="13" FontWeight="SemiBold">
<Run Text="Angemeldet als: "/><Run Text="{Binding SyncUsername}"/>
</TextBlock>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Verbindung testen" Command="{Binding SyncTestConnectionCommand}"/>
<Button Content="Abmelden" Command="{Binding SyncLogoutCommand}"/>
</StackPanel>
</StackPanel>
<TextBlock Text="{Binding SyncConnectionStatus}" FontSize="12"
IsVisible="{Binding SyncConnectionStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock FontSize="11" Opacity="0.5" TextWrapping="Wrap"
Text="Speichern/Anmelden startet die App neu, damit die Änderung wirksam wird."/>
</StackPanel>
</ScrollViewer>
</ContentPage>
</TabbedPage> </TabbedPage>
</Grid> </Grid>
</UserControl> </UserControl>
+16 -2
View File
@@ -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. ist aber nur aktiv, wenn eine Server-URL konfiguriert ist.
### 10.1 Client ### 10.1 Client
- [ ] **10.1.1** Sync-Einrichtung in den Einstellungen: Server-URL, Login, Token speichern. - [x] **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).
**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. - [ ] **10.1.3** Konfliktanzeige in der UI — was `ConflictResolver` entscheidet, muss sichtbar sein.
- [x] **10.1.4** Manuelles Auslösen einer vollständigen Synchronisation. - [x] **10.1.4** Manuelles Auslösen einer vollständigen Synchronisation.