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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user