using KronaStaffTerminal.Models; using KronaStaffTerminal.Services; using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using System.Text; using Windows.Storage; using Windows.Storage.Pickers; namespace KronaStaffTerminal; public sealed partial class MainPage : Page { private readonly TerminalStore _store = new(); private readonly NotificationService _notifications = new(); private readonly FingerprintScanner _scanner = new(); private readonly IUpdateSource _updateSource = new ForgejoUpdateSource(); private TerminalState _state = new(); private bool _loaded; private TaskCompletionSource? _adminAuthCompletion; private ContentDialog? _adminAuthDialog; private readonly DispatcherQueueTimer _adminSessionTimer; private readonly DispatcherQueueTimer _updateTimer; private readonly CancellationTokenSource _updateCancellation = new(); private AvailableUpdate? _availableUpdate; private bool _updateCheckInProgress; private bool _adminSessionActive; private bool _adminNavigationInProgress; private bool _stateLoadFailed; private bool _scannerReconnectPending; private string? _registrationEmployeeId; private bool _refreshingLists; private string _lastAdminActor = "unknown"; private DateTimeOffset _adminSessionExpiresAtUtc; private static readonly TimeSpan AdminSessionDuration = TimeSpan.FromMinutes(5); public MainPage() { try { InitializeComponent(); } catch (Exception exception) { CrashLogService.Write(exception); throw; } _scanner.StatusChanged += message => Enqueue(() => SetStatus(message)); _scanner.RegistrationProgress += count => Enqueue(() => SetStatus($"Регистрация отпечатка: {count}/3")); _scanner.RegistrationCompleted += (id, template) => EnqueueTask(() => CompleteRegistrationAsync(id, template)); _scanner.FingerRecognized += (id, score) => EnqueueTask(() => HandleRecognizedFingerAsync(id, score)); _scanner.ConnectionLost += () => EnqueueTask(ReconnectScannerAsync); _scanner.RegistrationCancelled += () => Enqueue(() => _registrationEmployeeId = null); _adminSessionTimer = DispatcherQueue.CreateTimer(); _adminSessionTimer.Interval = TimeSpan.FromSeconds(15); _adminSessionTimer.IsRepeating = true; _adminSessionTimer.Tick += (_, _) => { if (_adminSessionActive && DateTimeOffset.UtcNow >= _adminSessionExpiresAtUtc) { LockAdminSession("Сеанс администратора завершён после 5 минут бездействия."); } }; _adminSessionTimer.Start(); _updateTimer = DispatcherQueue.CreateTimer(); _updateTimer.Interval = TimeSpan.FromHours(6); _updateTimer.IsRepeating = true; _updateTimer.Tick += (_, _) => EnqueueTask(() => CheckForUpdatesAsync(false)); } private async void Page_Loaded(object sender, RoutedEventArgs e) { if (_loaded) return; _loaded = true; try { _state = await _store.LoadAsync(); } catch (Exception exception) { _stateLoadFailed = true; CrashLogService.Write(exception); MainTabs.SelectedItem = TerminalTab; TerminalMessage.Text = "Хранилище данных не открыто\nОбратитесь к администратору"; SetStatus(exception.Message, InfoBarSeverity.Error); return; } LoadSettings(); ApplyStartupSetting(); RefreshLists(); ConnectScanner(); _updateTimer.Start(); MainTabs.SelectedItem = TerminalTab; if (_store.RecoveryNotice is not null) { SetStatus(_store.RecoveryNotice, InfoBarSeverity.Warning); } await CheckForUpdatesAsync(false); } private void Page_Unloaded(object sender, RoutedEventArgs e) { _loaded = false; _adminSessionTimer.Stop(); _updateTimer.Stop(); _updateCancellation.Cancel(); _scanner.Dispose(); } private async Task ReconnectScannerAsync() { if (_scannerReconnectPending || !_loaded) return; _scannerReconnectPending = true; try { _scanner.Disconnect(); while (_loaded && !_scanner.IsConnected) { await Task.Delay(TimeSpan.FromSeconds(3)); if (!_loaded) return; ConnectScanner(); } } finally { _scannerReconnectPending = false; } } private async void MainTabs_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (_adminNavigationInProgress || ReferenceEquals(MainTabs.SelectedItem, TerminalTab)) return; if (HasActiveAdminSession) { TouchAdminSession(); return; } var requestedTab = MainTabs.SelectedItem; _adminNavigationInProgress = true; MainTabs.SelectedItem = TerminalTab; var authorized = await EnsureAdminSessionAsync("вход в административный раздел"); if (authorized) { MainTabs.SelectedItem = requestedTab; } _adminNavigationInProgress = false; } private void LogoutAdmin_Click(object sender, RoutedEventArgs e) => LockAdminSession("Административный раздел заблокирован."); public void LockAdminSession() => LockAdminSession("Административный раздел заблокирован при сворачивании."); private void LockAdminSession(string message) { CancelRegistration(); _adminSessionActive = false; _adminSessionExpiresAtUtc = default; _adminAuthCompletion?.TrySetResult(false); _adminAuthDialog?.Hide(); if (MainTabs is not null) { _adminNavigationInProgress = true; MainTabs.SelectedItem = TerminalTab; _adminNavigationInProgress = false; } if (_loaded) SetStatus(message, InfoBarSeverity.Informational); } private bool HasActiveAdminSession => _adminSessionActive && DateTimeOffset.UtcNow < _adminSessionExpiresAtUtc; private void TouchAdminSession() => _adminSessionExpiresAtUtc = DateTimeOffset.UtcNow.Add(AdminSessionDuration); private async Task EnsureAdminSessionAsync(string operation) { if (_stateLoadFailed) { SetStatus("Административные действия заблокированы: хранилище данных не открыто.", InfoBarSeverity.Error); return false; } if (HasActiveAdminSession) { TouchAdminSession(); return true; } var authorized = !PinConfigured && !AdministratorFingerprintConfigured ? await CreateInitialAdminPinAsync() : await AuthorizeAdminAsync(operation); if (!authorized) return false; _adminSessionActive = true; TouchAdminSession(); SetStatus(AdministratorFingerprintConfigured ? "Вход администратора выполнен. Сеанс закроется через 5 минут бездействия." : "Первичная настройка открыта. Добавьте администратора и зарегистрируйте его отпечаток.", InfoBarSeverity.Success); return true; } private async Task CreateInitialAdminPinAsync() { var pinBox = new PasswordBox { Header = "Придумайте PIN администратора", PasswordRevealMode = PasswordRevealMode.Peek }; var repeatBox = new PasswordBox { Header = "Повторите PIN", PasswordRevealMode = PasswordRevealMode.Peek }; var content = new StackPanel { Spacing = 12, MinWidth = 320 }; content.Children.Add(new TextBlock { Text = "Первичная настройка. После входа добавьте сотрудника-администратора и зарегистрируйте его отпечаток.", TextWrapping = TextWrapping.Wrap }); content.Children.Add(pinBox); content.Children.Add(repeatBox); var dialog = new ContentDialog { XamlRoot = XamlRoot, Title = "Создание администратора", Content = content, PrimaryButtonText = "Создать PIN", CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Primary }; if (await dialog.ShowAsync() != ContentDialogResult.Primary) return false; if (pinBox.Password.Length < 6 || pinBox.Password != repeatBox.Password) { SetStatus("PIN должен содержать не менее 6 символов, оба значения должны совпадать.", InfoBarSeverity.Error); return false; } var pin = await Task.Run(() => AdminPinService.Hash(pinBox.Password)); _state.Settings.AdminPinSalt = pin.Salt; _state.Settings.AdminPinHash = pin.Hash; _state.Settings.AdminPinIterations = pin.Iterations; await _store.SaveAsync(_state, createRecoveryPoint: true); _lastAdminActor = "initial-pin"; return true; } private void ConnectScanner() { try { _scanner.Connect(_state.Settings.ReaderIndex, _state.Employees .Where(employee => employee.HasFingerprint) .SelectMany(employee => employee.Fingerprints) .Select(fingerprint => (fingerprint.Id, fingerprint.Template))); ReaderCombo.Items.Clear(); for (var index = 0; index < _scanner.DeviceCount; index++) { ReaderCombo.Items.Add($"SLK20R №{index + 1}"); } ReaderCombo.SelectedIndex = _scanner.ReaderIndex; if (_state.Settings.ReaderIndex != _scanner.ReaderIndex) { _state.Settings.ReaderIndex = _scanner.ReaderIndex; SaveStateInBackground(); } if (_scanner.SkippedTemplateCount > 0) { SetStatus( $"Сканер SLK20R №{_scanner.ReaderIndex + 1} подключён. Не удалось загрузить отпечатков: {_scanner.SkippedTemplateCount}. Перерегистрируйте их в разделе сотрудников.", InfoBarSeverity.Warning); } else { SetStatus($"Сканер SLK20R №{_scanner.ReaderIndex + 1} подключён. Зарегистрировано сотрудников: {_state.Employees.Count(employee => employee.HasFingerprint)}.", InfoBarSeverity.Success); } } catch (Exception exception) { SetStatus(exception.Message, InfoBarSeverity.Error); } } private async void AddEmployee_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("добавление сотрудника")) return; var name = EmployeeNameBox.Text.Trim(); var number = PersonnelNumberBox.Text.Trim(); if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(number)) { SetStatus("Заполните имя и табельный номер сотрудника.", InfoBarSeverity.Warning); return; } if (_state.Employees.Any(employee => employee.PersonnelNumber.Equals(number, StringComparison.OrdinalIgnoreCase))) { SetStatus("Сотрудник с таким табельным номером уже есть.", InfoBarSeverity.Warning); return; } if (AdministratorCheckBox.IsChecked == true && SecurityConfigured && !await AuthorizeAdminAsync("назначение нового администратора")) { return; } var employee = new EmployeeRecord { DisplayName = name, PersonnelNumber = number, Position = PositionBox.Text.Trim(), Gender = GenderComboBox.SelectedIndex switch { 1 => "Мужской", 2 => "Женский", _ => "" }, IsAdministrator = AdministratorCheckBox.IsChecked == true }; _state.Employees.Add(employee); await _store.SaveAsync(_state, createRecoveryPoint: true); EmployeeNameBox.Text = ""; PersonnelNumberBox.Text = ""; PositionBox.Text = ""; GenderComboBox.SelectedIndex = 0; AdministratorCheckBox.IsChecked = false; RefreshLists(); EmployeesList.SelectedItem = employee; SetStatus($"{name} добавлен. Теперь зарегистрируйте отпечаток.", InfoBarSeverity.Success); } private async void EditEmployee_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("редактирование сотрудника")) return; if (EmployeesList.SelectedItem is not EmployeeRecord employee) { SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning); return; } var personnelNumberBox = new TextBox { Header = "Табельный номер", Text = employee.PersonnelNumber, MinWidth = 360 }; var nameBox = new TextBox { Header = "Имя сотрудника", Text = employee.DisplayName, MinWidth = 360 }; var positionBox = new TextBox { Header = "Должность", Text = employee.Position, MinWidth = 360 }; var genderBox = new ComboBox { Header = "Пол", MinWidth = 360, ItemsSource = new[] { "Не указан", "Мужской", "Женский" }, SelectedIndex = employee.Gender switch { "Мужской" => 1, "Женский" => 2, _ => 0 } }; var content = new StackPanel { Spacing = 12 }; content.Children.Add(personnelNumberBox); content.Children.Add(nameBox); content.Children.Add(positionBox); content.Children.Add(genderBox); var dialog = new ContentDialog { XamlRoot = XamlRoot, Title = $"Редактирование: {employee.DisplayName}", Content = content, PrimaryButtonText = "Сохранить", CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Primary }; if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; var number = personnelNumberBox.Text.Trim(); var name = nameBox.Text.Trim(); if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(number)) { SetStatus("Имя и табельный номер не могут быть пустыми.", InfoBarSeverity.Warning); return; } if (_state.Employees.Any(item => !ReferenceEquals(item, employee) && item.PersonnelNumber.Equals(number, StringComparison.OrdinalIgnoreCase))) { SetStatus("Сотрудник с таким табельным номером уже есть.", InfoBarSeverity.Warning); return; } employee.PersonnelNumber = number; employee.DisplayName = name; employee.Position = positionBox.Text.Trim(); employee.Gender = genderBox.SelectedIndex switch { 1 => "Мужской", 2 => "Женский", _ => "" }; await _store.SaveAsync(_state, createRecoveryPoint: true); RefreshLists(); EmployeesList.SelectedItem = employee; SetStatus($"Данные сотрудника «{name}» обновлены. Отпечатки и журнал сохранены.", InfoBarSeverity.Success); } private async void RegisterFingerprint_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("регистрация отпечатка")) return; if (EmployeesList.SelectedItem is not EmployeeRecord employee) { SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning); return; } if (!_scanner.IsConnected) { SetStatus("Сканер не подключён.", InfoBarSeverity.Error); return; } var fingerprintId = _state.Employees .SelectMany(item => item.Fingerprints) .Select(item => item.Id) .DefaultIfEmpty(0) .Max() + 1; _registrationEmployeeId = employee.Id; _scanner.BeginRegistration(fingerprintId); } private void CancelRegistration_Click(object sender, RoutedEventArgs e) { if (_registrationEmployeeId is null) { SetStatus("Активной регистрации отпечатка нет.", InfoBarSeverity.Informational); return; } CancelRegistration(); SetStatus("Регистрация отпечатка отменена.", InfoBarSeverity.Informational); } private void EmployeesList_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (_refreshingLists) return; if (_registrationEmployeeId is null) return; if (EmployeesList.SelectedItem is EmployeeRecord employee && employee.Id == _registrationEmployeeId) return; CancelRegistration(); SetStatus("Регистрация отпечатка отменена из-за смены сотрудника.", InfoBarSeverity.Warning); } private void CancelRegistration() { _scanner.CancelRegistration(); _registrationEmployeeId = null; } private async Task CompleteRegistrationAsync(int fingerprintId, string template) { var employee = _state.Employees.FirstOrDefault(item => item.Id == _registrationEmployeeId); if (employee is null) return; employee.Fingerprints.Add(new FingerprintRecord { Id = fingerprintId, Template = template, Label = $"Отпечаток {employee.Fingerprints.Count + 1}" }); _registrationEmployeeId = null; await _store.SaveAsync(_state, createRecoveryPoint: true); RefreshLists(); TerminalMessage.Text = $"Новый отпечаток сотрудника «{TerminalDisplayName(employee)}» зарегистрирован"; SetStatus($"Добавлен отпечаток №{employee.Fingerprints.Count} сотрудника «{employee.DisplayName}».", InfoBarSeverity.Success); } private async void DeleteFingerprints_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("удаление отпечатков")) return; if (EmployeesList.SelectedItem is not EmployeeRecord employee) { SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning); return; } if (!employee.HasFingerprint) { SetStatus("У выбранного сотрудника нет зарегистрированных отпечатков.", InfoBarSeverity.Informational); return; } if (employee.IsAdministrator && !PinConfigured && _state.Employees.Count(item => item.IsAdministrator && item.HasFingerprint) <= 1) { SetStatus("Нельзя удалить последний способ входа администратора.", InfoBarSeverity.Warning); return; } var dialog = new ContentDialog { XamlRoot = XamlRoot, Title = "Удалить все отпечатки сотрудника?", Content = $"{employee.DisplayName}: будет удалено отпечатков — {employee.Fingerprints.Count}. Сам сотрудник останется.", PrimaryButtonText = "Удалить отпечатки", CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Close }; if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; if (!await AuthorizeAdminAsync("удаление отпечатков сотрудника")) return; employee.Fingerprints.Clear(); await _store.SaveAsync(_state, createRecoveryPoint: true); RefreshLists(); ConnectScanner(); SetStatus($"Все отпечатки сотрудника «{employee.DisplayName}» удалены.", InfoBarSeverity.Success); } private async void ToggleAdministrator_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("изменение прав администратора")) return; if (EmployeesList.SelectedItem is not EmployeeRecord employee) { SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning); return; } if (SecurityConfigured && !await AuthorizeAdminAsync("изменение прав администратора")) return; if (employee.IsAdministrator && _state.Employees.Count(item => item.IsAdministrator) <= 1) { SetStatus("Нельзя снять права с единственного администратора с зарегистрированным отпечатком.", InfoBarSeverity.Warning); return; } employee.IsAdministrator = !employee.IsAdministrator; await _store.SaveAsync(_state, createRecoveryPoint: true); RefreshLists(); SetStatus(employee.IsAdministrator ? $"{employee.DisplayName} назначен администратором." : $"Права администратора у {employee.DisplayName} сняты.", InfoBarSeverity.Success); } private async void DeleteEmployee_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("удаление сотрудника")) return; if (EmployeesList.SelectedItem is not EmployeeRecord employee) { SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning); return; } var dialog = new ContentDialog { XamlRoot = XamlRoot, Title = "Удалить сотрудника и отпечаток?", Content = $"{employee.DisplayName}, его отпечаток и право доступа будут удалены из терминала.", PrimaryButtonText = "Продолжить", CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Close }; if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; if (!SecurityConfigured) { SetStatus("Сначала настройте PIN или отпечаток администратора.", InfoBarSeverity.Warning); return; } if (!await AuthorizeAdminAsync("удаление сотрудника и отпечатка")) return; if (employee.IsAdministrator && _state.Employees.Count(item => item.IsAdministrator) <= 1) { SetStatus("Нельзя удалить единственного администратора.", InfoBarSeverity.Warning); return; } _state.Employees.Remove(employee); await _store.SaveAsync(_state, createRecoveryPoint: true); RefreshLists(); ConnectScanner(); SetStatus("Сотрудник и его отпечаток удалены.", InfoBarSeverity.Success); } private async Task HandleRecognizedFingerAsync(int fingerprintId, int score) { var employee = _state.Employees.FirstOrDefault(item => item.Fingerprints.Any(fingerprint => fingerprint.Id == fingerprintId)); if (employee is null) return; if (_adminAuthCompletion is not null) { if (employee.IsAdministrator) { _lastAdminActor = $"employee:{employee.Id}"; TerminalMessage.Text = $"Администратор {TerminalDisplayName(employee)} подтверждён"; _adminAuthCompletion.TrySetResult(true); _adminAuthDialog?.Hide(); } else { await RecordAttendanceAsync(employee, score); } return; } await RecordAttendanceAsync(employee, score); } private async Task RecordAttendanceAsync(EmployeeRecord employee, int score) { var now = DateTimeOffset.UtcNow; var last = _state.Attendance .Where(item => item.EmployeeId == employee.Id) .OrderByDescending(item => item.OccurredAtUtc) .FirstOrDefault(); var guard = TimeSpan.FromSeconds(Math.Clamp(_state.Settings.DuplicateScanGuardSeconds, 10, 3600)); if (last is not null && now - last.OccurredAtUtc < guard) { var remaining = Math.Max(1, (int)Math.Ceiling((guard - (now - last.OccurredAtUtc)).TotalSeconds)); TerminalMessage.Text = $"{TerminalDisplayName(employee)}\nПовторный скан отклонён\nПовторите через {remaining} сек."; SetStatus($"Повторный скан защищён: событие не создано. Последнее событие — «{last.EventType}».", InfoBarSeverity.Informational); return; } var maximumShift = TimeSpan.FromHours(Math.Clamp(_state.Settings.MaximumShiftHours, 1, 48)); var eventType = last is not null && last.EventType == "Приход" && now - last.OccurredAtUtc <= maximumShift ? "Уход" : "Приход"; var record = new AttendanceRecord { EmployeeId = employee.Id, EmployeeName = employee.DisplayName, OccurredAtUtc = now, ReaderIndex = _scanner.ReaderIndex, EventType = eventType }; _state.Attendance.Insert(0, record); if (_state.Attendance.Count > 100_000) { _state.Attendance.RemoveRange(100_000, _state.Attendance.Count - 100_000); } await _store.SaveAsync(_state); RefreshLists(); TerminalMessage.Text = $"{TerminalDisplayName(employee)}\n{eventType} отмечен в {now.ToLocalTime():HH:mm}"; SetStatus($"{eventType} отмечен. Точность распознавания: {score}.", InfoBarSeverity.Success); var delivery = await _notifications.SendAttendanceAsync(_state.Settings, employee.DisplayName, eventType, now.ToLocalTime()); record.NotificationDelivered = delivery.Delivered; record.NotificationError = delivery.Error; await _store.SaveAsync(_state); RefreshLists(); if (delivery.Error is not null) { SetStatus($"{eventType} сохранён, но уведомление не отправлено: {delivery.Error}", InfoBarSeverity.Warning); } } private async void ClearAttendance_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("очистка журнала")) return; var dialog = new ContentDialog { XamlRoot = XamlRoot, Title = "Очистить журнал отметок?", Content = $"Будут удалены все записи прихода и ухода: {_state.Attendance.Count}. Сотрудники и отпечатки останутся.", PrimaryButtonText = "Очистить журнал", CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Close }; if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; if (!await AuthorizeAdminAsync("очистка журнала прихода и ухода")) return; _state.Attendance.Clear(); await _store.SaveAsync(_state, createRecoveryPoint: true); RefreshLists(); TerminalMessage.Text = "Журнал отметок очищен"; SetStatus("Журнал прихода и ухода очищен.", InfoBarSeverity.Success); } private async void ExportAttendance_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("экспорт журнала")) return; var warning = new ContentDialog { XamlRoot = XamlRoot, Title = "Экспорт персональных данных", Content = "CSV-файл не шифруется и содержит ФИО, табельные номера, должности и время отметок. Сохраняйте его только в защищённом месте.", PrimaryButtonText = "Продолжить экспорт", CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Close }; if (await warning.ShowAsync() != ContentDialogResult.Primary) return; _lastAdminActor = "unknown"; if (!await AuthorizeAdminAsync("экспорт персональных данных", requireIdentifiedActor: true)) return; var picker = new FileSavePicker { SuggestedFileName = $"krona-staff-attendance-{DateTime.Now:yyyy-MM-dd}" }; picker.FileTypeChoices.Add("CSV", new[] { ".csv" }); WinRT.Interop.InitializeWithWindow.Initialize(picker, App.WindowHandle); var file = await picker.PickSaveFileAsync(); if (file is null) return; AppendSecurityAudit("attendance.export", "started", $"records={_state.Attendance.Count}; file={file.Name}"); await _store.SaveAsync(_state); try { var csv = new StringBuilder("Сотрудник;Табельный номер;Должность;Событие;Время;Доставка\r\n"); foreach (var item in _state.Attendance.OrderBy(record => record.OccurredAtUtc)) { var employee = _state.Employees.FirstOrDefault(candidate => candidate.Id == item.EmployeeId); csv.Append(Csv(item.EmployeeName)).Append(';') .Append(Csv(employee?.PersonnelNumber ?? "")).Append(';') .Append(Csv(employee?.Position ?? "")).Append(';') .Append(Csv(item.EventType)).Append(';') .Append(Csv(item.LocalTime)).Append(';') .Append(Csv(item.DeliveryStatus)).Append("\r\n"); } await FileIO.WriteTextAsync(file, csv.ToString(), Windows.Storage.Streams.UnicodeEncoding.Utf8); AppendSecurityAudit("attendance.export", "succeeded", $"records={_state.Attendance.Count}; file={file.Name}"); await _store.SaveAsync(_state); SetStatus($"Журнал экспортирован: {file.Name}", InfoBarSeverity.Success); } catch (Exception exception) { AppendSecurityAudit("attendance.export", "failed", $"records={_state.Attendance.Count}; error={exception.GetType().Name}"); await _store.SaveAsync(_state); CrashLogService.Write(exception); SetStatus($"Экспорт не выполнен: {exception.Message}", InfoBarSeverity.Error); } } private static string Csv(string value) => $"\"{value.Replace("\"", "\"\"")}\""; public async Task AuthorizeExitAsync() { if (_stateLoadFailed) { SetStatus("Хранилище не открыто. Программа завершает работу без изменения данных.", InfoBarSeverity.Warning); return true; } if (!SecurityConfigured) { SetStatus("Выход заблокирован: сначала настройте PIN или отпечаток администратора.", InfoBarSeverity.Warning); return false; } return await AuthorizeAdminAsync("полное завершение терминала"); } private bool PinConfigured => !string.IsNullOrWhiteSpace(_state.Settings.AdminPinHash); private bool AdministratorFingerprintConfigured => _state.Employees.Any(employee => employee.IsAdministrator && employee.HasFingerprint); private bool SecurityConfigured => PinConfigured || AdministratorFingerprintConfigured; private async Task AuthorizeAdminAsync(string operation, bool requireIdentifiedActor = false) { var remainingLockout = _state.Settings.AdminPinBlockedUntilUtc - DateTimeOffset.UtcNow; var pinBlocked = remainingLockout > TimeSpan.Zero; var hasAdministratorFingerprint = AdministratorFingerprintConfigured; if (requireIdentifiedActor && !hasAdministratorFingerprint) { SetStatus("Для экспорта персональных данных требуется отпечаток сотрудника-администратора.", InfoBarSeverity.Warning); return false; } if (pinBlocked && !hasAdministratorFingerprint) { SetStatus($"Слишком много неверных попыток PIN. Повторите через {Math.Ceiling(remainingLockout.TotalSeconds)} сек.", InfoBarSeverity.Warning); return false; } var pinBox = new PasswordBox { Header = "PIN администратора", PasswordRevealMode = PasswordRevealMode.Peek, MinWidth = 280, IsEnabled = !pinBlocked && !requireIdentifiedActor }; var content = new StackPanel { Spacing = 12, MinWidth = 320 }; content.Children.Add(new TextBlock { Text = requireIdentifiedActor ? "Для записи автора экспорта приложите зарегистрированный палец сотрудника-администратора." : hasAdministratorFingerprint ? pinBlocked ? $"PIN временно заблокирован ещё на {Math.Ceiling(remainingLockout.TotalSeconds)} сек. Приложите зарегистрированный палец администратора." : "Введите PIN или приложите зарегистрированный палец администратора." : "Введите PIN. После входа зарегистрируйте отпечаток администратора.", TextWrapping = TextWrapping.Wrap }); content.Children.Add(pinBox); _adminAuthCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _adminAuthDialog = new ContentDialog { XamlRoot = XamlRoot, Title = $"Вход администратора: {operation}", Content = content, PrimaryButtonText = "Войти по PIN", IsPrimaryButtonEnabled = !pinBlocked && !requireIdentifiedActor, CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Primary }; var dialogResult = await _adminAuthDialog.ShowAsync(); var fingerprintAuthorized = _adminAuthCompletion.Task.IsCompletedSuccessfully && _adminAuthCompletion.Task.Result; _adminAuthCompletion.TrySetResult(false); var pinAuthorized = false; string verifiedPin = pinBox.Password; if (dialogResult == ContentDialogResult.Primary && !pinBlocked && !requireIdentifiedActor) { pinAuthorized = await Task.Run(() => AdminPinService.Verify( pinBox.Password, _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash, _state.Settings.AdminPinIterations) || (!string.Equals(pinBox.Password, pinBox.Password.Trim(), StringComparison.Ordinal) && AdminPinService.Verify( pinBox.Password.Trim(), _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash, _state.Settings.AdminPinIterations))); } var authorized = fingerprintAuthorized || pinAuthorized; var securityStateChanged = false; if (authorized) { _state.Settings.FailedAdminPinAttempts = 0; _state.Settings.AdminPinBlockedUntilUtc = default; securityStateChanged = true; if (pinAuthorized) { _lastAdminActor = "local-pin"; if (_state.Settings.AdminPinIterations < AdminPinService.CurrentIterations) { if (!AdminPinService.Verify(verifiedPin, _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash, _state.Settings.AdminPinIterations)) { verifiedPin = verifiedPin.Trim(); } var upgraded = await Task.Run(() => AdminPinService.Hash(verifiedPin)); _state.Settings.AdminPinSalt = upgraded.Salt; _state.Settings.AdminPinHash = upgraded.Hash; _state.Settings.AdminPinIterations = upgraded.Iterations; } } } else if (dialogResult == ContentDialogResult.Primary && !pinBlocked && !requireIdentifiedActor) { _state.Settings.FailedAdminPinAttempts++; var delaySeconds = Math.Min(900, 1 << Math.Min(_state.Settings.FailedAdminPinAttempts - 1, 10)); _state.Settings.AdminPinBlockedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(delaySeconds); securityStateChanged = true; } if (securityStateChanged) await _store.SaveAsync(_state); _adminAuthCompletion = null; _adminAuthDialog = null; SetStatus(authorized ? fingerprintAuthorized ? "Вход выполнен по отпечатку администратора." : "Вход выполнен по PIN администратора." : dialogResult == ContentDialogResult.Primary ? "Неверный PIN администратора." : "Вход отменён.", authorized ? InfoBarSeverity.Success : dialogResult == ContentDialogResult.Primary ? InfoBarSeverity.Error : InfoBarSeverity.Warning); return authorized; } private async void SetAdminPin_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("изменение PIN")) return; var newPin = NewAdminPinBox.Password.Trim(); if (newPin.Length < 6) { SetStatus("PIN должен содержать не менее 6 символов.", InfoBarSeverity.Warning); return; } if (!string.IsNullOrWhiteSpace(_state.Settings.AdminPinHash) && !await AuthorizeAdminAsync("изменение PIN")) return; var pin = await Task.Run(() => AdminPinService.Hash(newPin)); _state.Settings.AdminPinSalt = pin.Salt; _state.Settings.AdminPinHash = pin.Hash; _state.Settings.AdminPinIterations = pin.Iterations; NewAdminPinBox.Password = ""; await _store.SaveAsync(_state, createRecoveryPoint: true); SetStatus("Новый PIN администратора сохранён.", InfoBarSeverity.Success); } private async void SaveSettings_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("изменение настроек")) return; CopySettingsFromControls(); await _store.SaveAsync(_state, createRecoveryPoint: true); ApplyStartupSetting(); ConnectScanner(); SetStatus("Настройки сохранены.", InfoBarSeverity.Success); } private async void CheckForUpdates_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("проверка обновлений")) return; CopyUpdateSettingsFromControls(); await _store.SaveAsync(_state); await CheckForUpdatesAsync(true); } private async Task CheckForUpdatesAsync(bool reportUpToDate) { if (_updateCheckInProgress || (!_state.Settings.UpdateChecksEnabled && !reportUpToDate) || _updateCancellation.IsCancellationRequested) { return; } _updateCheckInProgress = true; try { var update = await _updateSource.FindUpdateAsync( _state.Settings, _updateCancellation.Token); _state.Settings.LastUpdateCheckUtc = DateTimeOffset.UtcNow; if (update is null) { _availableUpdate = null; UpdateBar.IsOpen = false; if (reportUpToDate) { SetStatus( $"Установлена последняя версия {ForgejoUpdateSource.CurrentVersion.ToString(3)}.", InfoBarSeverity.Success); } return; } _availableUpdate = update; UpdateBar.Message = $"{update.ReleaseName}. Установка доступна администратору."; UpdateBar.IsOpen = true; if (reportUpToDate) { SetStatus($"Доступна версия {update.DisplayVersion}.", InfoBarSeverity.Informational); } } catch (OperationCanceledException) when (_updateCancellation.IsCancellationRequested) { } catch (Exception exception) { CrashLogService.Write(exception); if (reportUpToDate) { SetStatus($"Не удалось проверить обновления: {exception.Message}", InfoBarSeverity.Warning); } } finally { _updateCheckInProgress = false; } } private async void InstallUpdate_Click(object sender, RoutedEventArgs e) { if (_availableUpdate is null) return; if (!await EnsureAdminSessionAsync("установка обновления")) return; var update = _availableUpdate; var notes = string.IsNullOrWhiteSpace(update.ReleaseNotes) ? "После загрузки программа закроется и откроется установщик." : update.ReleaseNotes; var dialog = new ContentDialog { XamlRoot = XamlRoot, Title = $"Установить Krona Staff {update.DisplayVersion}?", Content = new TextBlock { Text = notes, TextWrapping = TextWrapping.Wrap, MaxWidth = 520 }, PrimaryButtonText = "Скачать и установить", CloseButtonText = "Позже", DefaultButton = ContentDialogButton.Primary }; if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; try { UpdateBar.IsClosable = false; UpdateBar.Message = $"Загрузка версии {update.DisplayVersion}: 0%"; var progress = new Progress(value => UpdateBar.Message = $"Загрузка версии {update.DisplayVersion}: {value:P0}"); var installerPath = await _updateSource.DownloadAndVerifyAsync( update, _state.Settings, progress, _updateCancellation.Token); AppendSecurityAudit("application.update", "started", $"version={update.DisplayVersion}"); await _store.SaveAsync(_state, createRecoveryPoint: true); ForgejoUpdateSource.LaunchInstaller(installerPath); ((App)Application.Current).CompleteExit(); } catch (Exception exception) { CrashLogService.Write(exception); UpdateBar.IsClosable = true; SetStatus($"Обновление не установлено: {exception.Message}", InfoBarSeverity.Error); } } private async void TestNotification_Click(object sender, RoutedEventArgs e) { if (!await EnsureAdminSessionAsync("проверка уведомлений")) return; CopySettingsFromControls(); var result = await _notifications.SendTestAsync(_state.Settings); SetStatus(result.Delivered ? "Тестовое сообщение отправлено." : result.Error ?? "Включите Telegram или MAX и заполните реквизиты.", result.Delivered ? InfoBarSeverity.Success : InfoBarSeverity.Warning); } private void CopySettingsFromControls() { _state.Settings.ReaderIndex = Math.Max(0, ReaderCombo.SelectedIndex); _state.Settings.StartWithWindows = StartWithWindowsSwitch.IsOn; _state.Settings.DuplicateScanGuardSeconds = double.IsNaN(DuplicateGuardBox.Value) ? 120 : Math.Clamp((int)DuplicateGuardBox.Value, 10, 3600); _state.Settings.MaximumShiftHours = double.IsNaN(MaximumShiftHoursBox.Value) ? 20 : Math.Clamp((int)MaximumShiftHoursBox.Value, 1, 48); _state.Settings.ShowEmployeeNamesOnTerminal = ShowEmployeeNamesSwitch.IsOn; _state.Settings.TelegramEnabled = TelegramEnabledSwitch.IsOn; _state.Settings.TelegramBotToken = TelegramTokenBox.Password.Trim(); _state.Settings.TelegramChatId = TelegramChatBox.Text.Trim(); _state.Settings.MaxEnabled = MaxEnabledSwitch.IsOn; _state.Settings.MaxBotToken = MaxTokenBox.Password.Trim(); _state.Settings.MaxChatId = MaxChatBox.Text.Trim(); CopyUpdateSettingsFromControls(); } private void CopyUpdateSettingsFromControls() { _state.Settings.UpdateChecksEnabled = UpdateChecksSwitch.IsOn; _state.Settings.UpdateFeedUrl = UpdateFeedBox.Text.Trim(); _state.Settings.UpdateAccessToken = UpdateTokenBox.Password.Trim(); } private void LoadSettings() { StartWithWindowsSwitch.IsOn = _state.Settings.StartWithWindows; DuplicateGuardBox.Value = Math.Clamp(_state.Settings.DuplicateScanGuardSeconds, 10, 3600); MaximumShiftHoursBox.Value = Math.Clamp(_state.Settings.MaximumShiftHours, 1, 48); ShowEmployeeNamesSwitch.IsOn = _state.Settings.ShowEmployeeNamesOnTerminal; TelegramEnabledSwitch.IsOn = _state.Settings.TelegramEnabled; TelegramTokenBox.Password = _state.Settings.TelegramBotToken; TelegramChatBox.Text = _state.Settings.TelegramChatId; MaxEnabledSwitch.IsOn = _state.Settings.MaxEnabled; MaxTokenBox.Password = _state.Settings.MaxBotToken; MaxChatBox.Text = _state.Settings.MaxChatId; UpdateChecksSwitch.IsOn = _state.Settings.UpdateChecksEnabled; UpdateFeedBox.Text = _state.Settings.UpdateFeedUrl; UpdateTokenBox.Password = _state.Settings.UpdateAccessToken; CurrentVersionText.Text = $"Текущая версия: {ForgejoUpdateSource.CurrentVersion.ToString(3)}"; } private void ApplyStartupSetting() { try { StartupService.SetEnabled(_state.Settings.StartWithWindows); StartWithWindowsSwitch.IsOn = StartupService.IsEnabled(); } catch (Exception exception) { SetStatus($"Не удалось изменить автозагрузку: {exception.Message}", InfoBarSeverity.Warning); } } private void RefreshLists() { var selectedEmployeeId = (EmployeesList.SelectedItem as EmployeeRecord)?.Id; _refreshingLists = true; try { var employees = _state.Employees.OrderBy(employee => employee.DisplayName).ToList(); EmployeesList.ItemsSource = employees; EmployeesList.SelectedItem = employees.FirstOrDefault(employee => employee.Id == selectedEmployeeId); AttendanceList.ItemsSource = _state.Attendance .OrderByDescending(item => item.OccurredAtUtc) .Take(100) .Select(item => _state.Settings.ShowEmployeeNamesOnTerminal ? item : new AttendanceRecord { Id = item.Id, EmployeeId = item.EmployeeId, EmployeeName = MaskName(item.EmployeeName), OccurredAtUtc = item.OccurredAtUtc, ReaderIndex = item.ReaderIndex, EventType = item.EventType, NotificationDelivered = item.NotificationDelivered, NotificationError = item.NotificationError }) .ToList(); } finally { _refreshingLists = false; } } private string TerminalDisplayName(EmployeeRecord employee) => _state.Settings.ShowEmployeeNamesOnTerminal ? employee.DisplayName : MaskName(employee.DisplayName); private void AppendSecurityAudit(string action, string outcome, string detail) => _state.SecurityAudit.Add(new TerminalAuditRecord { Action = action, Actor = _lastAdminActor, Outcome = outcome, Detail = detail }); private static string MaskName(string name) => string.Join(' ', name .Split(' ', StringSplitOptions.RemoveEmptyEntries) .Select(part => part.Length == 1 ? $"{part[0]}…" : $"{part[0]}***")); private void SetStatus(string message, InfoBarSeverity severity = InfoBarSeverity.Informational) { StatusBar.Message = message; StatusBar.Severity = severity; StatusBar.IsOpen = true; } private static void Enqueue(Action action) => App.DispatcherQueue.TryEnqueue(() => action()); private void EnqueueTask(Func operation) => App.DispatcherQueue.TryEnqueue(async () => { try { await operation(); } catch (Exception exception) { CrashLogService.Write(exception); SetStatus($"Операция не выполнена: {exception.Message}", InfoBarSeverity.Error); } }); private async void SaveStateInBackground() { try { await _store.SaveAsync(_state); } catch (Exception exception) { CrashLogService.Write(exception); SetStatus($"Не удалось сохранить данные: {exception.Message}", InfoBarSeverity.Error); } } }