krona-staff/apps/KronaStaffTerminal/MainPage.xaml.cs
Ochenstarik b4eb3432ae
Some checks failed
ci / verify (push) Has been cancelled
feat(terminal): add scheduled automatic checkout
2026-09-21 10:00:11 +07:00

1544 lines
70 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using KronaStaffTerminal.Models;
using KronaStaffTerminal.Services;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
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<bool>? _adminAuthCompletion;
private ContentDialog? _adminAuthDialog;
private readonly DispatcherQueueTimer _adminSessionTimer;
private readonly DispatcherQueueTimer _updateTimer;
private readonly DispatcherQueueTimer _automaticCheckoutTimer;
private readonly CancellationTokenSource _updateCancellation = new();
private AvailableUpdate? _availableUpdate;
private bool _updateCheckInProgress;
private bool _automaticCheckoutInProgress;
private bool _adminSessionActive;
private bool _adminNavigationInProgress;
private bool _stateLoadFailed;
private bool _scannerReconnectPending;
private bool _refreshingReaders;
private int? _attendanceSuppressedFingerprintId;
private DateTimeOffset _attendanceSuppressedUntilUtc;
private string? _registrationEmployeeId;
private bool _refreshingLists;
private bool _applicationLocked = true;
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));
_automaticCheckoutTimer = DispatcherQueue.CreateTimer();
_automaticCheckoutTimer.Interval = TimeSpan.FromMinutes(1);
_automaticCheckoutTimer.IsRepeating = true;
_automaticCheckoutTimer.Tick += (_, _) => EnqueueTask(RunAutomaticCheckoutIfDueAsync);
}
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();
_automaticCheckoutTimer.Start();
MainTabs.SelectedItem = TerminalTab;
LockScreenMessage.Text = SecurityConfigured
? "Введите PIN администратора или приложите зарегистрированный палец."
: "Первый запуск: нажмите «Войти», чтобы создать PIN администратора.";
if (_store.RecoveryNotice is not null)
{
SetStatus(_store.RecoveryNotice, InfoBarSeverity.Warning);
}
await RunAutomaticCheckoutIfDueAsync();
await CheckForUpdatesAsync(false);
}
private void Page_Unloaded(object sender, RoutedEventArgs e)
{
_loaded = false;
_adminSessionTimer.Stop();
_updateTimer.Stop();
_automaticCheckoutTimer.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) =>
LockApplication("Программа заблокирована.");
public void LockAdminSession() =>
LockApplication("Программа заблокирована при сворачивании.");
private void LockApplication(string message)
{
LockAdminSession(message);
_applicationLocked = true;
if (ProtectedContent is not null) ProtectedContent.Visibility = Visibility.Collapsed;
if (ApplicationLockScreen is not null) ApplicationLockScreen.Visibility = Visibility.Visible;
if (UnlockPinBox is not null) UnlockPinBox.Password = "";
if (LockScreenMessage is not null) LockScreenMessage.Text = message + " Введите PIN или приложите палец администратора.";
}
private void UnlockApplication(string actor, string message)
{
_applicationLocked = false;
_lastAdminActor = actor;
_adminSessionActive = true;
TouchAdminSession();
UnlockPinBox.Password = "";
ApplicationLockScreen.Visibility = Visibility.Collapsed;
ProtectedContent.Visibility = Visibility.Visible;
MainTabs.SelectedItem = TerminalTab;
SetStatus(message, InfoBarSeverity.Success);
}
private async void UnlockApplication_Click(object sender, RoutedEventArgs e) =>
await UnlockApplicationByPinAsync();
private async void UnlockPinBox_KeyDown(object sender, KeyRoutedEventArgs e)
{
if (e.Key != Windows.System.VirtualKey.Enter) return;
e.Handled = true;
await UnlockApplicationByPinAsync();
}
private async Task UnlockApplicationByPinAsync()
{
if (_stateLoadFailed)
{
LockScreenMessage.Text = "Хранилище не открыто. Доступ заблокирован.";
return;
}
if (!SecurityConfigured)
{
if (await EnsureAdminSessionAsync("первичная настройка"))
{
UnlockApplication("initial-pin", "Первичная настройка выполнена.");
}
return;
}
var remainingLockout = _state.Settings.AdminPinBlockedUntilUtc - DateTimeOffset.UtcNow;
if (remainingLockout > TimeSpan.Zero)
{
LockScreenMessage.Text = $"PIN временно заблокирован. Повторите через {Math.Ceiling(remainingLockout.TotalSeconds)} сек. Можно приложить палец администратора.";
return;
}
var enteredPin = UnlockPinBox.Password;
var authorized = await Task.Run(() =>
AdminPinService.Verify(enteredPin, _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash, _state.Settings.AdminPinIterations) ||
(!string.Equals(enteredPin, enteredPin.Trim(), StringComparison.Ordinal) &&
AdminPinService.Verify(enteredPin.Trim(), _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash, _state.Settings.AdminPinIterations)));
if (authorized)
{
_state.Settings.FailedAdminPinAttempts = 0;
_state.Settings.AdminPinBlockedUntilUtc = default;
await _store.SaveAsync(_state);
UnlockApplication("local-pin", "Вход выполнен по PIN администратора.");
return;
}
_state.Settings.FailedAdminPinAttempts++;
var delaySeconds = Math.Min(900, 1 << Math.Min(_state.Settings.FailedAdminPinAttempts - 1, 10));
_state.Settings.AdminPinBlockedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(delaySeconds);
UnlockPinBox.Password = "";
LockScreenMessage.Text = $"Неверный PIN. Следующая попытка через {delaySeconds} сек.";
await _store.SaveAsync(_state);
}
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<bool> 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(AdministratorEmployeeConfigured
? "Вход администратора выполнен. Сеанс закроется через 5 минут бездействия."
: "Первичная настройка открыта. Добавьте первого сотрудника-администратора. Сканер можно подключить позже.",
InfoBarSeverity.Success);
return true;
}
private async Task<bool> 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 = "Первичная настройка. PIN работает без сканера. После входа добавьте первого сотрудника-администратора; отпечаток можно зарегистрировать позже.",
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;
}
// WinUI controls are apartment-bound. Read the value on the UI thread
// before moving the expensive password hashing to the thread pool.
var enteredPin = pinBox.Password;
var pin = await Task.Run(() => AdminPinService.Hash(enteredPin));
_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 && !employee.IsBlocked)
.SelectMany(employee => employee.Fingerprints)
.Select(fingerprint => (fingerprint.Id, fingerprint.Template)));
_refreshingReaders = true;
try
{
ReaderCombo.Items.Clear();
for (var index = 0; index < _scanner.DeviceCount; index++)
{
ReaderCombo.Items.Add($"SLK20R №{index + 1}");
}
ReaderCombo.SelectedIndex = _scanner.ReaderIndex;
}
finally
{
_refreshingReaders = false;
}
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)
{
_refreshingReaders = true;
try
{
ReaderCombo.Items.Clear();
ReaderCombo.SelectedIndex = -1;
}
finally
{
_refreshingReaders = false;
}
SetStatus($"{exception.Message} Проверьте USB-подключение и нажмите «Найти сканеры».", InfoBarSeverity.Error);
}
}
private void ScanReaders_Click(object sender, RoutedEventArgs e) => ConnectScanner();
private void ReaderCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_refreshingReaders || !_loaded || ReaderCombo.SelectedIndex < 0) return;
if (_scanner.IsConnected && ReaderCombo.SelectedIndex == _scanner.ReaderIndex) return;
_state.Settings.ReaderIndex = ReaderCombo.SelectedIndex;
ConnectScanner();
}
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 && AdministratorEmployeeConfigured &&
!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(employee.IsAdministrator
? $"{name} добавлен как администратор. Отпечаток можно зарегистрировать после подключения сканера."
: $"{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 EditEmployeeNotifications_Click(object sender, RoutedEventArgs e)
{
if (!await EnsureAdminSessionAsync("настройка личных уведомлений")) return;
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
{
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
return;
}
if (employee.IsBlocked)
{
SetStatus("Сначала разблокируйте сотрудника.", InfoBarSeverity.Warning);
return;
}
var telegramBox = new TextBox
{
Header = "Telegram — личный chat_id",
Text = employee.TelegramChatId,
PlaceholderText = "Например, 123456789",
MinWidth = 420
};
var vkBox = new TextBox
{
Header = "ВКонтакте — личный peer_id",
Text = employee.VkPeerId,
PlaceholderText = "ID пользователя в диалоге с сообществом"
};
var maxBox = new TextBox
{
Header = "MAX — личный user_id",
Text = employee.MaxUserId,
PlaceholderText = "ID пользователя в диалоге с ботом"
};
var content = new StackPanel { Spacing = 12 };
content.Children.Add(new TextBlock
{
Text = "Сообщения отправляются только в личный диалог этого сотрудника. Перед привязкой сотрудник должен открыть соответствующего бота и начать диалог.",
TextWrapping = TextWrapping.Wrap
});
content.Children.Add(telegramBox);
content.Children.Add(vkBox);
content.Children.Add(maxBox);
var dialog = new ContentDialog
{
XamlRoot = XamlRoot,
Title = $"Личные уведомления: {employee.DisplayName}",
Content = content,
PrimaryButtonText = "Сохранить",
CloseButtonText = "Отмена",
DefaultButton = ContentDialogButton.Primary
};
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
employee.TelegramChatId = telegramBox.Text.Trim();
employee.VkPeerId = vkBox.Text.Trim();
employee.MaxUserId = maxBox.Text.Trim();
await _store.SaveAsync(_state, createRecoveryPoint: true);
RefreshLists();
EmployeesList.SelectedItem = employee;
SetStatus($"Личные уведомления сотрудника «{employee.DisplayName}» сохранены: {employee.NotificationStatus}.", InfoBarSeverity.Success);
}
private async void ToggleEmployeeBlocked_Click(object sender, RoutedEventArgs e)
{
if (!await EnsureAdminSessionAsync("блокировка сотрудника")) return;
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
{
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
return;
}
if (!employee.IsBlocked && employee.IsAdministrator &&
_state.Employees.Count(item => item.IsAdministrator && !item.IsBlocked) <= 1)
{
SetStatus("Нельзя заблокировать единственного активного администратора.", InfoBarSeverity.Warning);
return;
}
if (!employee.IsBlocked)
{
var dialog = new ContentDialog
{
XamlRoot = XamlRoot,
Title = $"Заблокировать {employee.DisplayName}?",
Content = "Отпечатки перестанут создавать отметки. Привязки Telegram, ВКонтакте и MAX будут удалены, новые сообщения отправляться не будут. История диалога в самом мессенджере останется у пользователя.",
PrimaryButtonText = "Заблокировать",
CloseButtonText = "Отмена",
DefaultButton = ContentDialogButton.Close
};
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
employee.IsBlocked = true;
employee.BlockedAtUtc = DateTimeOffset.UtcNow;
employee.TelegramChatId = "";
employee.VkPeerId = "";
employee.MaxUserId = "";
}
else
{
employee.IsBlocked = false;
employee.BlockedAtUtc = null;
}
await _store.SaveAsync(_state, createRecoveryPoint: true);
RefreshLists();
EmployeesList.SelectedItem = employee;
ConnectScanner();
SetStatus(employee.IsBlocked
? $"Сотрудник «{employee.DisplayName}» заблокирован, личные каналы отвязаны."
: $"Сотрудник «{employee.DisplayName}» разблокирован. Личные уведомления нужно подключить заново.",
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 && !employee.IsBlocked && !PinConfigured &&
_state.Employees.Count(item => item.IsAdministrator && !item.IsBlocked && 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 (AdministratorEmployeeConfigured && !await AuthorizeAdminAsync("изменение прав администратора")) return;
if (employee.IsAdministrator && !employee.IsBlocked &&
_state.Employees.Count(item => item.IsAdministrator && !item.IsBlocked) <= 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 && !employee.IsBlocked &&
_state.Employees.Count(item => item.IsAdministrator && !item.IsBlocked) <= 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 (employee.IsBlocked)
{
if (_applicationLocked) LockScreenMessage.Text = "Этот сотрудник заблокирован.";
else SetStatus("Сотрудник заблокирован. Отметка не создана.", InfoBarSeverity.Warning);
return;
}
if (_applicationLocked)
{
if (!employee.IsAdministrator)
{
await RecordAttendanceAsync(employee, score);
LockScreenMessage.Text = "Отметка прихода или ухода зафиксирована. Интерфейс остаётся заблокированным.";
return;
}
_attendanceSuppressedFingerprintId = fingerprintId;
_attendanceSuppressedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(3);
UnlockApplication($"employee:{employee.Id}", $"Вход выполнен по отпечатку администратора {employee.DisplayName}.");
return;
}
if (_adminAuthCompletion is not null)
{
if (employee.IsAdministrator)
{
// Some SDK/reader combinations can report the same touch more
// than once while the authorization dialog is closing. Keep the
// follow-up report out of attendance processing.
_attendanceSuppressedFingerprintId = fingerprintId;
_attendanceSuppressedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(3);
_lastAdminActor = $"employee:{employee.Id}";
TerminalMessage.Text = $"Администратор {TerminalDisplayName(employee)} подтверждён";
_adminAuthCompletion.TrySetResult(true);
_adminAuthDialog?.Hide();
}
else
{
SetStatus("Этот отпечаток не принадлежит администратору. Отметка прихода или ухода не создана.", InfoBarSeverity.Warning);
}
return;
}
if (_attendanceSuppressedFingerprintId == fingerprintId &&
DateTimeOffset.UtcNow < _attendanceSuppressedUntilUtc)
{
return;
}
_attendanceSuppressedFingerprintId = null;
_attendanceSuppressedUntilUtc = default;
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, 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 Task RunAutomaticCheckoutIfDueAsync()
{
if (!_loaded || _stateLoadFailed || _automaticCheckoutInProgress ||
!_state.Settings.AutomaticCheckoutEnabled)
{
return;
}
var nowUtc = DateTimeOffset.UtcNow;
var hour = Math.Clamp(_state.Settings.AutomaticCheckoutHour, 0, 23);
var minute = Math.Clamp(_state.Settings.AutomaticCheckoutMinute, 0, 59);
var latestScheduleUtc = LatestAutomaticCheckoutScheduleUtc(nowUtc, hour, minute);
if (_state.Settings.LastAutomaticCheckoutUtc >= latestScheduleUtc) return;
_automaticCheckoutInProgress = true;
try
{
var automaticRecords = new List<(AttendanceRecord Record, EmployeeRecord Employee)>();
foreach (var employee in _state.Employees)
{
var last = _state.Attendance
.Where(item => item.EmployeeId == employee.Id)
.OrderByDescending(item => item.OccurredAtUtc)
.FirstOrDefault();
if (last is null || last.EventType != "Приход") continue;
var checkoutUtc = NextAutomaticCheckoutAfter(last.OccurredAtUtc, hour, minute);
if (checkoutUtc > nowUtc) continue;
automaticRecords.Add((new AttendanceRecord
{
EmployeeId = employee.Id,
EmployeeName = employee.DisplayName,
OccurredAtUtc = checkoutUtc,
ReaderIndex = -1,
EventType = "Уход автоматически"
}, employee));
}
_state.Attendance.InsertRange(0, automaticRecords.Select(item => item.Record));
if (_state.Attendance.Count > 100_000)
{
_state.Attendance.RemoveRange(100_000, _state.Attendance.Count - 100_000);
}
_state.Settings.LastAutomaticCheckoutUtc = latestScheduleUtc;
AppendSecurityAudit(
"attendance.automatic_checkout",
"succeeded",
$"scheduled={latestScheduleUtc:O}; records={automaticRecords.Count}");
await _store.SaveAsync(_state);
RefreshLists();
if (automaticRecords.Count == 0) return;
TerminalMessage.Text = $"Автоматический уход\nЗакрыто смен: {automaticRecords.Count}";
SetStatus($"Автоматически закрыто смен: {automaticRecords.Count}.", InfoBarSeverity.Informational);
await Task.WhenAll(automaticRecords.Select(async item =>
{
var delivery = await _notifications.SendAttendanceAsync(
_state.Settings,
item.Employee,
item.Record.EventType,
item.Record.OccurredAtUtc.ToLocalTime());
item.Record.NotificationDelivered = delivery.Delivered;
item.Record.NotificationError = delivery.Error;
}));
await _store.SaveAsync(_state);
RefreshLists();
}
catch (Exception exception)
{
CrashLogService.Write(exception);
SetStatus($"Автоматический уход не выполнен: {exception.Message}", InfoBarSeverity.Warning);
}
finally
{
_automaticCheckoutInProgress = false;
}
}
private static DateTimeOffset LatestAutomaticCheckoutScheduleUtc(DateTimeOffset nowUtc, int hour, int minute)
{
var localNow = nowUtc.ToLocalTime();
var localSchedule = localNow.Date.AddHours(hour).AddMinutes(minute);
if (localSchedule > localNow.DateTime) localSchedule = localSchedule.AddDays(-1);
return LocalScheduleToUtc(localSchedule);
}
private static DateTimeOffset NextAutomaticCheckoutAfter(DateTimeOffset arrivalUtc, int hour, int minute)
{
var localArrival = arrivalUtc.ToLocalTime();
var localSchedule = localArrival.Date.AddHours(hour).AddMinutes(minute);
if (localSchedule <= localArrival.DateTime) localSchedule = localSchedule.AddDays(1);
return LocalScheduleToUtc(localSchedule);
}
private static DateTimeOffset LocalScheduleToUtc(DateTime localSchedule)
{
localSchedule = DateTime.SpecifyKind(localSchedule, DateTimeKind.Unspecified);
if (TimeZoneInfo.Local.IsInvalidTime(localSchedule)) localSchedule = localSchedule.AddHours(1);
return new DateTimeOffset(TimeZoneInfo.ConvertTimeToUtc(localSchedule, TimeZoneInfo.Local));
}
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<bool> 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.IsBlocked && employee.HasFingerprint);
private bool AdministratorEmployeeConfigured =>
_state.Employees.Any(employee => employee.IsAdministrator && !employee.IsBlocked);
private bool SecurityConfigured =>
PinConfigured || AdministratorFingerprintConfigured;
private async Task<bool> 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<bool>(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;
// PasswordBox is a WinUI object and must only be accessed from the UI
// thread. Capture its value before performing PBKDF2 verification.
var enteredPin = pinBox.Password;
string verifiedPin = enteredPin;
if (dialogResult == ContentDialogResult.Primary && !pinBlocked && !requireIdentifiedActor)
{
pinAuthorized = await Task.Run(() =>
AdminPinService.Verify(
enteredPin,
_state.Settings.AdminPinSalt,
_state.Settings.AdminPinHash,
_state.Settings.AdminPinIterations) ||
(!string.Equals(enteredPin, enteredPin.Trim(), StringComparison.Ordinal) &&
AdminPinService.Verify(
enteredPin.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 (AdministratorEmployeeConfigured && PinConfigured && !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)
{
// The settings tab itself is protected by the active admin session.
// Saving must not start a second fingerprint authorization flow.
if (!HasActiveAdminSession)
{
LockAdminSession("Сеанс администратора завершён. Войдите снова, чтобы сохранить настройки.");
return;
}
TouchAdminSession();
CopySettingsFromControls();
await _store.SaveAsync(_state, createRecoveryPoint: true);
ApplyStartupSetting();
ConnectScanner();
await RunAutomaticCheckoutIfDueAsync();
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<double>(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;
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
{
SetStatus("Выберите сотрудника в разделе «Сотрудники», затем повторите тест.", InfoBarSeverity.Warning);
return;
}
CopySettingsFromControls();
var result = await _notifications.SendTestAsync(_state.Settings, employee);
SetStatus(result.Delivered
? $"Тестовое сообщение отправлено лично сотруднику «{employee.DisplayName}»."
: result.Error ?? "У сотрудника не указан личный ID или канал выключен.",
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.AutomaticCheckoutEnabled = AutomaticCheckoutSwitch.IsOn;
_state.Settings.AutomaticCheckoutHour = Math.Clamp(AutomaticCheckoutTimePicker.Time.Hours, 0, 23);
_state.Settings.AutomaticCheckoutMinute = Math.Clamp(AutomaticCheckoutTimePicker.Time.Minutes, 0, 59);
_state.Settings.ShowEmployeeNamesOnTerminal = ShowEmployeeNamesSwitch.IsOn;
_state.Settings.TelegramEnabled = TelegramEnabledSwitch.IsOn;
_state.Settings.TelegramBotToken = TelegramTokenBox.Password.Trim();
_state.Settings.VkEnabled = VkEnabledSwitch.IsOn;
_state.Settings.VkGroupToken = VkTokenBox.Password.Trim();
_state.Settings.MaxEnabled = MaxEnabledSwitch.IsOn;
_state.Settings.MaxBotToken = MaxTokenBox.Password.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);
AutomaticCheckoutSwitch.IsOn = _state.Settings.AutomaticCheckoutEnabled;
AutomaticCheckoutTimePicker.Time = new TimeSpan(
Math.Clamp(_state.Settings.AutomaticCheckoutHour, 0, 23),
Math.Clamp(_state.Settings.AutomaticCheckoutMinute, 0, 59),
0);
ShowEmployeeNamesSwitch.IsOn = _state.Settings.ShowEmployeeNamesOnTerminal;
TelegramEnabledSwitch.IsOn = _state.Settings.TelegramEnabled;
TelegramTokenBox.Password = _state.Settings.TelegramBotToken;
VkEnabledSwitch.IsOn = _state.Settings.VkEnabled;
VkTokenBox.Password = _state.Settings.VkGroupToken;
MaxEnabledSwitch.IsOn = _state.Settings.MaxEnabled;
MaxTokenBox.Password = _state.Settings.MaxBotToken;
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<Task> 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);
}
}
}