diff --git a/api/openapi.yaml b/api/openapi.yaml
index 3db59ee..33b8fea 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: Krona Staff API
- version: 0.2.0
+ version: 0.2.4
description: Operational API for the Krona Staff foundation. HR domain endpoints are intentionally absent until owner requirements are approved.
license:
name: Proprietary
diff --git a/apps/KronaStaffTerminal/KronaStaffTerminal.csproj b/apps/KronaStaffTerminal/KronaStaffTerminal.csproj
index 9fa57db..35d9cbb 100644
--- a/apps/KronaStaffTerminal/KronaStaffTerminal.csproj
+++ b/apps/KronaStaffTerminal/KronaStaffTerminal.csproj
@@ -17,10 +17,10 @@
enable
enable
x64
- 0.2.0
- 0.2.0.0
- 0.2.0.0
- 0.2.0
+ 0.2.4
+ 0.2.4.0
+ 0.2.4.0
+ 0.2.4
diff --git a/apps/KronaStaffTerminal/MainPage.xaml b/apps/KronaStaffTerminal/MainPage.xaml
index 827b1d9..c0457d6 100644
--- a/apps/KronaStaffTerminal/MainPage.xaml
+++ b/apps/KronaStaffTerminal/MainPage.xaml
@@ -10,9 +10,11 @@
Unloaded="Page_Unloaded"
mc:Ignorable="d">
-
+
+
@@ -149,6 +151,8 @@
+
+
@@ -204,7 +208,20 @@
-
+
+
+
+
+
+
+
+
@@ -214,11 +231,15 @@
-
+
+
+
+
+
-
+
@@ -244,7 +265,7 @@
Background="{ThemeResource KronaPrimaryBrush}"
Foreground="{ThemeResource KronaTextInverseBrush}"
Click="SaveSettings_Click"/>
-
+
@@ -253,4 +274,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/KronaStaffTerminal/MainPage.xaml.cs b/apps/KronaStaffTerminal/MainPage.xaml.cs
index 8ca53a6..576d3af 100644
--- a/apps/KronaStaffTerminal/MainPage.xaml.cs
+++ b/apps/KronaStaffTerminal/MainPage.xaml.cs
@@ -3,6 +3,7 @@ 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;
@@ -28,8 +29,12 @@ public sealed partial class MainPage : Page
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);
@@ -91,6 +96,9 @@ public sealed partial class MainPage : Page
ConnectScanner();
_updateTimer.Start();
MainTabs.SelectedItem = TerminalTab;
+ LockScreenMessage.Text = SecurityConfigured
+ ? "Введите PIN администратора или приложите зарегистрированный палец."
+ : "Первый запуск: нажмите «Войти», чтобы создать PIN администратора.";
if (_store.RecoveryNotice is not null)
{
SetStatus(_store.RecoveryNotice, InfoBarSeverity.Warning);
@@ -148,10 +156,88 @@ public sealed partial class MainPage : Page
}
private void LogoutAdmin_Click(object sender, RoutedEventArgs e) =>
- LockAdminSession("Административный раздел заблокирован.");
+ LockApplication("Программа заблокирована.");
public void LockAdminSession() =>
- 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)
{
@@ -195,9 +281,9 @@ public sealed partial class MainPage : Page
_adminSessionActive = true;
TouchAdminSession();
- SetStatus(AdministratorFingerprintConfigured
+ SetStatus(AdministratorEmployeeConfigured
? "Вход администратора выполнен. Сеанс закроется через 5 минут бездействия."
- : "Первичная настройка открыта. Добавьте администратора и зарегистрируйте его отпечаток.",
+ : "Первичная настройка открыта. Добавьте первого сотрудника-администратора. Сканер можно подключить позже.",
InfoBarSeverity.Success);
return true;
}
@@ -209,7 +295,7 @@ public sealed partial class MainPage : Page
var content = new StackPanel { Spacing = 12, MinWidth = 320 };
content.Children.Add(new TextBlock
{
- Text = "Первичная настройка. После входа добавьте сотрудника-администратора и зарегистрируйте его отпечаток.",
+ Text = "Первичная настройка. PIN работает без сканера. После входа добавьте первого сотрудника-администратора; отпечаток можно зарегистрировать позже.",
TextWrapping = TextWrapping.Wrap
});
content.Children.Add(pinBox);
@@ -230,7 +316,10 @@ public sealed partial class MainPage : Page
return false;
}
- var pin = await Task.Run(() => AdminPinService.Hash(pinBox.Password));
+ // 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;
@@ -244,15 +333,23 @@ public sealed partial class MainPage : Page
try
{
_scanner.Connect(_state.Settings.ReaderIndex, _state.Employees
- .Where(employee => employee.HasFingerprint)
+ .Where(employee => employee.HasFingerprint && !employee.IsBlocked)
.SelectMany(employee => employee.Fingerprints)
.Select(fingerprint => (fingerprint.Id, fingerprint.Template)));
- ReaderCombo.Items.Clear();
- for (var index = 0; index < _scanner.DeviceCount; index++)
+ _refreshingReaders = true;
+ try
{
- ReaderCombo.Items.Add($"SLK20R №{index + 1}");
+ ReaderCombo.Items.Clear();
+ for (var index = 0; index < _scanner.DeviceCount; index++)
+ {
+ ReaderCombo.Items.Add($"SLK20R №{index + 1}");
+ }
+ ReaderCombo.SelectedIndex = _scanner.ReaderIndex;
+ }
+ finally
+ {
+ _refreshingReaders = false;
}
- ReaderCombo.SelectedIndex = _scanner.ReaderIndex;
if (_state.Settings.ReaderIndex != _scanner.ReaderIndex)
{
_state.Settings.ReaderIndex = _scanner.ReaderIndex;
@@ -271,10 +368,31 @@ public sealed partial class MainPage : Page
}
catch (Exception exception)
{
- SetStatus(exception.Message, InfoBarSeverity.Error);
+ _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;
@@ -290,7 +408,7 @@ public sealed partial class MainPage : Page
SetStatus("Сотрудник с таким табельным номером уже есть.", InfoBarSeverity.Warning);
return;
}
- if (AdministratorCheckBox.IsChecked == true && SecurityConfigured &&
+ if (AdministratorCheckBox.IsChecked == true && AdministratorEmployeeConfigured &&
!await AuthorizeAdminAsync("назначение нового администратора"))
{
return;
@@ -318,7 +436,10 @@ public sealed partial class MainPage : Page
AdministratorCheckBox.IsChecked = false;
RefreshLists();
EmployeesList.SelectedItem = employee;
- SetStatus($"{name} добавлен. Теперь зарегистрируйте отпечаток.", InfoBarSeverity.Success);
+ SetStatus(employee.IsAdministrator
+ ? $"{name} добавлен как администратор. Отпечаток можно зарегистрировать после подключения сканера."
+ : $"{name} добавлен. Отпечаток можно зарегистрировать позже.",
+ InfoBarSeverity.Success);
}
private async void EditEmployee_Click(object sender, RoutedEventArgs e)
@@ -406,6 +527,120 @@ public sealed partial class MainPage : Page
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;
@@ -484,8 +719,8 @@ public sealed partial class MainPage : Page
SetStatus("У выбранного сотрудника нет зарегистрированных отпечатков.", InfoBarSeverity.Informational);
return;
}
- if (employee.IsAdministrator && !PinConfigured &&
- _state.Employees.Count(item => item.IsAdministrator && item.HasFingerprint) <= 1)
+ if (employee.IsAdministrator && !employee.IsBlocked && !PinConfigured &&
+ _state.Employees.Count(item => item.IsAdministrator && !item.IsBlocked && item.HasFingerprint) <= 1)
{
SetStatus("Нельзя удалить последний способ входа администратора.", InfoBarSeverity.Warning);
return;
@@ -518,8 +753,9 @@ public sealed partial class MainPage : Page
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
return;
}
- if (SecurityConfigured && !await AuthorizeAdminAsync("изменение прав администратора")) return;
- if (employee.IsAdministrator && _state.Employees.Count(item => item.IsAdministrator) <= 1)
+ if (AdministratorEmployeeConfigured && !await AuthorizeAdminAsync("изменение прав администратора")) return;
+ if (employee.IsAdministrator && !employee.IsBlocked &&
+ _state.Employees.Count(item => item.IsAdministrator && !item.IsBlocked) <= 1)
{
SetStatus("Нельзя снять права с единственного администратора с зарегистрированным отпечатком.", InfoBarSeverity.Warning);
return;
@@ -542,7 +778,7 @@ public sealed partial class MainPage : Page
{
XamlRoot = XamlRoot,
Title = "Удалить сотрудника и отпечаток?",
- Content = $"{employee.DisplayName}, его отпечаток и право доступа будут удалены из терминала.",
+ Content = $"{employee.DisplayName}, его отпечатки, право доступа и привязки личных уведомлений будут удалены из терминала. История диалога в самом мессенджере останется у пользователя.",
PrimaryButtonText = "Продолжить",
CloseButtonText = "Отмена",
DefaultButton = ContentDialogButton.Close
@@ -554,7 +790,8 @@ public sealed partial class MainPage : Page
return;
}
if (!await AuthorizeAdminAsync("удаление сотрудника и отпечатка")) return;
- if (employee.IsAdministrator && _state.Employees.Count(item => item.IsAdministrator) <= 1)
+ if (employee.IsAdministrator && !employee.IsBlocked &&
+ _state.Employees.Count(item => item.IsAdministrator && !item.IsBlocked) <= 1)
{
SetStatus("Нельзя удалить единственного администратора.", InfoBarSeverity.Warning);
return;
@@ -572,10 +809,34 @@ public sealed partial class MainPage : Page
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);
@@ -583,10 +844,18 @@ public sealed partial class MainPage : Page
}
else
{
- await RecordAttendanceAsync(employee, score);
+ SetStatus("Этот отпечаток не принадлежит администратору. Отметка прихода или ухода не создана.", InfoBarSeverity.Warning);
}
return;
}
+
+ if (_attendanceSuppressedFingerprintId == fingerprintId &&
+ DateTimeOffset.UtcNow < _attendanceSuppressedUntilUtc)
+ {
+ return;
+ }
+ _attendanceSuppressedFingerprintId = null;
+ _attendanceSuppressedUntilUtc = default;
await RecordAttendanceAsync(employee, score);
}
@@ -628,7 +897,7 @@ public sealed partial class MainPage : Page
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());
+ var delivery = await _notifications.SendAttendanceAsync(_state.Settings, employee, eventType, now.ToLocalTime());
record.NotificationDelivered = delivery.Delivered;
record.NotificationError = delivery.Error;
await _store.SaveAsync(_state);
@@ -736,7 +1005,10 @@ public sealed partial class MainPage : Page
!string.IsNullOrWhiteSpace(_state.Settings.AdminPinHash);
private bool AdministratorFingerprintConfigured =>
- _state.Employees.Any(employee => employee.IsAdministrator && employee.HasFingerprint);
+ _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;
@@ -793,18 +1065,21 @@ public sealed partial class MainPage : Page
_adminAuthCompletion.Task.Result;
_adminAuthCompletion.TrySetResult(false);
var pinAuthorized = false;
- string verifiedPin = pinBox.Password;
+ // 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(
- pinBox.Password,
+ enteredPin,
_state.Settings.AdminPinSalt,
_state.Settings.AdminPinHash,
_state.Settings.AdminPinIterations) ||
- (!string.Equals(pinBox.Password, pinBox.Password.Trim(), StringComparison.Ordinal) &&
+ (!string.Equals(enteredPin, enteredPin.Trim(), StringComparison.Ordinal) &&
AdminPinService.Verify(
- pinBox.Password.Trim(),
+ enteredPin.Trim(),
_state.Settings.AdminPinSalt,
_state.Settings.AdminPinHash,
_state.Settings.AdminPinIterations)));
@@ -858,7 +1133,7 @@ public sealed partial class MainPage : Page
SetStatus("PIN должен содержать не менее 6 символов.", InfoBarSeverity.Warning);
return;
}
- if (!string.IsNullOrWhiteSpace(_state.Settings.AdminPinHash) && !await AuthorizeAdminAsync("изменение PIN")) 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;
@@ -870,7 +1145,14 @@ public sealed partial class MainPage : Page
private async void SaveSettings_Click(object sender, RoutedEventArgs e)
{
- if (!await EnsureAdminSessionAsync("изменение настроек")) return;
+ // 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();
@@ -992,9 +1274,17 @@ public sealed partial class MainPage : Page
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);
- SetStatus(result.Delivered ? "Тестовое сообщение отправлено." : result.Error ?? "Включите Telegram или MAX и заполните реквизиты.", result.Delivered ? InfoBarSeverity.Success : InfoBarSeverity.Warning);
+ 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()
@@ -1006,10 +1296,10 @@ public sealed partial class MainPage : Page
_state.Settings.ShowEmployeeNamesOnTerminal = ShowEmployeeNamesSwitch.IsOn;
_state.Settings.TelegramEnabled = TelegramEnabledSwitch.IsOn;
_state.Settings.TelegramBotToken = TelegramTokenBox.Password.Trim();
- _state.Settings.TelegramChatId = TelegramChatBox.Text.Trim();
+ _state.Settings.VkEnabled = VkEnabledSwitch.IsOn;
+ _state.Settings.VkGroupToken = VkTokenBox.Password.Trim();
_state.Settings.MaxEnabled = MaxEnabledSwitch.IsOn;
_state.Settings.MaxBotToken = MaxTokenBox.Password.Trim();
- _state.Settings.MaxChatId = MaxChatBox.Text.Trim();
CopyUpdateSettingsFromControls();
}
@@ -1028,10 +1318,10 @@ public sealed partial class MainPage : Page
ShowEmployeeNamesSwitch.IsOn = _state.Settings.ShowEmployeeNamesOnTerminal;
TelegramEnabledSwitch.IsOn = _state.Settings.TelegramEnabled;
TelegramTokenBox.Password = _state.Settings.TelegramBotToken;
- TelegramChatBox.Text = _state.Settings.TelegramChatId;
+ VkEnabledSwitch.IsOn = _state.Settings.VkEnabled;
+ VkTokenBox.Password = _state.Settings.VkGroupToken;
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;
diff --git a/apps/KronaStaffTerminal/Models/TerminalModels.cs b/apps/KronaStaffTerminal/Models/TerminalModels.cs
index 8f6cff8..cbf2333 100644
--- a/apps/KronaStaffTerminal/Models/TerminalModels.cs
+++ b/apps/KronaStaffTerminal/Models/TerminalModels.cs
@@ -10,7 +10,12 @@ public sealed class EmployeeRecord
public string Position { get; set; } = "";
public string Gender { get; set; } = "";
public bool IsAdministrator { get; set; }
+ public bool IsBlocked { get; set; }
+ public DateTimeOffset? BlockedAtUtc { get; set; }
public List Fingerprints { get; set; } = [];
+ public string TelegramChatId { get; set; } = "";
+ public string VkPeerId { get; set; } = "";
+ public string MaxUserId { get; set; } = "";
// Legacy single-fingerprint fields are kept for one-way migration of existing encrypted state.
public int FingerprintId { get; set; }
@@ -29,7 +34,21 @@ public sealed class EmployeeRecord
};
[JsonIgnore]
- public string AccessStatus => IsAdministrator ? "Администратор" : "Сотрудник";
+ public string AccessStatus => IsBlocked ? "Заблокирован" : IsAdministrator ? "Администратор" : "Сотрудник";
+
+ [JsonIgnore]
+ public string NotificationStatus
+ {
+ get
+ {
+ if (IsBlocked) return "Отключены";
+ var channels = new List();
+ if (!string.IsNullOrWhiteSpace(TelegramChatId)) channels.Add("Telegram");
+ if (!string.IsNullOrWhiteSpace(VkPeerId)) channels.Add("VK");
+ if (!string.IsNullOrWhiteSpace(MaxUserId)) channels.Add("MAX");
+ return channels.Count == 0 ? "Не подключены" : string.Join(", ", channels);
+ }
+ }
[JsonIgnore]
public string GenderDisplay => string.IsNullOrWhiteSpace(Gender) ? "—" : Gender;
@@ -74,7 +93,10 @@ public sealed class TerminalSettings
public bool ShowEmployeeNamesOnTerminal { get; set; }
public bool TelegramEnabled { get; set; }
public string TelegramBotToken { get; set; } = "";
+ // Legacy global chat IDs are retained only so existing encrypted state remains readable.
public string TelegramChatId { get; set; } = "";
+ public bool VkEnabled { get; set; }
+ public string VkGroupToken { get; set; } = "";
public bool MaxEnabled { get; set; }
public string MaxBotToken { get; set; } = "";
public string MaxChatId { get; set; } = "";
diff --git a/apps/KronaStaffTerminal/README.md b/apps/KronaStaffTerminal/README.md
index 361521b..5fdff84 100644
--- a/apps/KronaStaffTerminal/README.md
+++ b/apps/KronaStaffTerminal/README.md
@@ -4,7 +4,8 @@
## Возможности
-- выбор одного из подключённых сканеров с автоматическим переходом на рабочее устройство;
+- повторный поиск сканеров по кнопке, ручной выбор одного из подключённых устройств и автоматический переход на рабочее устройство;
+- первичная настройка PIN и добавление первого сотрудника-администратора без подключённого сканера;
- добавление и редактирование сотрудников с должностью, полом и правами администратора без потери отпечатков и журнала;
- несколько отпечатков у одного сотрудника, каждый регистрируется по трём последовательным сканам;
- чередование событий «Приход» и «Уход»;
@@ -13,10 +14,12 @@
- экспорт журнала в CSV и резервные копии зашифрованного состояния;
- защищённая очистка журнала без удаления сотрудников и отпечатков;
- приватный режим, скрывающий полные имена на публичном экране;
-- уведомления через Telegram Bot API и MAX Bot API;
+- личные уведомления сотрудников через Telegram, сообщения сообщества ВКонтакте и MAX;
+- блокировка сотрудника с отключением его отпечатков и безвозвратным удалением локальных привязок мессенджеров;
- автозагрузка сразу в системный трей и постоянное фоновое сканирование, даже когда открыта другая программа;
-- закрытый административный раздел с входом по PIN или отпечатку администратора;
-- автоматическая блокировка административного раздела через 5 минут и при сворачивании;
+- закрытый экран входа, скрывающий сотрудников, журнал и настройки до проверки PIN или отпечатка администратора;
+- автоматическая блокировка всей программы при запуске, сворачивании и ручной блокировке;
+- автоматическая блокировка административного раздела через 5 минут бездействия;
- шифрование сотрудников, токенов и шаблонов отпечатков средствами Windows DPAPI.
- проверка новых релизов Forgejo при запуске и каждые шесть часов;
- загрузка обновления только после входа администратора и обязательной проверки SHA-256.
@@ -55,7 +58,7 @@ ZKTeco. Уже установленный драйвер продолжает р
1. Вкладки «Сотрудники» и «Уведомления и сканер» открываются только после входа администратора.
2. Для входа достаточно PIN либо зарегистрированного отпечатка администратора.
-3. Кнопка «Заблокировать», сворачивание окна или пять минут бездействия завершают административный сеанс.
+3. Кнопка «Заблокировать» и сворачивание скрывают весь интерфейс до повторного входа. Пять минут бездействия закрывают административные вкладки, не останавливая рабочий терминал.
После первичной настройки добавление сотрудников, регистрация и удаление отпечатков, изменение прав, уведомлений и параметров сканера доступны только в административном сеансе. Крестик скрывает окно в системный трей, сканер продолжает работать. Двойной щелчок по значку возвращает окно. Автозагрузка включается администратором в настройках.
@@ -66,7 +69,9 @@ ZKTeco. Уже установленный драйвер продолжает р
## Уведомления
-На вкладке «Уведомления и сканер» включите нужный канал, укажите токен бота и идентификатор чата, сохраните настройки и нажмите «Отправить тест». При успешном распознавании сообщение содержит имя сотрудника и местное время прихода.
+На вкладке «Уведомления и сканер» включите нужные каналы и укажите токены ботов. Затем выберите сотрудника, нажмите «Личные уведомления» и сохраните его личный `chat_id` Telegram, `peer_id` ВКонтакте или `user_id` MAX. Сотрудник должен предварительно начать личный диалог с ботом или разрешить сообщения сообщества. Кнопка «Тест выбранному сотруднику» отправляет сообщение только этому человеку. При успешной отметке сотрудник получает дату, время и тип собственного события без имени и данных других сотрудников.
+
+При блокировке сотрудника его личные идентификаторы удаляются из зашифрованного хранилища, отпечатки перестают создавать отметки, а отправка прекращается. После разблокировки каналы необходимо привязать заново. Удалить весь диалог из приложения мессенджера на устройстве сотрудника бот не может; уже доставленная история остаётся под управлением самого пользователя и соответствующего мессенджера.
## Обновления
diff --git a/apps/KronaStaffTerminal/Services/FingerprintScanner.cs b/apps/KronaStaffTerminal/Services/FingerprintScanner.cs
index c14b1c6..532fe88 100644
--- a/apps/KronaStaffTerminal/Services/FingerprintScanner.cs
+++ b/apps/KronaStaffTerminal/Services/FingerprintScanner.cs
@@ -40,9 +40,10 @@ public sealed class FingerprintScanner : IDisposable
throw new InvalidOperationException($"SDK ZKTeco не инициализировано (код {result}).");
}
- // ZKFinger SDK 5.3 reports one service slot in addition to physical readers.
- // The vendor C# sample uses GetDeviceCount() - 1 for this SDK generation.
- DeviceCount = Math.Max(0, zkfp2.GetDeviceCount() - 1);
+ // GetDeviceCount returns the number of physical readers for the SLK20R
+ // driver shipped with ZKFinger SDK 5.3. Do not subtract a service slot:
+ // on a workstation with one reader that would hide the only device.
+ DeviceCount = Math.Max(0, zkfp2.GetDeviceCount());
if (DeviceCount <= 0)
{
zkfp2.Terminate();
diff --git a/apps/KronaStaffTerminal/Services/NotificationService.cs b/apps/KronaStaffTerminal/Services/NotificationService.cs
index 903e0ea..7e60cb4 100644
--- a/apps/KronaStaffTerminal/Services/NotificationService.cs
+++ b/apps/KronaStaffTerminal/Services/NotificationService.cs
@@ -9,28 +9,33 @@ public sealed class NotificationService
{
private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(15) };
- public async Task<(bool Delivered, string? Error)> SendAttendanceAsync(TerminalSettings settings, string employeeName, string eventType, DateTimeOffset localTime)
+ public async Task<(bool Delivered, string? Error)> SendAttendanceAsync(TerminalSettings settings, EmployeeRecord employee, string eventType, DateTimeOffset localTime)
{
- var action = eventType == "Уход" ? "ушёл(ушла)" : "пришёл(пришла)";
- return await SendAsync(settings, $"{employeeName} {action} в {localTime:HH:mm}");
+ var action = eventType == "Уход" ? "уход" : "приход";
+ return await SendAsync(settings, employee,
+ $"Krona Staff: ваш {action} зарегистрирован {localTime:dd.MM.yyyy} в {localTime:HH:mm}.");
}
- public async Task<(bool Delivered, string? Error)> SendTestAsync(TerminalSettings settings)
+ public async Task<(bool Delivered, string? Error)> SendTestAsync(TerminalSettings settings, EmployeeRecord employee)
{
- return await SendAsync(settings, "Krona Staff: тестовое сообщение доставлено.");
+ return await SendAsync(settings, employee, "Krona Staff: личные уведомления подключены.");
}
- private async Task<(bool Delivered, string? Error)> SendAsync(TerminalSettings settings, string text)
+ private async Task<(bool Delivered, string? Error)> SendAsync(TerminalSettings settings, EmployeeRecord employee, string text)
{
+ if (employee.IsBlocked) return (false, "Сотрудник заблокирован.");
var errors = new List();
var sent = false;
- if (settings.TelegramEnabled)
+ var telegramConfigured = settings.TelegramEnabled &&
+ !string.IsNullOrWhiteSpace(settings.TelegramBotToken) &&
+ !string.IsNullOrWhiteSpace(employee.TelegramChatId);
+ if (telegramConfigured)
{
try
{
var url = $"https://api.telegram.org/bot{settings.TelegramBotToken}/sendMessage";
- using var response = await _httpClient.PostAsJsonAsync(url, new { chat_id = settings.TelegramChatId, text });
+ using var response = await _httpClient.PostAsJsonAsync(url, new { chat_id = employee.TelegramChatId, text });
if (!response.IsSuccessStatusCode)
{
errors.Add($"Telegram: HTTP {(int)response.StatusCode}");
@@ -46,11 +51,48 @@ public sealed class NotificationService
}
}
- if (settings.MaxEnabled)
+ var vkConfigured = settings.VkEnabled &&
+ !string.IsNullOrWhiteSpace(settings.VkGroupToken) &&
+ !string.IsNullOrWhiteSpace(employee.VkPeerId);
+ if (vkConfigured)
{
try
{
- var url = $"https://platform-api.max.ru/messages?chat_id={Uri.EscapeDataString(settings.MaxChatId)}";
+ var values = new Dictionary
+ {
+ ["access_token"] = settings.VkGroupToken,
+ ["v"] = "5.199",
+ ["peer_id"] = employee.VkPeerId,
+ ["random_id"] = Random.Shared.Next(1, int.MaxValue).ToString(),
+ ["message"] = text
+ };
+ using var response = await _httpClient.PostAsync(
+ "https://api.vk.com/method/messages.send",
+ new FormUrlEncodedContent(values));
+ var responseText = await response.Content.ReadAsStringAsync();
+ if (!response.IsSuccessStatusCode || responseText.Contains("\"error\"", StringComparison.OrdinalIgnoreCase))
+ {
+ errors.Add($"VK: HTTP {(int)response.StatusCode}");
+ }
+ else
+ {
+ sent = true;
+ }
+ }
+ catch (Exception exception)
+ {
+ errors.Add($"VK: {exception.Message}");
+ }
+ }
+
+ var maxConfigured = settings.MaxEnabled &&
+ !string.IsNullOrWhiteSpace(settings.MaxBotToken) &&
+ !string.IsNullOrWhiteSpace(employee.MaxUserId);
+ if (maxConfigured)
+ {
+ try
+ {
+ var url = $"https://platform-api2.max.ru/messages?user_id={Uri.EscapeDataString(employee.MaxUserId)}";
using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.TryAddWithoutValidation("Authorization", settings.MaxBotToken);
request.Content = new StringContent(JsonSerializer.Serialize(new { text }), Encoding.UTF8, "application/json");
@@ -70,7 +112,7 @@ public sealed class NotificationService
}
}
- if (!settings.TelegramEnabled && !settings.MaxEnabled)
+ if (!telegramConfigured && !vkConfigured && !maxConfigured)
{
return (false, null);
}
diff --git a/installer/KronaStaffTerminal.nsi b/installer/KronaStaffTerminal.nsi
index b7bfa98..f48050f 100644
--- a/installer/KronaStaffTerminal.nsi
+++ b/installer/KronaStaffTerminal.nsi
@@ -4,7 +4,7 @@ SetCompressor /SOLID lzma
!include "MUI2.nsh"
!ifndef APP_VERSION
- !define APP_VERSION "0.2.0"
+ !define APP_VERSION "0.2.4"
!endif
!ifndef PUBLISH_DIR
!error "PUBLISH_DIR is required"
diff --git a/module.json b/module.json
index 6d1fc14..fd71fd4 100644
--- a/module.json
+++ b/module.json
@@ -2,7 +2,7 @@
"$schema": "./schemas/module.schema.json",
"id": "ru.krona.staff",
"name": "Krona Staff",
- "version": "0.2.0",
+ "version": "0.2.4",
"min_platform_version": "3.2.0",
"dependencies": ["ru.krona.core"],
"optional_dependencies": [],
diff --git a/package-lock.json b/package-lock.json
index f9088bb..9ec533d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@krona/staff",
- "version": "0.2.0",
+ "version": "0.2.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@krona/staff",
- "version": "0.2.0",
+ "version": "0.2.4",
"dependencies": {
"@fastify/helmet": "^13.0.2",
"@fastify/sensible": "^6.0.3",
diff --git a/package.json b/package.json
index 388f26e..a67192a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@krona/staff",
- "version": "0.2.0",
+ "version": "0.2.4",
"private": true,
"type": "module",
"engines": {
diff --git a/src/version.ts b/src/version.ts
index 743df30..a25d2a4 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1,2 +1,2 @@
export const SERVICE_NAME = 'krona-staff';
-export const SERVICE_VERSION = '0.2.0';
+export const SERVICE_VERSION = '0.2.4';