diff --git a/LehrerApp.Core/Services/AppLockService.cs b/LehrerApp.Core/Services/AppLockService.cs
new file mode 100644
index 0000000..8a9a5ab
--- /dev/null
+++ b/LehrerApp.Core/Services/AppLockService.cs
@@ -0,0 +1,73 @@
+using System.Security.Cryptography;
+using System.Text.Json;
+
+namespace LehrerApp.Core.Services;
+
+internal class AppLockConfig
+{
+ public bool Enabled { get; set; }
+ public string? Salt { get; set; }
+ public string? PasswordHash { get; set; }
+ public int TimeoutMinutes { get; set; } = 10;
+}
+
+///
+/// App-Sperre nach Inaktivität: eigenes Passwort (unabhängig von einer eventuellen
+/// Datenbank-Verschlüsselung), als gesalzener Hash in <AppData>/LehrerApp/applock.json
+/// abgelegt. Das Klartext-Passwort wird nie gespeichert.
+///
+public class AppLockService
+{
+ private const int Pbkdf2Iterations = 100_000;
+ private readonly string _configPath;
+ private AppLockConfig _config;
+
+ public bool IsEnabled => _config.Enabled;
+ public int TimeoutMinutes => _config.TimeoutMinutes;
+ public bool HasPassword => !string.IsNullOrEmpty(_config.PasswordHash);
+
+ public AppLockService(string appDataPath)
+ {
+ _configPath = Path.Combine(appDataPath, "applock.json");
+ _config = Load();
+ }
+
+ public void Configure(bool enabled, int timeoutMinutes)
+ {
+ _config.Enabled = enabled;
+ _config.TimeoutMinutes = Math.Max(1, timeoutMinutes);
+ Save();
+ }
+
+ public void SetPassword(string password)
+ {
+ var salt = RandomNumberGenerator.GetBytes(16);
+ _config.Salt = Convert.ToBase64String(salt);
+ _config.PasswordHash = Hash(password, salt);
+ Save();
+ }
+
+ public bool VerifyPassword(string password)
+ {
+ if (string.IsNullOrEmpty(_config.PasswordHash) || string.IsNullOrEmpty(_config.Salt)) return false;
+ var salt = Convert.FromBase64String(_config.Salt);
+ return _config.PasswordHash == Hash(password, salt);
+ }
+
+ private static string Hash(string password, byte[] salt) =>
+ Convert.ToBase64String(Rfc2898DeriveBytes.Pbkdf2(password, salt, Pbkdf2Iterations, HashAlgorithmName.SHA256, 32));
+
+ private AppLockConfig Load()
+ {
+ try
+ {
+ if (File.Exists(_configPath))
+ return JsonSerializer.Deserialize(File.ReadAllText(_configPath)) ?? new AppLockConfig();
+ }
+ catch { /* beschädigte Konfiguration -> Standardwerte, Sperre bleibt deaktiviert */ }
+ return new AppLockConfig();
+ }
+
+ private void Save() =>
+ File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
+}
diff --git a/LehrerApp.Core/Services/BackupService.cs b/LehrerApp.Core/Services/BackupService.cs
new file mode 100644
index 0000000..8cd9e79
--- /dev/null
+++ b/LehrerApp.Core/Services/BackupService.cs
@@ -0,0 +1,63 @@
+namespace LehrerApp.Core.Services;
+
+public record BackupInfo(string Path, DateTime CreatedAt, long SizeBytes);
+
+///
+/// Rollierendes lokales Backup der LiteDB-Datei
+/// (<AppData>/LehrerApp/backups/lehrerapp-JJJJ-MM-TT_HH-mm-ss.db).
+/// Reine Dateikopie — unabhängig davon, ob die Datenbank verschlüsselt ist.
+///
+public class BackupService
+{
+ private const int DefaultKeepCount = 10;
+ private readonly string _backupDirectory;
+
+ public string BackupDirectory => _backupDirectory;
+
+ public BackupService(string appDataPath)
+ {
+ _backupDirectory = Path.Combine(appDataPath, "backups");
+ Directory.CreateDirectory(_backupDirectory);
+ }
+
+ /// Erstellt ein Backup der übergebenen Datenbankdatei, falls sie existiert.
+ /// Gibt den Pfad des neuen Backups zurück, oder null, wenn keine Datei vorhanden war.
+ public string? CreateBackup(string databasePath, int keepCount = DefaultKeepCount)
+ {
+ if (!File.Exists(databasePath)) return null;
+
+ // Millisekunden + Zufallssuffix, damit mehrere Backups innerhalb derselben Sekunde
+ // (z.B. mehrfaches Klicken auf "Jetzt sichern") sich nicht gegenseitig überschreiben.
+ var suffix = Guid.NewGuid().ToString("N")[..8];
+ var fileName = $"lehrerapp-{DateTime.Now:yyyy-MM-dd_HH-mm-ss-fff}-{suffix}.db";
+ var target = Path.Combine(_backupDirectory, fileName);
+ File.Copy(databasePath, target, overwrite: true);
+
+ PruneOldBackups(keepCount);
+ return target;
+ }
+
+ public List ListBackups() =>
+ Directory.GetFiles(_backupDirectory, "lehrerapp-*.db")
+ .Select(p => new BackupInfo(p, File.GetLastWriteTime(p), new FileInfo(p).Length))
+ .OrderByDescending(b => b.CreatedAt)
+ .ToList();
+
+ public void PruneOldBackups(int keepCount = DefaultKeepCount)
+ {
+ var backups = ListBackups();
+ foreach (var stale in backups.Skip(keepCount))
+ {
+ try { File.Delete(stale.Path); }
+ catch { /* nächster Lauf versucht es erneut */ }
+ }
+ }
+
+ /// Kopiert ein Backup an die Zielposition (z.B. über die aktive Datenbankdatei).
+ /// Der Aufrufer ist dafür verantwortlich, alle offenen Verbindungen vorher zu schließen.
+ public void RestoreBackup(string backupPath, string databasePath)
+ {
+ if (!File.Exists(backupPath)) throw new FileNotFoundException("Backup nicht gefunden.", backupPath);
+ File.Copy(backupPath, databasePath, overwrite: true);
+ }
+}
diff --git a/LehrerApp.Data.Tests/DatabaseEncryptionServiceTests.cs b/LehrerApp.Data.Tests/DatabaseEncryptionServiceTests.cs
new file mode 100644
index 0000000..124cb3c
--- /dev/null
+++ b/LehrerApp.Data.Tests/DatabaseEncryptionServiceTests.cs
@@ -0,0 +1,80 @@
+using LiteDB;
+using Xunit;
+
+namespace LehrerApp.Data.Tests;
+
+public sealed class DatabaseEncryptionServiceTests
+{
+ [Fact]
+ public void IsEncrypted_OhneVorhandeneDatei_GibtFalseZurueck()
+ {
+ using var temp = new TempDatabase();
+ var service = new DatabaseEncryptionService();
+
+ Assert.False(service.IsEncrypted(temp.Path));
+ }
+
+ [Fact]
+ public void IsEncrypted_UnverschluesselteDatenbank_GibtFalseZurueck()
+ {
+ using var temp = new TempDatabase();
+ using (var db = new LiteDatabase(temp.Path))
+ db.GetCollection("t").Insert(new BsonDocument { ["x"] = 1 });
+
+ var service = new DatabaseEncryptionService();
+
+ Assert.False(service.IsEncrypted(temp.Path));
+ }
+
+ [Fact]
+ public void SetPassword_VerschluesseltDieDatenbankUndBehaeltDieDaten()
+ {
+ using var temp = new TempDatabase();
+ using (var db = new LiteDatabase(temp.Path))
+ db.GetCollection("t").Insert(new BsonDocument { ["Name"] = "Hallo" });
+
+ var service = new DatabaseEncryptionService();
+ service.SetPassword(temp.Path, currentPassword: null, newPassword: "geheim123");
+
+ Assert.True(service.IsEncrypted(temp.Path));
+ Assert.True(service.VerifyPassword(temp.Path, "geheim123"));
+ Assert.False(service.VerifyPassword(temp.Path, "falsch"));
+
+ using var reopened = new LiteDatabase(new ConnectionString(temp.Path) { Password = "geheim123" });
+ var doc = reopened.GetCollection("t").FindAll().First();
+ Assert.Equal("Hallo", doc["Name"].AsString);
+ }
+
+ [Fact]
+ public void SetPassword_EntferntDasPasswortWiederWennNeuesPasswortNullIst()
+ {
+ using var temp = new TempDatabase();
+ var service = new DatabaseEncryptionService();
+ using (var db = new LiteDatabase(new ConnectionString(temp.Path) { Password = "geheim123" }))
+ db.GetCollection("t").Insert(new BsonDocument { ["x"] = 1 });
+
+ service.SetPassword(temp.Path, currentPassword: "geheim123", newPassword: null);
+
+ Assert.False(service.IsEncrypted(temp.Path));
+ using var reopened = new LiteDatabase(temp.Path);
+ Assert.Single(reopened.GetCollection("t").FindAll());
+ }
+
+ private sealed class TempDatabase : IDisposable
+ {
+ private readonly string _directory = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(), $"lehrerapp-dbenc-tests-{Guid.NewGuid():N}");
+ public string Path { get; }
+
+ public TempDatabase()
+ {
+ Directory.CreateDirectory(_directory);
+ Path = System.IO.Path.Combine(_directory, "test.db");
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
+ }
+ }
+}
diff --git a/LehrerApp.Data.Tests/LiteDbContextTests.cs b/LehrerApp.Data.Tests/LiteDbContextTests.cs
index 3948d06..6c3faf5 100644
--- a/LehrerApp.Data.Tests/LiteDbContextTests.cs
+++ b/LehrerApp.Data.Tests/LiteDbContextTests.cs
@@ -115,6 +115,26 @@ public sealed class LiteDbContextTests
new ExamResult { StudentId = studentId, ExamId = examId }));
}
+ [Fact]
+ public void NeueDatenbank_HatDieAktuelleSchemaVersionNachDemErstenOeffnen()
+ {
+ using var temp = new TempDatabase();
+ using var context = new LiteDbContext(temp.Path);
+
+ Assert.Equal(1, context.SchemaVersion);
+ }
+
+ [Fact]
+ public void SchemaVersion_WirdUeberEinenNeustartHinwegBeibehalten()
+ {
+ using var temp = new TempDatabase();
+ using (var first = new LiteDbContext(temp.Path)) { }
+
+ using var second = new LiteDbContext(temp.Path);
+
+ Assert.Equal(1, second.SchemaVersion);
+ }
+
private sealed class TempDatabase : IDisposable
{
private readonly string _directory = System.IO.Path.Combine(
diff --git a/LehrerApp.Data/DatabaseEncryptionService.cs b/LehrerApp.Data/DatabaseEncryptionService.cs
new file mode 100644
index 0000000..90329eb
--- /dev/null
+++ b/LehrerApp.Data/DatabaseEncryptionService.cs
@@ -0,0 +1,68 @@
+using LiteDB;
+
+namespace LehrerApp.Data;
+
+///
+/// Prüft und ändert den Passwortschutz einer LiteDB-Datei.
+/// Passwort-Änderungen laufen über eine Kopie (neue Datei mit Zielpasswort, alle
+/// Collections umkopiert, dann Austausch) statt über LiteDatabase.Rebuild mit
+/// Passwort, das in LiteDB 5.0.21 nachweislich fehlschlägt (per Skript verifiziert).
+///
+public class DatabaseEncryptionService
+{
+ public bool IsEncrypted(string dbPath)
+ {
+ if (!File.Exists(dbPath)) return false;
+ try
+ {
+ using var db = new LiteDatabase(dbPath);
+ return false;
+ }
+ catch (LiteException)
+ {
+ return true;
+ }
+ }
+
+ public bool VerifyPassword(string dbPath, string password)
+ {
+ try
+ {
+ using var db = new LiteDatabase(new ConnectionString(dbPath) { Password = password });
+ return true;
+ }
+ catch (LiteException)
+ {
+ return false;
+ }
+ }
+
+ /// Setzt (newPassword != null), ändert oder entfernt (newPassword == null) das
+ /// Passwort der Datenbank. Der Aufrufer muss sicherstellen, dass keine andere
+ /// Verbindung (z.B. der laufende ) die Datei offen hält.
+ public void SetPassword(string dbPath, string? currentPassword, string? newPassword)
+ {
+ var tempPath = dbPath + ".reencrypt.tmp";
+ if (File.Exists(tempPath)) File.Delete(tempPath);
+
+ var srcConnection = currentPassword is null
+ ? new ConnectionString(dbPath)
+ : new ConnectionString(dbPath) { Password = currentPassword };
+ var dstConnection = newPassword is null
+ ? new ConnectionString(tempPath)
+ : new ConnectionString(tempPath) { Password = newPassword };
+
+ using (var src = new LiteDatabase(srcConnection))
+ using (var dst = new LiteDatabase(dstConnection))
+ {
+ foreach (var name in src.GetCollectionNames())
+ {
+ var docs = src.GetCollection(name).FindAll().ToList();
+ if (docs.Count > 0) dst.GetCollection(name).InsertBulk(docs);
+ }
+ }
+
+ File.Copy(tempPath, dbPath, overwrite: true);
+ File.Delete(tempPath);
+ }
+}
diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs
index 324cfb9..46df789 100644
--- a/LehrerApp.Data/LiteDbContext.cs
+++ b/LehrerApp.Data/LiteDbContext.cs
@@ -8,15 +8,21 @@ namespace LehrerApp.Data;
///
public class LiteDbContext : IDisposable
{
+ /// Aktuelle Schema-Version. Migrationsschritte werden versioniert unter
+ /// ergänzt, statt bei jedem Start erneut
+ /// (idempotent, aber unnötig) über alle Daten zu laufen.
+ private const int CurrentSchemaVersion = 1;
+
private readonly LiteDatabase _db;
- public LiteDbContext(string databasePath)
+ public LiteDbContext(string databasePath, string? password = null)
{
_db = new LiteDatabase(new ConnectionString(databasePath)
{
Connection = ConnectionType.Shared,
+ Password = password,
});
- MigrateExistingData();
+ RunVersionedMigrations();
EnsureIndexes();
}
@@ -24,7 +30,7 @@ public class LiteDbContext : IDisposable
public LiteDbContext(Stream stream)
{
_db = new LiteDatabase(stream);
- MigrateExistingData();
+ RunVersionedMigrations();
EnsureIndexes();
}
@@ -51,6 +57,32 @@ public class LiteDbContext : IDisposable
public void Checkpoint() => _db.Checkpoint();
+ public int SchemaVersion => ReadSchemaVersion();
+
+ private void RunVersionedMigrations()
+ {
+ var version = ReadSchemaVersion();
+ if (version < 1)
+ {
+ MigrateExistingData();
+ version = 1;
+ }
+ WriteSchemaVersion(version);
+ }
+
+ private int ReadSchemaVersion()
+ {
+ var meta = _db.GetCollection("meta").FindById(1);
+ return meta?["SchemaVersion"].AsInt32 ?? 0;
+ }
+
+ private void WriteSchemaVersion(int version) =>
+ _db.GetCollection("meta").Upsert(new BsonDocument
+ {
+ ["_id"] = 1,
+ ["SchemaVersion"] = version,
+ });
+
private void MigrateExistingData()
{
MigrateMemberships();
diff --git a/LehrerApp.Desktop.Tests/AppLockViewModelTests.cs b/LehrerApp.Desktop.Tests/AppLockViewModelTests.cs
new file mode 100644
index 0000000..5a50166
--- /dev/null
+++ b/LehrerApp.Desktop.Tests/AppLockViewModelTests.cs
@@ -0,0 +1,46 @@
+using LehrerApp.Core.Services;
+using LehrerApp.Desktop.ViewModels;
+using Xunit;
+
+namespace LehrerApp.Desktop.Tests;
+
+public sealed class AppLockViewModelTests
+{
+ [Fact]
+ public void Unlock_KorrektesPasswort_HebtDieSperreAuf()
+ {
+ using var temp = new TempAppData();
+ var appLock = new AppLockService(temp.Path);
+ appLock.SetPassword("geheim123");
+ var vm = new AppLockViewModel(appLock) { IsLocked = true, PasswordAttempt = "geheim123" };
+
+ vm.UnlockCommand.Execute(null);
+
+ Assert.False(vm.IsLocked);
+ Assert.Equal("", vm.UnlockError);
+ }
+
+ [Fact]
+ public void Unlock_FalschesPasswort_BleibtGesperrtUndZeigtFehler()
+ {
+ using var temp = new TempAppData();
+ var appLock = new AppLockService(temp.Path);
+ appLock.SetPassword("geheim123");
+ var vm = new AppLockViewModel(appLock) { IsLocked = true, PasswordAttempt = "falsch" };
+
+ vm.UnlockCommand.Execute(null);
+
+ Assert.True(vm.IsLocked);
+ Assert.NotEqual("", vm.UnlockError);
+ Assert.Equal("", vm.PasswordAttempt);
+ }
+
+ private sealed class TempAppData : IDisposable
+ {
+ public string Path { get; } = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(), $"lehrerapp-applockvm-tests-{Guid.NewGuid():N}");
+
+ public TempAppData() => Directory.CreateDirectory(Path);
+ public void Dispose() { if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true); }
+ }
+}
diff --git a/LehrerApp.Desktop/App.axaml.cs b/LehrerApp.Desktop/App.axaml.cs
index 7bf84b7..7808a45 100644
--- a/LehrerApp.Desktop/App.axaml.cs
+++ b/LehrerApp.Desktop/App.axaml.cs
@@ -2,6 +2,7 @@ using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using LehrerApp.Core.Services;
+using LehrerApp.Data;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Groups;
@@ -18,19 +19,44 @@ public class App : Application
public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ // Verschlüsselte Datenbank (13.3.4): Passwort abfragen, bevor die Datenbank
+ // (und damit BuildServices, das sie öffnet) angefasst wird.
+ if (AppBootstrapper.IsDbEncrypted())
+ {
+ var promptVm = new DbPasswordPromptViewModel(
+ new DatabaseEncryptionService(), AppBootstrapper.ResolveDbPath());
+ var promptWindow = new DbPasswordPromptWindow { DataContext = promptVm };
+ promptVm.OnUnlocked = password =>
+ {
+ AppBootstrapper.DbPassword = password;
+ StartMainApp(desktop);
+ promptWindow.Close();
+ };
+ desktop.MainWindow = promptWindow;
+ }
+ else
+ {
+ StartMainApp(desktop);
+ }
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+
+ private static void StartMainApp(IClassicDesktopStyleApplicationLifetime desktop)
{
Services = AppBootstrapper.BuildServices();
Services.GetRequiredService().Info("Anwendung gestartet.");
GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService());
- if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
- {
- var mainVm = Services.GetRequiredService();
- WireCallbacks(mainVm);
- desktop.MainWindow = new MainWindow { DataContext = mainVm };
- }
-
- base.OnFrameworkInitializationCompleted();
+ var mainVm = Services.GetRequiredService();
+ WireCallbacks(mainVm);
+ var main = new MainWindow { DataContext = mainVm };
+ desktop.MainWindow = main;
+ main.Show();
}
private static void WireCallbacks(MainWindowViewModel main)
diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs
index 31b58c1..51c1f26 100644
--- a/LehrerApp.Desktop/AppBootstrapper.cs
+++ b/LehrerApp.Desktop/AppBootstrapper.cs
@@ -25,6 +25,12 @@ public static class AppBootstrapper
public static string DbPath { get; private set; } = "";
public static string AppDataPath { get; private set; } = "";
+ ///
+ /// Vor gesetzt, wenn die Datenbank passwortgeschützt ist
+ /// (siehe App.axaml.cs: Passwort-Abfrage vor dem Öffnen der Datenbank).
+ ///
+ public static string? DbPassword { get; set; }
+
///
/// Vor der eigentlichen DI-Konfiguration verfügbar (z.B. für den globalen
/// Exception-Handler in Program.cs, der schon vor greifen muss).
@@ -42,19 +48,41 @@ public static class AppBootstrapper
return appData;
}
+ /// Vor dem Öffnen der Datenbank verfügbar (z.B. für die Passwort-Abfrage beim Start).
+ public static string ResolveDbPath()
+ {
+ if (string.IsNullOrEmpty(DbPath))
+ DbPath = Path.Combine(ResolveAppDataPath(), "lehrerapp.db");
+ return DbPath;
+ }
+
public static AppLogger EnsureLogger()
{
Logger ??= new AppLogger(ResolveAppDataPath());
return Logger;
}
+ public static bool IsDbEncrypted() =>
+ new DatabaseEncryptionService().IsEncrypted(ResolveDbPath());
+
+ /// Beendet den Prozess und startet ihn neu (z.B. nach Wiederherstellung eines Backups
+ /// oder einer Änderung der Datenbank-Verschlüsselung — die laufende LiteDB-Verbindung
+ /// kann nicht sicher "heiß" auf eine andere Datei umgehängt werden).
+ public static void RestartApplication()
+ {
+ var exePath = Environment.ProcessPath;
+ if (!string.IsNullOrEmpty(exePath))
+ System.Diagnostics.Process.Start(exePath);
+ Environment.Exit(0);
+ }
+
public static IServiceProvider BuildServices()
{
var services = new ServiceCollection();
// ── Pfade ─────────────────────────────────────────────────────────────
var appData = ResolveAppDataPath();
- DbPath = Path.Combine(appData, "lehrerapp.db");
+ ResolveDbPath();
var queuePath = Path.Combine(appData, "syncqueue.db");
var keyPath = Path.Combine(appData, "sync.key");
@@ -63,8 +91,17 @@ public static class AppBootstrapper
services.AddSingleton(Logger);
services.AddSingleton();
+ // ── Datensicherheit (13.3) ───────────────────────────────────────────
+ // Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
+ // Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig.
+ var backup = new BackupService(appData);
+ backup.CreateBackup(DbPath);
+ services.AddSingleton(backup);
+ services.AddSingleton(_ => new AppLockService(appData));
+ services.AddSingleton();
+
// ── Datenbank ─────────────────────────────────────────────────────────
- services.AddSingleton(_ => new LiteDbContext(DbPath));
+ services.AddSingleton(_ => new LiteDbContext(DbPath, DbPassword));
// ── Repositories ──────────────────────────────────────────────────────
services.AddSingleton();
@@ -128,6 +165,7 @@ public static class AppBootstrapper
// ── ViewModels ────────────────────────────────────────────────────────
// Singleton: einmal erstellt, überall dieselbe Instanz
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton(sp =>
diff --git a/LehrerApp.Desktop/ViewModels/AppLockViewModel.cs b/LehrerApp.Desktop/ViewModels/AppLockViewModel.cs
new file mode 100644
index 0000000..80a08ea
--- /dev/null
+++ b/LehrerApp.Desktop/ViewModels/AppLockViewModel.cs
@@ -0,0 +1,71 @@
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using LehrerApp.Core.Services;
+
+namespace LehrerApp.Desktop.ViewModels;
+
+///
+/// Sperrbildschirm nach Inaktivität (13.3.5). Läuft unabhängig von einer eventuellen
+/// Datenbank-Verschlüsselung — eigenes Passwort über .
+///
+public partial class AppLockViewModel : ObservableObject
+{
+ private readonly AppLockService _appLock;
+ private System.Timers.Timer? _idleTimer;
+
+ [ObservableProperty] private bool _isLocked;
+ [ObservableProperty] private string _passwordAttempt = "";
+ [ObservableProperty] private string _unlockError = "";
+
+ public AppLockViewModel(AppLockService appLock) => _appLock = appLock;
+
+ /// Wird bei jeder Nutzereingabe (Maus/Tastatur) im Hauptfenster aufgerufen.
+ public void NotifyActivity()
+ {
+ if (!IsLocked) ResetIdleTimer();
+ }
+
+ /// Nach dem Start bzw. nach Änderungen in den Einstellungen aufrufen.
+ public void ApplyConfig()
+ {
+ _idleTimer?.Stop();
+ _idleTimer = null;
+ if (!_appLock.IsEnabled || !_appLock.HasPassword) return;
+
+ _idleTimer = new System.Timers.Timer(_appLock.TimeoutMinutes * 60_000) { AutoReset = false };
+ _idleTimer.Elapsed += (_, _) => Dispatcher.UIThread.Post(Lock);
+ _idleTimer.Start();
+ }
+
+ private void ResetIdleTimer()
+ {
+ if (_idleTimer is null) return;
+ _idleTimer.Stop();
+ _idleTimer.Start();
+ }
+
+ private void Lock()
+ {
+ if (!_appLock.IsEnabled || !_appLock.HasPassword) return;
+ PasswordAttempt = "";
+ UnlockError = "";
+ IsLocked = true;
+ }
+
+ [RelayCommand]
+ private void Unlock()
+ {
+ if (_appLock.VerifyPassword(PasswordAttempt))
+ {
+ IsLocked = false;
+ UnlockError = "";
+ ResetIdleTimer();
+ }
+ else
+ {
+ UnlockError = "Falsches Passwort.";
+ }
+ PasswordAttempt = "";
+ }
+}
diff --git a/LehrerApp.Desktop/ViewModels/DbPasswordPromptViewModel.cs b/LehrerApp.Desktop/ViewModels/DbPasswordPromptViewModel.cs
new file mode 100644
index 0000000..cfe9b4e
--- /dev/null
+++ b/LehrerApp.Desktop/ViewModels/DbPasswordPromptViewModel.cs
@@ -0,0 +1,39 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using LehrerApp.Data;
+
+namespace LehrerApp.Desktop.ViewModels;
+
+///
+/// Passwort-Abfrage beim Programmstart, wenn die Datenbank verschlüsselt ist (13.3.4).
+/// Läuft vor , deshalb ohne DI.
+///
+public partial class DbPasswordPromptViewModel : ObservableObject
+{
+ private readonly DatabaseEncryptionService _dbEncryption;
+ private readonly string _dbPath;
+
+ [ObservableProperty] private string _password = "";
+ [ObservableProperty] private string _error = "";
+
+ public Action? OnUnlocked { get; set; }
+
+ public DbPasswordPromptViewModel(DatabaseEncryptionService dbEncryption, string dbPath)
+ {
+ _dbEncryption = dbEncryption;
+ _dbPath = dbPath;
+ }
+
+ [RelayCommand]
+ private void Unlock()
+ {
+ if (string.IsNullOrEmpty(Password)) { Error = "Bitte Passwort eingeben."; return; }
+ if (!_dbEncryption.VerifyPassword(_dbPath, Password))
+ {
+ Error = "Falsches Passwort.";
+ Password = "";
+ return;
+ }
+ OnUnlocked?.Invoke(Password);
+ }
+}
diff --git a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs
index e33ceba..6d90991 100644
--- a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs
@@ -20,16 +20,19 @@ public partial class MainWindowViewModel : ObservableObject
public SyncStatusViewModel SyncStatus { get; }
public ObservableCollection Toasts { get; }
+ public AppLockViewModel AppLock { get; }
public MainWindowViewModel(IServiceProvider services,
DashboardViewModel dashboard, SchoolYearService sy,
- SyncStatusViewModel syncStatus, NotificationService notifications)
+ SyncStatusViewModel syncStatus, NotificationService notifications, AppLockViewModel appLock)
{
_services = services;
SyncStatus = syncStatus;
Toasts = notifications.Toasts;
+ AppLock = appLock;
CurrentSchoolYear = sy.CurrentSchoolYear();
CurrentPage = dashboard;
+ AppLock.ApplyConfig();
}
[RelayCommand]
diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs
index 601d8c7..a67b333 100644
--- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs
@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
+using LehrerApp.Data;
using System.Collections.ObjectModel;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -18,6 +19,10 @@ public partial class SettingsViewModel : ObservableObject
private readonly IGradingKeyTemplateRepository _gradingKeyTemplates;
private readonly IGradingSchemeRepository _gradingSchemes;
private readonly GradingService _grading;
+ private readonly BackupService _backups;
+ private readonly DatabaseEncryptionService _dbEncryption;
+ private readonly AppLockService _appLock;
+ private readonly LiteDbContext _dbContext;
// ── Fächer ────────────────────────────────────────────────────────────────
@@ -51,20 +56,183 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private GradingSchemeEditItem _classScheme = null!;
[ObservableProperty] private GradingSchemeEditItem _courseScheme = null!;
+ // ── Sicherheit: Datensicherung (13.3.1/13.3.2) ───────────────────────────
+
+ [ObservableProperty] private string _backupStatus = "";
+ public ObservableCollection Backups { get; } = [];
+
+ /// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog vor dem Wiederherstellen.
+ public Func>? OnConfirmRestore { get; set; }
+
+ // ── Sicherheit: Datenbank-Verschlüsselung (13.3.4) ───────────────────────
+
+ [ObservableProperty] private bool _isDbEncrypted;
+ [ObservableProperty] private string _currentDbPassword = "";
+ [ObservableProperty] private string _newDbPassword = "";
+ [ObservableProperty] private string _newDbPasswordConfirm = "";
+ [ObservableProperty] private string _dbPasswordError = "";
+
+ public string DbEncryptionStatusLabel => IsDbEncrypted ? "verschlüsselt" : "nicht verschlüsselt";
+
+ partial void OnIsDbEncryptedChanged(bool value) => OnPropertyChanged(nameof(DbEncryptionStatusLabel));
+
+ // ── Sicherheit: App-Sperre nach Inaktivität (13.3.5) ─────────────────────
+
+ [ObservableProperty] private bool _appLockEnabled;
+ [ObservableProperty] private int _appLockTimeoutMinutes = 10;
+ [ObservableProperty] private string _appLockNewPassword = "";
+ [ObservableProperty] private string _appLockNewPasswordConfirm = "";
+ [ObservableProperty] private string _appLockPasswordError = "";
+ [ObservableProperty] private string _appLockStatus = "";
+
+ public bool AppLockHasPassword => _appLock.HasPassword;
+
+ /// Vom Code-Behind gesetzt: aktualisiert den laufenden Inaktivitäts-Timer sofort,
+ /// ohne dass die App neu gestartet werden muss.
+ public Action? OnAppLockChanged { get; set; }
+
// ── Konstruktor ───────────────────────────────────────────────────────────
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes,
- GradingService grading)
+ GradingService grading, BackupService backups, DatabaseEncryptionService dbEncryption,
+ AppLockService appLock, LiteDbContext dbContext)
{
_subjects = subjects;
_domainRepo = domainRepo;
_gradingKeyTemplates = gradingKeyTemplates;
_gradingSchemes = gradingSchemes;
_grading = grading;
+ _backups = backups;
+ _dbEncryption = dbEncryption;
+ _appLock = appLock;
+ _dbContext = dbContext;
LoadSubjects();
LoadGradingKeyTemplates();
LoadGradingSchemes();
+ LoadBackups();
+ IsDbEncrypted = _dbEncryption.IsEncrypted(AppBootstrapper.DbPath);
+ AppLockEnabled = _appLock.IsEnabled;
+ AppLockTimeoutMinutes = _appLock.TimeoutMinutes;
+ }
+
+ // ── Datensicherung: Laden / Erstellen / Wiederherstellen ─────────────────
+
+ private void LoadBackups()
+ {
+ Backups.Clear();
+ foreach (var b in _backups.ListBackups()) Backups.Add(new BackupListItem(b));
+ }
+
+ [RelayCommand]
+ private void CreateBackupNow()
+ {
+ _backups.CreateBackup(AppBootstrapper.DbPath);
+ LoadBackups();
+ BackupStatus = "Backup erstellt.";
+ }
+
+ [RelayCommand]
+ private async Task RestoreBackup(BackupListItem? item)
+ {
+ if (item is null) return;
+ if (OnConfirmRestore is not null && !await OnConfirmRestore(item)) return;
+
+ _dbContext.Dispose();
+ _backups.RestoreBackup(item.Path, AppBootstrapper.DbPath);
+ AppBootstrapper.RestartApplication();
+ }
+
+ // ── Datenbank-Verschlüsselung: Setzen / Ändern / Entfernen ───────────────
+
+ [RelayCommand]
+ private void SaveDbPassword()
+ {
+ DbPasswordError = "";
+ var valid = true;
+ var wantsPassword = !string.IsNullOrWhiteSpace(NewDbPassword) || !string.IsNullOrWhiteSpace(NewDbPasswordConfirm);
+
+ if (IsDbEncrypted && !_dbEncryption.VerifyPassword(AppBootstrapper.DbPath, CurrentDbPassword))
+ {
+ DbPasswordError = "Aktuelles Passwort ist falsch.";
+ valid = false;
+ }
+
+ if (!wantsPassword)
+ {
+ DbPasswordError = "Bitte ein neues Passwort eingeben.";
+ valid = false;
+ }
+ else if (NewDbPassword != NewDbPasswordConfirm)
+ {
+ DbPasswordError = "Neue Passwörter stimmen nicht überein.";
+ valid = false;
+ }
+ else if (NewDbPassword.Length < 4)
+ {
+ DbPasswordError = "Mindestens 4 Zeichen.";
+ valid = false;
+ }
+
+ if (!valid) return;
+
+ _dbContext.Dispose();
+ _dbEncryption.SetPassword(AppBootstrapper.DbPath, IsDbEncrypted ? CurrentDbPassword : null, NewDbPassword.Trim());
+ AppBootstrapper.RestartApplication();
+ }
+
+ [RelayCommand]
+ private void RemoveDbPassword()
+ {
+ DbPasswordError = "";
+ if (!_dbEncryption.VerifyPassword(AppBootstrapper.DbPath, CurrentDbPassword))
+ {
+ DbPasswordError = "Aktuelles Passwort ist falsch.";
+ return;
+ }
+
+ _dbContext.Dispose();
+ _dbEncryption.SetPassword(AppBootstrapper.DbPath, CurrentDbPassword, null);
+ AppBootstrapper.RestartApplication();
+ }
+
+ // ── App-Sperre: Speichern ─────────────────────────────────────────────────
+
+ [RelayCommand]
+ private void SaveAppLockSettings()
+ {
+ AppLockPasswordError = "";
+ var valid = true;
+ var wantsNewPassword = !string.IsNullOrWhiteSpace(AppLockNewPassword) || !string.IsNullOrWhiteSpace(AppLockNewPasswordConfirm);
+
+ if (AppLockEnabled && !_appLock.HasPassword && !wantsNewPassword)
+ {
+ AppLockPasswordError = "Bitte ein Passwort für die App-Sperre festlegen.";
+ valid = false;
+ }
+ else if (wantsNewPassword)
+ {
+ if (AppLockNewPassword != AppLockNewPasswordConfirm)
+ {
+ AppLockPasswordError = "Passwörter stimmen nicht überein.";
+ valid = false;
+ }
+ else if (AppLockNewPassword.Length < 4)
+ {
+ AppLockPasswordError = "Mindestens 4 Zeichen.";
+ valid = false;
+ }
+ }
+
+ if (!valid) return;
+
+ if (wantsNewPassword) _appLock.SetPassword(AppLockNewPassword.Trim());
+ _appLock.Configure(AppLockEnabled, AppLockTimeoutMinutes);
+
+ AppLockNewPassword = ""; AppLockNewPasswordConfirm = "";
+ OnPropertyChanged(nameof(AppLockHasPassword));
+ AppLockStatus = "Gespeichert.";
+ OnAppLockChanged?.Invoke();
}
// ── Gewichtungsschema-Voreinstellungen: Laden ────────────────────────────
@@ -486,6 +654,12 @@ public class SubjectListItem(Subject s)
public string ShortName { get; } = s.ShortName;
}
+public class BackupListItem(BackupInfo info)
+{
+ public string Path { get; } = info.Path;
+ public string Display { get; } = $"{info.CreatedAt:dd.MM.yyyy HH:mm} · {info.SizeBytes / 1024.0:0} KB";
+}
+
// ── JSON DTOs ─────────────────────────────────────────────────────────────────
internal class CatalogDto
diff --git a/LehrerApp.Desktop/Views/DbPasswordPromptWindow.axaml b/LehrerApp.Desktop/Views/DbPasswordPromptWindow.axaml
new file mode 100644
index 0000000..76394c2
--- /dev/null
+++ b/LehrerApp.Desktop/Views/DbPasswordPromptWindow.axaml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/DbPasswordPromptWindow.axaml.cs b/LehrerApp.Desktop/Views/DbPasswordPromptWindow.axaml.cs
new file mode 100644
index 0000000..6fdb9c2
--- /dev/null
+++ b/LehrerApp.Desktop/Views/DbPasswordPromptWindow.axaml.cs
@@ -0,0 +1,16 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using LehrerApp.Desktop.ViewModels;
+
+namespace LehrerApp.Desktop.Views;
+
+public partial class DbPasswordPromptWindow : Window
+{
+ public DbPasswordPromptWindow() => InitializeComponent();
+
+ private void OnKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Enter && DataContext is DbPasswordPromptViewModel vm)
+ vm.UnlockCommand.Execute(null);
+ }
+}
diff --git a/LehrerApp.Desktop/Views/MainWindow.axaml b/LehrerApp.Desktop/Views/MainWindow.axaml
index 7867b94..dcd50eb 100644
--- a/LehrerApp.Desktop/Views/MainWindow.axaml
+++ b/LehrerApp.Desktop/Views/MainWindow.axaml
@@ -218,5 +218,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/MainWindow.axaml.cs b/LehrerApp.Desktop/Views/MainWindow.axaml.cs
index 45a5239..6ec1e6b 100644
--- a/LehrerApp.Desktop/Views/MainWindow.axaml.cs
+++ b/LehrerApp.Desktop/Views/MainWindow.axaml.cs
@@ -1,8 +1,27 @@
using Avalonia.Controls;
+using Avalonia.Input;
+using LehrerApp.Desktop.ViewModels;
namespace LehrerApp.Desktop.Views;
public partial class MainWindow : Window
{
- public MainWindow() => InitializeComponent();
+ public MainWindow()
+ {
+ InitializeComponent();
+ PointerMoved += (_, _) => NotifyActivity();
+ PointerPressed += (_, _) => NotifyActivity();
+ KeyDown += (_, _) => NotifyActivity();
+ }
+
+ private void NotifyActivity()
+ {
+ if (DataContext is MainWindowViewModel vm) vm.AppLock.NotifyActivity();
+ }
+
+ private void OnLockScreenKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Enter && DataContext is MainWindowViewModel vm)
+ vm.AppLock.UnlockCommand.Execute(null);
+ }
}
diff --git a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml
index bd7ad5b..1063fb0 100644
--- a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml
+++ b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml
@@ -266,6 +266,118 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml.cs b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml.cs
index f066024..f21d64c 100644
--- a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml.cs
+++ b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml.cs
@@ -1,7 +1,10 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
+using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Settings;
+using LehrerApp.Desktop.Views.Shared;
+using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Settings;
@@ -9,6 +12,30 @@ public partial class SettingsView : UserControl
{
public SettingsView() => InitializeComponent();
+ protected override void OnDataContextChanged(EventArgs e)
+ {
+ base.OnDataContextChanged(e);
+ if (DataContext is SettingsViewModel vm)
+ {
+ vm.OnConfirmRestore = ShowRestoreConfirmDialog;
+ vm.OnAppLockChanged = () => App.Services.GetRequiredService().ApplyConfig();
+ }
+ }
+
+ private async Task ShowRestoreConfirmDialog(BackupListItem item)
+ {
+ var info = new ConfirmDialogInfo
+ {
+ Title = "Backup wiederherstellen?",
+ Message = $"Die aktuelle Datenbank wird durch das Backup vom {item.Display} ersetzt. " +
+ "Die App wird danach automatisch neu gestartet.",
+ ConfirmText = "Wiederherstellen",
+ };
+ var dialog = new ConfirmDialog { DataContext = info };
+ var owner = TopLevel.GetTopLevel(this) as Window;
+ return owner is not null && await dialog.ShowDialog(owner);
+ }
+
private async void OnImportClick(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
diff --git a/LehrerApp.Desktop/Views/Shared/ConfirmDialog.axaml b/LehrerApp.Desktop/Views/Shared/ConfirmDialog.axaml
new file mode 100644
index 0000000..cbc5056
--- /dev/null
+++ b/LehrerApp.Desktop/Views/Shared/ConfirmDialog.axaml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Shared/ConfirmDialog.axaml.cs b/LehrerApp.Desktop/Views/Shared/ConfirmDialog.axaml.cs
new file mode 100644
index 0000000..9d9470f
--- /dev/null
+++ b/LehrerApp.Desktop/Views/Shared/ConfirmDialog.axaml.cs
@@ -0,0 +1,12 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+
+namespace LehrerApp.Desktop.Views.Shared;
+
+public partial class ConfirmDialog : Window
+{
+ public ConfirmDialog() => InitializeComponent();
+
+ private void OnConfirm(object? sender, RoutedEventArgs e) => Close(true);
+ private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
+}
diff --git a/LehrerApp.Desktop/Views/Shared/ConfirmDialogInfo.cs b/LehrerApp.Desktop/Views/Shared/ConfirmDialogInfo.cs
new file mode 100644
index 0000000..91b6b6f
--- /dev/null
+++ b/LehrerApp.Desktop/Views/Shared/ConfirmDialogInfo.cs
@@ -0,0 +1,9 @@
+namespace LehrerApp.Desktop.Views.Shared;
+
+/// Generisches DataContext für .
+public class ConfirmDialogInfo
+{
+ public string Title { get; init; } = "";
+ public string Message { get; init; } = "";
+ public string ConfirmText { get; init; } = "Bestätigen";
+}
diff --git a/LehrerApp.Tests/AppLockServiceTests.cs b/LehrerApp.Tests/AppLockServiceTests.cs
new file mode 100644
index 0000000..709198f
--- /dev/null
+++ b/LehrerApp.Tests/AppLockServiceTests.cs
@@ -0,0 +1,74 @@
+using LehrerApp.Core.Services;
+using Xunit;
+
+namespace LehrerApp.Tests;
+
+public sealed class AppLockServiceTests
+{
+ [Fact]
+ public void NeueKonfiguration_IstStandardmaessigDeaktiviert()
+ {
+ using var temp = new TempAppData();
+ var service = new AppLockService(temp.Path);
+
+ Assert.False(service.IsEnabled);
+ Assert.False(service.HasPassword);
+ }
+
+ [Fact]
+ public void SetPassword_VerifyPassword_KorrektesPasswortWirdAkzeptiert()
+ {
+ using var temp = new TempAppData();
+ var service = new AppLockService(temp.Path);
+
+ service.SetPassword("geheim123");
+
+ Assert.True(service.HasPassword);
+ Assert.True(service.VerifyPassword("geheim123"));
+ Assert.False(service.VerifyPassword("falsch"));
+ }
+
+ [Fact]
+ public void Configure_WirdUeberNeueInstanzHinwegPersistiert()
+ {
+ using var temp = new TempAppData();
+ var first = new AppLockService(temp.Path);
+ first.SetPassword("geheim123");
+ first.Configure(enabled: true, timeoutMinutes: 15);
+
+ var second = new AppLockService(temp.Path);
+
+ Assert.True(second.IsEnabled);
+ Assert.Equal(15, second.TimeoutMinutes);
+ Assert.True(second.VerifyPassword("geheim123"));
+ }
+
+ [Fact]
+ public void VerifyPassword_OhneGesetztesPasswort_GibtFalseZurueck()
+ {
+ using var temp = new TempAppData();
+ var service = new AppLockService(temp.Path);
+
+ Assert.False(service.VerifyPassword("irgendwas"));
+ }
+
+ [Fact]
+ public void Configure_TimeoutUnterEinerMinute_WirdAufEineMinuteBegrenzt()
+ {
+ using var temp = new TempAppData();
+ var service = new AppLockService(temp.Path);
+
+ service.Configure(enabled: true, timeoutMinutes: 0);
+
+ Assert.Equal(1, service.TimeoutMinutes);
+ }
+
+ private sealed class TempAppData : IDisposable
+ {
+ public string Path { get; } = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(), $"lehrerapp-applock-tests-{Guid.NewGuid():N}");
+
+ public TempAppData() => Directory.CreateDirectory(Path);
+ public void Dispose() { if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true); }
+ }
+}
diff --git a/LehrerApp.Tests/BackupServiceTests.cs b/LehrerApp.Tests/BackupServiceTests.cs
new file mode 100644
index 0000000..491c4f1
--- /dev/null
+++ b/LehrerApp.Tests/BackupServiceTests.cs
@@ -0,0 +1,103 @@
+using LehrerApp.Core.Services;
+using Xunit;
+
+namespace LehrerApp.Tests;
+
+public sealed class BackupServiceTests
+{
+ [Fact]
+ public void CreateBackup_OhneVorhandeneDatenbank_GibtNullZurueck()
+ {
+ using var temp = new TempAppData();
+ var service = new BackupService(temp.Path);
+
+ var result = service.CreateBackup(Path.Combine(temp.Path, "fehlt.db"));
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void CreateBackup_KopiertDieDatenbankdatei()
+ {
+ using var temp = new TempAppData();
+ var dbPath = Path.Combine(temp.Path, "lehrerapp.db");
+ File.WriteAllText(dbPath, "Testinhalt");
+ var service = new BackupService(temp.Path);
+
+ var backupPath = service.CreateBackup(dbPath);
+
+ Assert.NotNull(backupPath);
+ Assert.True(File.Exists(backupPath));
+ Assert.Equal("Testinhalt", File.ReadAllText(backupPath!));
+ }
+
+ [Fact]
+ public void ListBackups_SortiertNeuesteZuerst()
+ {
+ using var temp = new TempAppData();
+ var dbPath = Path.Combine(temp.Path, "lehrerapp.db");
+ File.WriteAllText(dbPath, "v1");
+ var service = new BackupService(temp.Path);
+
+ var first = service.CreateBackup(dbPath)!;
+ File.SetLastWriteTime(first, DateTime.Now.AddMinutes(-5));
+ File.WriteAllText(dbPath, "v2");
+ var second = service.CreateBackup(dbPath)!;
+
+ var backups = service.ListBackups();
+
+ Assert.Equal(2, backups.Count);
+ Assert.Equal(second, backups[0].Path);
+ }
+
+ [Fact]
+ public void CreateBackup_BehaeltNurDieLetztenNBackups()
+ {
+ using var temp = new TempAppData();
+ var dbPath = Path.Combine(temp.Path, "lehrerapp.db");
+ var service = new BackupService(temp.Path);
+
+ for (var i = 0; i < 5; i++)
+ {
+ File.WriteAllText(dbPath, $"v{i}");
+ var backup = service.CreateBackup(dbPath, keepCount: 3)!;
+ File.SetLastWriteTime(backup, DateTime.Now.AddMinutes(-5 + i));
+ }
+
+ Assert.Equal(3, service.ListBackups().Count);
+ }
+
+ [Fact]
+ public void RestoreBackup_KopiertBackupUeberDieZieldatei()
+ {
+ using var temp = new TempAppData();
+ var dbPath = Path.Combine(temp.Path, "lehrerapp.db");
+ File.WriteAllText(dbPath, "alt");
+ var service = new BackupService(temp.Path);
+ var backupPath = service.CreateBackup(dbPath)!;
+ File.WriteAllText(dbPath, "neu-kaputt");
+
+ service.RestoreBackup(backupPath, dbPath);
+
+ Assert.Equal("alt", File.ReadAllText(dbPath));
+ }
+
+ [Fact]
+ public void RestoreBackup_OhneVorhandenesBackup_WirftException()
+ {
+ using var temp = new TempAppData();
+ var service = new BackupService(temp.Path);
+
+ Assert.Throws(() =>
+ service.RestoreBackup(Path.Combine(temp.Path, "backups", "fehlt.db"), Path.Combine(temp.Path, "lehrerapp.db")));
+ }
+
+ private sealed class TempAppData : IDisposable
+ {
+ public string Path { get; } = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(), $"lehrerapp-backup-tests-{Guid.NewGuid():N}");
+
+ public TempAppData() => Directory.CreateDirectory(Path);
+ public void Dispose() { if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true); }
+ }
+}
diff --git a/TODO.md b/TODO.md
index 80192d7..68c8501 100644
--- a/TODO.md
+++ b/TODO.md
@@ -530,11 +530,54 @@ Fächer- und Kompetenzverwaltung existiert bereits in
"Schüler ausgewählt?", kein Textfeld zum Andocken vorhanden).
### 13.3 Datensicherheit
-- [ ] **13.3.1** Automatisches lokales Backup der LiteDB beim Start (rollierend, letzte 10).
-- [ ] **13.3.2** Wiederherstellung aus einem Backup über die Einstellungen.
-- [ ] **13.3.3** Schema-Migration: Versionsnummer in der DB, Migrationsschritte beim Start.
-- [ ] **13.3.4** Optionale Verschlüsselung der lokalen Datenbank (LiteDB-Passwort).
-- [ ] **13.3.5** App-Sperre nach Inaktivität (Schülerdaten auf dem Lehrerrechner).
+- [x] **13.3.1** Automatisches lokales Backup der LiteDB beim Start (rollierend, letzte 10) —
+ [BackupService.cs](LehrerApp.Core/Services/BackupService.cs). Reine Dateikopie nach
+ `/LehrerApp/backups/`, läuft in `AppBootstrapper.BuildServices()` **vor** dem
+ Öffnen der Datenbank (unabhängig von Verschlüsselung, kein LiteDB-Handle nötig). Behält
+ standardmäßig die letzten 10 Backups, ältere werden entfernt. 6 Tests in
+ [BackupServiceTests.cs](LehrerApp.Tests/BackupServiceTests.cs).
+- [x] **13.3.2** Wiederherstellung aus einem Backup über die Einstellungen — neuer Tab
+ "Sicherheit" in [SettingsView.axaml](LehrerApp.Desktop/Views/Settings/SettingsView.axaml)
+ listet vorhandene Backups mit Zeitstempel/Größe, "Wiederherstellen" fragt über einen neuen
+ generischen [ConfirmDialog](LehrerApp.Desktop/Views/Shared/ConfirmDialog.axaml) nach,
+ kopiert das Backup über die aktive Datenbankdatei und startet die App danach automatisch
+ neu (`AppBootstrapper.RestartApplication()`) — ein laufender LiteDB-Verbindung kann nicht
+ sicher "heiß" auf eine andere Datei umgehängt werden.
+- [x] **13.3.3** Schema-Migration mit Versionsnummer — `LiteDbContext` schreibt die
+ Schema-Version in eine `meta`-Collection und führt Migrationsschritte nur noch aus, wenn
+ die gespeicherte Version dahinter liegt (`RunVersionedMigrations`), statt wie bisher bei
+ jedem Start erneut über alle Daten zu laufen. Die bestehenden (bereits idempotenten)
+ Migrationsschritte wurden als Version-1-Schritt gebündelt, für zukünftige
+ Schema-Änderungen ergänzt man einen weiteren `if (version < N)`-Block. 2 Tests in
+ [LiteDbContextTests.cs](LehrerApp.Data.Tests/LiteDbContextTests.cs).
+- [x] **13.3.4** Optionale Verschlüsselung der lokalen Datenbank —
+ [DatabaseEncryptionService.cs](LehrerApp.Data/DatabaseEncryptionService.cs). Passwort
+ setzen/ändern/entfernen läuft über eine Kopie (neue Datei mit Zielpasswort anlegen, alle
+ Collections umkopieren, Datei austauschen) statt über `LiteDatabase.Rebuild(...)` mit
+ Passwort — das ist in LiteDB 5.0.21 nachweislich fehlerhaft (per Skript verifiziert: wirft
+ "this data file is encrypted" beim Rebuild einer unverschlüsselten Datei). Ist die
+ Datenbank verschlüsselt, fragt ein eigenes Fenster
+ ([DbPasswordPromptWindow](LehrerApp.Desktop/Views/DbPasswordPromptWindow.axaml)) das
+ Passwort ab, **bevor** `AppBootstrapper.BuildServices()` (und damit das Öffnen der
+ Datenbank) läuft — siehe `App.axaml.cs`. Ändern/Entfernen des Passworts läuft über
+ Einstellungen → Sicherheit, jeweils mit Neustart der App danach. 4 Tests in
+ [DatabaseEncryptionServiceTests.cs](LehrerApp.Data.Tests/DatabaseEncryptionServiceTests.cs).
+- [x] **13.3.5** App-Sperre nach Inaktivität —
+ [AppLockService.cs](LehrerApp.Core/Services/AppLockService.cs) (eigenes, gesalzenes
+ PBKDF2-Passwort, unabhängig von einer eventuellen Datenbank-Verschlüsselung) +
+ [AppLockViewModel.cs](LehrerApp.Desktop/ViewModels/AppLockViewModel.cs) (Inaktivitäts-Timer,
+ Sperrbildschirm-Logik). Maus-/Tastatureingaben im Hauptfenster setzen den Timer zurück
+ ([MainWindow.axaml.cs](LehrerApp.Desktop/Views/MainWindow.axaml.cs)); läuft er ab, blendet
+ ein Overlay in [MainWindow.axaml](LehrerApp.Desktop/Views/MainWindow.axaml) die gesamte
+ Oberfläche aus, bis das Passwort stimmt. Einstellbar (aktiv/inaktiv, Zeit, Passwort) über
+ Einstellungen → Sicherheit, wirkt sofort ohne Neustart. 2 Tests in
+ [AppLockViewModelTests.cs](LehrerApp.Desktop.Tests/AppLockViewModelTests.cs) (reine
+ Unlock-Logik; der zeitbasierte Inaktivitäts-Timer selbst ist nicht automatisiert getestet).
+ **Hinweis:** `RestartApplication()` startet den Prozess über `Environment.ProcessPath`
+ neu — im veröffentlichten Programm korrekt, im Entwicklungsbetrieb über `dotnet run`
+ zeigt `ProcessPath` auf den `dotnet`-Host statt auf die App, ein Neustart über die
+ Einstellungen ist dort also nur in einer veröffentlichten Build (`dotnet publish`)
+ sinnvoll zu testen.
### 13.4 Codepflege
- [ ] **13.4.1** `CLAUDE.md` mit Projektkonventionen anlegen (`/init`).