feat(terminal): update attendance terminal to 0.2.4
Some checks are pending
ci / verify (push) Waiting to run

This commit is contained in:
Ochenstarik 2026-09-20 23:40:43 +07:00
parent 827d321a1a
commit 4d37350757
13 changed files with 494 additions and 74 deletions

View file

@ -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

View file

@ -17,10 +17,10 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PlatformTarget>x64</PlatformTarget>
<Version>0.2.0</Version>
<AssemblyVersion>0.2.0.0</AssemblyVersion>
<FileVersion>0.2.0.0</FileVersion>
<InformationalVersion>0.2.0</InformationalVersion>
<Version>0.2.4</Version>
<AssemblyVersion>0.2.4.0</AssemblyVersion>
<FileVersion>0.2.4.0</FileVersion>
<InformationalVersion>0.2.4</InformationalVersion>
</PropertyGroup>
<ItemGroup>

View file

@ -10,9 +10,11 @@
Unloaded="Page_Unloaded"
mc:Ignorable="d">
<Grid Padding="24,16,24,24"
RowSpacing="12"
Background="{ThemeResource KronaAppBackgroundBrush}">
<Grid Background="{ThemeResource KronaAppBackgroundBrush}">
<Grid x:Name="ProtectedContent"
Visibility="Collapsed"
Padding="24,16,24,24"
RowSpacing="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
@ -149,6 +151,8 @@
</AppBarButton.Icon>
</AppBarButton>
<AppBarButton Icon="Permissions" Label="Права администратора" Click="ToggleAdministrator_Click"/>
<AppBarButton Icon="Message" Label="Личные уведомления" Click="EditEmployeeNotifications_Click"/>
<AppBarButton Icon="BlockContact" Label="Блок./разблок." Click="ToggleEmployeeBlocked_Click"/>
<AppBarButton Icon="Delete" Label="Удалить" Click="DeleteEmployee_Click"/>
<AppBarSeparator />
<AppBarButton Label="Заблокировать" Click="LogoutAdmin_Click">
@ -204,7 +208,20 @@
<ScrollViewer>
<StackPanel Padding="4,16,20,24" Spacing="14" MaxWidth="720" HorizontalAlignment="Left">
<TextBlock Text="Сканер" Foreground="{ThemeResource KronaTextPrimaryBrush}" Style="{StaticResource SubtitleTextBlockStyle}"/>
<ComboBox x:Name="ReaderCombo" Header="Используемое устройство" MinWidth="320"/>
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<ComboBox x:Name="ReaderCombo"
Header="Используемое устройство"
MinWidth="320"
SelectionChanged="ReaderCombo_SelectionChanged"/>
<Button Grid.Column="1"
Content="Найти сканеры"
VerticalAlignment="Bottom"
Click="ScanReaders_Click"/>
</Grid>
<ToggleSwitch x:Name="StartWithWindowsSwitch" Header="Автозагрузка" OffContent="Выключена" OnContent="Запускать вместе с Windows"/>
<NumberBox x:Name="DuplicateGuardBox" Header="Защита от повторного скана, секунд" Minimum="10" Maximum="3600" SpinButtonPlacementMode="Compact"/>
<NumberBox x:Name="MaximumShiftHoursBox" Header="Максимальная длительность смены, часов" Minimum="1" Maximum="48" SpinButtonPlacementMode="Compact"/>
@ -214,11 +231,15 @@
<Border Height="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}"/>
<ToggleSwitch x:Name="TelegramEnabledSwitch" Header="Telegram" OffContent="Выключен" OnContent="Включён"/>
<PasswordBox x:Name="TelegramTokenBox" Header="Токен Telegram-бота" PasswordRevealMode="Peek"/>
<TextBox x:Name="TelegramChatBox" Header="Chat ID Telegram"/>
<TextBlock Text="Личный chat_id задаётся в карточке каждого сотрудника." TextWrapping="Wrap" Opacity="0.7"/>
<Border Height="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}"/>
<ToggleSwitch x:Name="VkEnabledSwitch" Header="ВКонтакте" OffContent="Выключен" OnContent="Включён"/>
<PasswordBox x:Name="VkTokenBox" Header="Токен сообщества ВКонтакте" PasswordRevealMode="Peek"/>
<TextBlock Text="Личный peer_id задаётся в карточке сотрудника. Сотрудник должен разрешить сообщения сообщества." TextWrapping="Wrap" Opacity="0.7"/>
<Border Height="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}"/>
<ToggleSwitch x:Name="MaxEnabledSwitch" Header="MAX" OffContent="Выключен" OnContent="Включён"/>
<PasswordBox x:Name="MaxTokenBox" Header="Токен MAX-бота" PasswordRevealMode="Peek"/>
<TextBox x:Name="MaxChatBox" Header="Chat ID MAX"/>
<TextBlock Text="Личный user_id задаётся в карточке каждого сотрудника." TextWrapping="Wrap" Opacity="0.7"/>
<Border Height="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}"/>
<TextBlock Text="Обновления программы" Foreground="{ThemeResource KronaTextPrimaryBrush}" Style="{StaticResource SubtitleTextBlockStyle}"/>
<TextBlock x:Name="CurrentVersionText" Opacity="0.75"/>
@ -244,7 +265,7 @@
Background="{ThemeResource KronaPrimaryBrush}"
Foreground="{ThemeResource KronaTextInverseBrush}"
Click="SaveSettings_Click"/>
<Button Content="Отправить тест" Click="TestNotification_Click"/>
<Button Content="Тест выбранному сотруднику" Click="TestNotification_Click"/>
<Button Content="Заблокировать" Click="LogoutAdmin_Click"/>
</StackPanel>
<TextBlock Text="Токены, сотрудники и шаблоны отпечатков хранятся в зашифрованном хранилище текущей учётной записи Windows." TextWrapping="Wrap" Opacity="0.7"/>
@ -253,4 +274,43 @@
</TabViewItem>
</TabView>
</Grid>
<Grid x:Name="ApplicationLockScreen"
Padding="32"
Background="{ThemeResource KronaAppBackgroundBrush}">
<Border Width="460"
Padding="40"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Background="{ThemeResource KronaSurfaceBrush}"
BorderBrush="{ThemeResource KronaBorderBrush}"
BorderThickness="1"
CornerRadius="{StaticResource KronaRadiusLarge}">
<StackPanel Spacing="18">
<Image Width="120" Height="120" Source="Assets/TrushenkoLogo.png" Stretch="Uniform"/>
<TextBlock Text="Krona Staff заблокирован"
Style="{StaticResource TitleTextBlockStyle}"
TextAlignment="Center"/>
<TextBlock x:Name="LockScreenMessage"
Text="Введите PIN администратора или приложите зарегистрированный палец."
TextWrapping="Wrap"
TextAlignment="Center"
Opacity="0.75"/>
<PasswordBox x:Name="UnlockPinBox"
Header="PIN администратора"
PasswordRevealMode="Peek"
KeyDown="UnlockPinBox_KeyDown"/>
<Button Content="Войти"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Background="{ThemeResource KronaPrimaryBrush}"
Foreground="{ThemeResource KronaTextInverseBrush}"
Click="UnlockApplication_Click"/>
<TextBlock Text="До входа сотрудники, журнал и настройки скрыты."
TextAlignment="Center"
Opacity="0.6"/>
</StackPanel>
</Border>
</Grid>
</Grid>
</Page>

