Datensicherheit: Backup, Verschlüsselung, App-Sperre (Kapitel 13.3)

Automatisches rollierendes Backup der Datenbank beim Start mit
Wiederherstellung über die Einstellungen, versionierte Schema-Migration,
optionale Passwort-Verschlüsselung der LiteDB-Datei und eine App-Sperre
nach Inaktivität mit eigenem Passwort.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 13:07:36 +02:00
co-authored by Claude Sonnet 5
parent db1afff7e4
commit 48d07a2b99
25 changed files with 1228 additions and 21 deletions
+34 -8
View File
@@ -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<AppLogger>().Info("Anwendung gestartet.");
GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService<NotificationService>());
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
WireCallbacks(mainVm);
desktop.MainWindow = new MainWindow { DataContext = mainVm };
}
base.OnFrameworkInitializationCompleted();
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
WireCallbacks(mainVm);
var main = new MainWindow { DataContext = mainVm };
desktop.MainWindow = main;
main.Show();
}
private static void WireCallbacks(MainWindowViewModel main)
+40 -2
View File
@@ -25,6 +25,12 @@ public static class AppBootstrapper
public static string DbPath { get; private set; } = "";
public static string AppDataPath { get; private set; } = "";
/// <summary>
/// Vor <see cref="BuildServices"/> gesetzt, wenn die Datenbank passwortgeschützt ist
/// (siehe App.axaml.cs: Passwort-Abfrage vor dem Öffnen der Datenbank).
/// </summary>
public static string? DbPassword { get; set; }
/// <summary>
/// Vor der eigentlichen DI-Konfiguration verfügbar (z.B. für den globalen
/// Exception-Handler in Program.cs, der schon vor <see cref="BuildServices"/> 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<NotificationService>();
// ── 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<DatabaseEncryptionService>();
// ── Datenbank ─────────────────────────────────────────────────────────
services.AddSingleton(_ => new LiteDbContext(DbPath));
services.AddSingleton(_ => new LiteDbContext(DbPath, DbPassword));
// ── Repositories ──────────────────────────────────────────────────────
services.AddSingleton<IStudentRepository, StudentRepository>();
@@ -128,6 +165,7 @@ public static class AppBootstrapper
// ── ViewModels ────────────────────────────────────────────────────────
// Singleton: einmal erstellt, überall dieselbe Instanz
services.AddSingleton<AppLockViewModel>();
services.AddSingleton<MainWindowViewModel>();
services.AddSingleton<DashboardViewModel>();
services.AddSingleton(sp =>
@@ -0,0 +1,71 @@
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.ViewModels;
/// <summary>
/// Sperrbildschirm nach Inaktivität (13.3.5). Läuft unabhängig von einer eventuellen
/// Datenbank-Verschlüsselung — eigenes Passwort über <see cref="AppLockService"/>.
/// </summary>
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 = "";
}
}
@@ -0,0 +1,39 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Data;
namespace LehrerApp.Desktop.ViewModels;
/// <summary>
/// Passwort-Abfrage beim Programmstart, wenn die Datenbank verschlüsselt ist (13.3.4).
/// Läuft vor <see cref="AppBootstrapper.BuildServices"/>, deshalb ohne DI.
/// </summary>
public partial class DbPasswordPromptViewModel : ObservableObject
{
private readonly DatabaseEncryptionService _dbEncryption;
private readonly string _dbPath;
[ObservableProperty] private string _password = "";
[ObservableProperty] private string _error = "";
public Action<string>? 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);
}
}
@@ -20,16 +20,19 @@ public partial class MainWindowViewModel : ObservableObject
public SyncStatusViewModel SyncStatus { get; }
public ObservableCollection<ToastItem> 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]
@@ -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<BackupListItem> Backups { get; } = [];
/// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog vor dem Wiederherstellen.
public Func<BackupListItem, Task<bool>>? 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
@@ -0,0 +1,20 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels"
x:Class="LehrerApp.Desktop.Views.DbPasswordPromptWindow"
x:DataType="vm:DbPasswordPromptViewModel"
Title="LehrerApp Datenbank entsperren"
Width="360" SizeToContent="Height"
CanResize="False" WindowStartupLocation="CenterScreen">
<StackPanel Margin="24" Spacing="12">
<TextBlock Text="🔒" FontSize="28" HorizontalAlignment="Center"/>
<TextBlock Text="Datenbank ist passwortgeschützt" FontSize="15" FontWeight="SemiBold"
HorizontalAlignment="Center" TextWrapping="Wrap"/>
<TextBox PlaceholderText="Passwort" PasswordChar="●"
Text="{Binding Password}" KeyDown="OnKeyDown" x:Name="PasswordBox"/>
<TextBlock Text="{Binding Error}" Foreground="Red" FontSize="12" HorizontalAlignment="Center"
IsVisible="{Binding Error, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content="Entsperren" Command="{Binding UnlockCommand}"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
</StackPanel>
</Window>
@@ -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);
}
}
+21
View File
@@ -218,5 +218,26 @@
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- App-Sperre nach Inaktivität (13.3.5): blockiert die gesamte Oberfläche. -->
<Border Background="#E6101014" IsVisible="{Binding AppLock.IsLocked}">
<Border Width="320" Padding="24" CornerRadius="10"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center">
<StackPanel Spacing="12">
<TextBlock Text="🔒" FontSize="28" HorizontalAlignment="Center"/>
<TextBlock Text="LehrerApp gesperrt" FontSize="16" FontWeight="SemiBold"
HorizontalAlignment="Center"/>
<TextBox PlaceholderText="Passwort" PasswordChar="●"
Text="{Binding AppLock.PasswordAttempt}"
KeyDown="OnLockScreenKeyDown"/>
<TextBlock Text="{Binding AppLock.UnlockError}" Foreground="Red" FontSize="12"
HorizontalAlignment="Center"
IsVisible="{Binding AppLock.UnlockError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content="Entsperren" Command="{Binding AppLock.UnlockCommand}"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
</StackPanel>
</Border>
</Border>
</Panel>
</Window>
+20 -1
View File
@@ -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);
}
}
@@ -266,6 +266,118 @@
</ScrollViewer>
</ContentPage>
<!-- Tab: Sicherheit (13.3) -->
<ContentPage Header="Sicherheit">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="24" MaxWidth="560">
<!-- Datensicherung -->
<StackPanel Spacing="10">
<TextBlock Text="Datensicherung" FontSize="16" FontWeight="SemiBold"/>
<TextBlock Text="Bei jedem Start wird automatisch ein Backup der Datenbank angelegt (die letzten 10 werden behalten)."
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
<Button Content="Jetzt sichern" Command="{Binding CreateBackupNowCommand}"
HorizontalAlignment="Left"/>
<TextBlock Text="{Binding BackupStatus}" Foreground="Green" FontSize="12"
IsVisible="{Binding BackupStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<ItemsControl ItemsSource="{Binding Backups}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:BackupListItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,7">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Display}"
VerticalAlignment="Center" FontSize="13"/>
<Button Grid.Column="1" Content="Wiederherstellen" FontSize="12" Padding="10,4"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).RestoreBackupCommand}"
CommandParameter="{Binding}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Noch keine Backups vorhanden." Opacity="0.35" FontSize="12"
IsVisible="{Binding !Backups.Count}"/>
</StackPanel>
<Separator/>
<!-- Datenbank-Verschlüsselung -->
<StackPanel Spacing="10">
<TextBlock Text="Datenbank-Verschlüsselung" FontSize="16" FontWeight="SemiBold"/>
<TextBlock Text="Schützt die Datenbankdatei auf der Festplatte mit einem Passwort. Ohne das Passwort kann die App die Datenbank beim Start nicht öffnen — bei Verlust sind die Daten nicht mehr zugänglich."
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
<TextBlock FontSize="13" FontWeight="SemiBold">
<Run Text="Status: "/>
<Run Text="{Binding DbEncryptionStatusLabel}"/>
</TextBlock>
<StackPanel Spacing="4" IsVisible="{Binding IsDbEncrypted}">
<TextBlock Text="Aktuelles Passwort" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding CurrentDbPassword}" PasswordChar="●"/>
</StackPanel>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Neues Passwort" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding NewDbPassword}" PasswordChar="●"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Wiederholen" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding NewDbPasswordConfirm}" PasswordChar="●"/>
</StackPanel>
</Grid>
<TextBlock Text="{Binding DbPasswordError}" Foreground="Red" FontSize="12"
IsVisible="{Binding DbPasswordError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Passwort speichern" Command="{Binding SaveDbPasswordCommand}"/>
<Button Content="Verschlüsselung entfernen" Command="{Binding RemoveDbPasswordCommand}"
IsVisible="{Binding IsDbEncrypted}"/>
</StackPanel>
<TextBlock Text="Nach dem Ändern wird die App automatisch neu gestartet."
FontSize="11" Opacity="0.5"/>
</StackPanel>
<Separator/>
<!-- App-Sperre nach Inaktivität -->
<StackPanel Spacing="10">
<TextBlock Text="App-Sperre nach Inaktivität" FontSize="16" FontWeight="SemiBold"/>
<TextBlock Text="Sperrt die Oberfläche nach einer Zeit ohne Maus-/Tastatureingabe. Eigenes Passwort, unabhängig von der Datenbank-Verschlüsselung."
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
<CheckBox Content="App-Sperre aktivieren" IsChecked="{Binding AppLockEnabled}"/>
<StackPanel Spacing="4" MaxWidth="200">
<TextBlock Text="Zeit bis zur Sperre (Minuten)" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding AppLockTimeoutMinutes}" Minimum="1" Maximum="120" FormatString="0"/>
</StackPanel>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Passwort (leer lassen, um es zu behalten)" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding AppLockNewPassword}" PasswordChar="●"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Wiederholen" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding AppLockNewPasswordConfirm}" PasswordChar="●"/>
</StackPanel>
</Grid>
<TextBlock Text="{Binding AppLockPasswordError}" Foreground="Red" FontSize="12"
IsVisible="{Binding AppLockPasswordError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content="Speichern" Command="{Binding SaveAppLockSettingsCommand}" HorizontalAlignment="Left"/>
<TextBlock Text="{Binding AppLockStatus}" Foreground="Green" FontSize="12"
IsVisible="{Binding AppLockStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</ContentPage>
</TabbedPage>
</Grid>
</UserControl>
@@ -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<AppLockViewModel>().ApplyConfig();
}
}
private async Task<bool> 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<bool>(owner);
}
private async void OnImportClick(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
@@ -0,0 +1,18 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LehrerApp.Desktop.Views.Shared.ConfirmDialog"
x:CompileBindings="False"
Title="{Binding Title}"
Width="420" SizeToContent="Height"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="12">
<TextBlock Text="{Binding Title}" FontSize="18" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Message}" TextWrapping="Wrap" Opacity="0.8"/>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="{Binding ConfirmText}" HorizontalAlignment="Stretch" Click="OnConfirm"/>
</Grid>
</Grid>
</Window>
@@ -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);
}
@@ -0,0 +1,9 @@
namespace LehrerApp.Desktop.Views.Shared;
/// Generisches DataContext für <see cref="ConfirmDialog"/>.
public class ConfirmDialogInfo
{
public string Title { get; init; } = "";
public string Message { get; init; } = "";
public string ConfirmText { get; init; } = "Bestätigen";
}