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
+13 -18
View File
@@ -164,6 +164,10 @@ public static class AppBootstrapper
services.AddSingleton<AiPlanningService>();
// ── 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<EventQueue>()));
services.AddSingleton<byte[]>(_ =>
@@ -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<LiteDbContext>(), sp.GetRequiredService<byte[]>(),
BuildHttp(serverUrl, appData)));
BuildHttp(serverUrl, syncSettings)));
services.AddSingleton(sp => new SyncEventPublisher(
sp.GetRequiredService<EventQueue>(), deviceId, sp.GetRequiredService<byte[]>()));
services.AddSingleton(sp => new AttachmentSyncer(
sp.GetRequiredService<LiteDbContext>(), BuildHttp(serverUrl, appData),
sp.GetRequiredService<LiteDbContext>(), BuildHttp(serverUrl, syncSettings),
sp.GetRequiredService<byte[]>()));
services.AddSingleton<SyncEngine>(sp => new SyncEngine(
@@ -192,7 +196,7 @@ public static class AppBootstrapper
sp.GetRequiredService<ConflictResolver>(),
sp.GetRequiredService<EventApplier>(),
sp.GetRequiredService<AttachmentSyncer>(),
BuildHttp(serverUrl, appData),
BuildHttp(serverUrl, syncSettings),
new SyncConfig
{
ServerUrl = serverUrl,
@@ -202,7 +206,7 @@ public static class AppBootstrapper
}));
services.AddSingleton<SnapshotService>(sp => new SnapshotService(
BuildHttp(serverUrl, appData),
BuildHttp(serverUrl, syncSettings),
sp.GetRequiredService<LiteDbContext>(),
sp.GetRequiredService<byte[]>(),
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");
@@ -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 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()
@@ -759,6 +759,57 @@
</ScrollViewer>
</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>
</Grid>
</UserControl>