View file

@ -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;

View file

@ -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<FingerprintRecord> 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<string>();
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; } = "";

View file

@ -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. Сотрудник должен предварительно начать личный диалог с ботом или разрешить сообщения сообщества. Кнопка «Тест выбранному сотруднику» отправляет сообщение только этому человеку. При успешной отметке сотрудник получает дату, время и тип собственного события без имени и данных других сотрудников.
При блокировке сотрудника его личные идентификаторы удаляются из зашифрованного хранилища, отпечатки перестают создавать отметки, а отправка прекращается. После разблокировки каналы необходимо привязать заново. Удалить весь диалог из приложения мессенджера на устройстве сотрудника бот не может; уже доставленная история остаётся под управлением самого пользователя и соответствующего мессенджера.
## Обновления

View file

@ -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();

View file

@ -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<string>();
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<string, string>
{
["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);
}

View file

@ -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"

View file

@ -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": [],

4
package-lock.json generated
View file

@ -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",

View file

@ -1,6 +1,6 @@
{
"name": "@krona/staff",
"version": "0.2.0",
"version": "0.2.4",
"private": true,
"type": "module",
"engines": {

View file

@ -1,2 +1,2 @@
export const SERVICE_NAME = 'krona-staff';
export const SERVICE_VERSION = '0.2.0';
export const SERVICE_VERSION = '0.2.4';