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 openapi: 3.1.0
info: info:
title: Krona Staff API 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. description: Operational API for the Krona Staff foundation. HR domain endpoints are intentionally absent until owner requirements are approved.
license: license:
name: Proprietary name: Proprietary

View file

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

View file

@ -10,9 +10,11 @@
Unloaded="Page_Unloaded" Unloaded="Page_Unloaded"
mc:Ignorable="d"> mc:Ignorable="d">
<Grid Padding="24,16,24,24" <Grid Background="{ThemeResource KronaAppBackgroundBrush}">
RowSpacing="12" <Grid x:Name="ProtectedContent"
Background="{ThemeResource KronaAppBackgroundBrush}"> Visibility="Collapsed"
Padding="24,16,24,24"
RowSpacing="12">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
@ -149,6 +151,8 @@
</AppBarButton.Icon> </AppBarButton.Icon>
</AppBarButton> </AppBarButton>
<AppBarButton Icon="Permissions" Label="Права администратора" Click="ToggleAdministrator_Click"/> <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"/> <AppBarButton Icon="Delete" Label="Удалить" Click="DeleteEmployee_Click"/>
<AppBarSeparator /> <AppBarSeparator />
<AppBarButton Label="Заблокировать" Click="LogoutAdmin_Click"> <AppBarButton Label="Заблокировать" Click="LogoutAdmin_Click">
@ -204,7 +208,20 @@
<ScrollViewer> <ScrollViewer>
<StackPanel Padding="4,16,20,24" Spacing="14" MaxWidth="720" HorizontalAlignment="Left"> <StackPanel Padding="4,16,20,24" Spacing="14" MaxWidth="720" HorizontalAlignment="Left">
<TextBlock Text="Сканер" Foreground="{ThemeResource KronaTextPrimaryBrush}" Style="{StaticResource SubtitleTextBlockStyle}"/> <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"/> <ToggleSwitch x:Name="StartWithWindowsSwitch" Header="Автозагрузка" OffContent="Выключена" OnContent="Запускать вместе с Windows"/>
<NumberBox x:Name="DuplicateGuardBox" Header="Защита от повторного скана, секунд" Minimum="10" Maximum="3600" SpinButtonPlacementMode="Compact"/> <NumberBox x:Name="DuplicateGuardBox" Header="Защита от повторного скана, секунд" Minimum="10" Maximum="3600" SpinButtonPlacementMode="Compact"/>
<NumberBox x:Name="MaximumShiftHoursBox" Header="Максимальная длительность смены, часов" Minimum="1" Maximum="48" SpinButtonPlacementMode="Compact"/> <NumberBox x:Name="MaximumShiftHoursBox" Header="Максимальная длительность смены, часов" Minimum="1" Maximum="48" SpinButtonPlacementMode="Compact"/>
@ -214,11 +231,15 @@
<Border Height="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}"/> <Border Height="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}"/>
<ToggleSwitch x:Name="TelegramEnabledSwitch" Header="Telegram" OffContent="Выключен" OnContent="Включён"/> <ToggleSwitch x:Name="TelegramEnabledSwitch" Header="Telegram" OffContent="Выключен" OnContent="Включён"/>
<PasswordBox x:Name="TelegramTokenBox" Header="Токен Telegram-бота" PasswordRevealMode="Peek"/> <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}"/> <Border Height="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}"/>
<ToggleSwitch x:Name="MaxEnabledSwitch" Header="MAX" OffContent="Выключен" OnContent="Включён"/> <ToggleSwitch x:Name="MaxEnabledSwitch" Header="MAX" OffContent="Выключен" OnContent="Включён"/>
<PasswordBox x:Name="MaxTokenBox" Header="Токен MAX-бота" PasswordRevealMode="Peek"/> <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}"/> <Border Height="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}"/>
<TextBlock Text="Обновления программы" Foreground="{ThemeResource KronaTextPrimaryBrush}" Style="{StaticResource SubtitleTextBlockStyle}"/> <TextBlock Text="Обновления программы" Foreground="{ThemeResource KronaTextPrimaryBrush}" Style="{StaticResource SubtitleTextBlockStyle}"/>
<TextBlock x:Name="CurrentVersionText" Opacity="0.75"/> <TextBlock x:Name="CurrentVersionText" Opacity="0.75"/>
@ -244,7 +265,7 @@
Background="{ThemeResource KronaPrimaryBrush}" Background="{ThemeResource KronaPrimaryBrush}"
Foreground="{ThemeResource KronaTextInverseBrush}" Foreground="{ThemeResource KronaTextInverseBrush}"
Click="SaveSettings_Click"/> Click="SaveSettings_Click"/>
<Button Content="Отправить тест" Click="TestNotification_Click"/> <Button Content="Тест выбранному сотруднику" Click="TestNotification_Click"/>
<Button Content="Заблокировать" Click="LogoutAdmin_Click"/> <Button Content="Заблокировать" Click="LogoutAdmin_Click"/>
</StackPanel> </StackPanel>
<TextBlock Text="Токены, сотрудники и шаблоны отпечатков хранятся в зашифрованном хранилище текущей учётной записи Windows." TextWrapping="Wrap" Opacity="0.7"/> <TextBlock Text="Токены, сотрудники и шаблоны отпечатков хранятся в зашифрованном хранилище текущей учётной записи Windows." TextWrapping="Wrap" Opacity="0.7"/>
@ -253,4 +274,43 @@
</TabViewItem> </TabViewItem>
</TabView> </TabView>
</Grid> </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> </Page>

View file

@ -3,6 +3,7 @@ using KronaStaffTerminal.Services;
using Microsoft.UI.Dispatching; using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using System.Text; using System.Text;
using Windows.Storage; using Windows.Storage;
using Windows.Storage.Pickers; using Windows.Storage.Pickers;
@ -28,8 +29,12 @@ public sealed partial class MainPage : Page
private bool _adminNavigationInProgress; private bool _adminNavigationInProgress;
private bool _stateLoadFailed; private bool _stateLoadFailed;
private bool _scannerReconnectPending; private bool _scannerReconnectPending;
private bool _refreshingReaders;
private int? _attendanceSuppressedFingerprintId;
private DateTimeOffset _attendanceSuppressedUntilUtc;
private string? _registrationEmployeeId; private string? _registrationEmployeeId;
private bool _refreshingLists; private bool _refreshingLists;
private bool _applicationLocked = true;
private string _lastAdminActor = "unknown"; private string _lastAdminActor = "unknown";
private DateTimeOffset _adminSessionExpiresAtUtc; private DateTimeOffset _adminSessionExpiresAtUtc;
private static readonly TimeSpan AdminSessionDuration = TimeSpan.FromMinutes(5); private static readonly TimeSpan AdminSessionDuration = TimeSpan.FromMinutes(5);
@ -91,6 +96,9 @@ public sealed partial class MainPage : Page
ConnectScanner(); ConnectScanner();
_updateTimer.Start(); _updateTimer.Start();
MainTabs.SelectedItem = TerminalTab; MainTabs.SelectedItem = TerminalTab;
LockScreenMessage.Text = SecurityConfigured
? "Введите PIN администратора или приложите зарегистрированный палец."
: "Первый запуск: нажмите «Войти», чтобы создать PIN администратора.";
if (_store.RecoveryNotice is not null) if (_store.RecoveryNotice is not null)
{ {
SetStatus(_store.RecoveryNotice, InfoBarSeverity.Warning); SetStatus(_store.RecoveryNotice, InfoBarSeverity.Warning);
@ -148,10 +156,88 @@ public sealed partial class MainPage : Page
} }
private void LogoutAdmin_Click(object sender, RoutedEventArgs e) => private void LogoutAdmin_Click(object sender, RoutedEventArgs e) =>
LockAdminSession("Административный раздел заблокирован."); LockApplication("Программа заблокирована.");
public void LockAdminSession() => 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) private void LockAdminSession(string message)
{ {
@ -195,9 +281,9 @@ public sealed partial class MainPage : Page
_adminSessionActive = true; _adminSessionActive = true;
TouchAdminSession(); TouchAdminSession();
SetStatus(AdministratorFingerprintConfigured SetStatus(AdministratorEmployeeConfigured
? "Вход администратора выполнен. Сеанс закроется через 5 минут бездействия." ? "Вход администратора выполнен. Сеанс закроется через 5 минут бездействия."
: "Первичная настройка открыта. Добавьте администратора и зарегистрируйте его отпечаток.", : "Первичная настройка открыта. Добавьте первого сотрудника-администратора. Сканер можно подключить позже.",
InfoBarSeverity.Success); InfoBarSeverity.Success);
return true; return true;
} }
@ -209,7 +295,7 @@ public sealed partial class MainPage : Page
var content = new StackPanel { Spacing = 12, MinWidth = 320 }; var content = new StackPanel { Spacing = 12, MinWidth = 320 };
content.Children.Add(new TextBlock content.Children.Add(new TextBlock
{ {
Text = "Первичная настройка. После входа добавьте сотрудника-администратора и зарегистрируйте его отпечаток.", Text = "Первичная настройка. PIN работает без сканера. После входа добавьте первого сотрудника-администратора; отпечаток можно зарегистрировать позже.",
TextWrapping = TextWrapping.Wrap TextWrapping = TextWrapping.Wrap
}); });
content.Children.Add(pinBox); content.Children.Add(pinBox);
@ -230,7 +316,10 @@ public sealed partial class MainPage : Page
return false; 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.AdminPinSalt = pin.Salt;
_state.Settings.AdminPinHash = pin.Hash; _state.Settings.AdminPinHash = pin.Hash;
_state.Settings.AdminPinIterations = pin.Iterations; _state.Settings.AdminPinIterations = pin.Iterations;
@ -244,15 +333,23 @@ public sealed partial class MainPage : Page
try try
{ {
_scanner.Connect(_state.Settings.ReaderIndex, _state.Employees _scanner.Connect(_state.Settings.ReaderIndex, _state.Employees
.Where(employee => employee.HasFingerprint) .Where(employee => employee.HasFingerprint && !employee.IsBlocked)
.SelectMany(employee => employee.Fingerprints) .SelectMany(employee => employee.Fingerprints)
.Select(fingerprint => (fingerprint.Id, fingerprint.Template))); .Select(fingerprint => (fingerprint.Id, fingerprint.Template)));
ReaderCombo.Items.Clear(); _refreshingReaders = true;
for (var index = 0; index < _scanner.DeviceCount; index++) 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) if (_state.Settings.ReaderIndex != _scanner.ReaderIndex)
{ {
_state.Settings.ReaderIndex = _scanner.ReaderIndex; _state.Settings.ReaderIndex = _scanner.ReaderIndex;
@ -271,10 +368,31 @@ public sealed partial class MainPage : Page
} }
catch (Exception exception) 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) private async void AddEmployee_Click(object sender, RoutedEventArgs e)
{ {
if (!await EnsureAdminSessionAsync("добавление сотрудника")) return; if (!await EnsureAdminSessionAsync("добавление сотрудника")) return;
@ -290,7 +408,7 @@ public sealed partial class MainPage : Page
SetStatus("Сотрудник с таким табельным номером уже есть.", InfoBarSeverity.Warning); SetStatus("Сотрудник с таким табельным номером уже есть.", InfoBarSeverity.Warning);
return; return;
} }
if (AdministratorCheckBox.IsChecked == true && SecurityConfigured && if (AdministratorCheckBox.IsChecked == true && AdministratorEmployeeConfigured &&
!await AuthorizeAdminAsync("назначение нового администратора")) !await AuthorizeAdminAsync("назначение нового администратора"))
{ {
return; return;
@ -318,7 +436,10 @@ public sealed partial class MainPage : Page
AdministratorCheckBox.IsChecked = false; AdministratorCheckBox.IsChecked = false;
RefreshLists(); RefreshLists();
EmployeesList.SelectedItem = employee; EmployeesList.SelectedItem = employee;
SetStatus($"{name} добавлен. Теперь зарегистрируйте отпечаток.", InfoBarSeverity.Success); SetStatus(employee.IsAdministrator
? $"{name} добавлен как администратор. Отпечаток можно зарегистрировать после подключения сканера."
: $"{name} добавлен. Отпечаток можно зарегистрировать позже.",
InfoBarSeverity.Success);
} }
private async void EditEmployee_Click(object sender, RoutedEventArgs e) private async void EditEmployee_Click(object sender, RoutedEventArgs e)
@ -406,6 +527,120 @@ public sealed partial class MainPage : Page
SetStatus($"Данные сотрудника «{name}» обновлены. Отпечатки и журнал сохранены.", InfoBarSeverity.Success); 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) private async void RegisterFingerprint_Click(object sender, RoutedEventArgs e)
{ {
if (!await EnsureAdminSessionAsync("регистрация отпечатка")) return; if (!await EnsureAdminSessionAsync("регистрация отпечатка")) return;
@ -484,8 +719,8 @@ public sealed partial class MainPage : Page
SetStatus("У выбранного сотрудника нет зарегистрированных отпечатков.", InfoBarSeverity.Informational); SetStatus("У выбранного сотрудника нет зарегистрированных отпечатков.", InfoBarSeverity.Informational);
return; return;
} }
if (employee.IsAdministrator && !PinConfigured && if (employee.IsAdministrator && !employee.IsBlocked && !PinConfigured &&
_state.Employees.Count(item => item.IsAdministrator && item.HasFingerprint) <= 1) _state.Employees.Count(item => item.IsAdministrator && !item.IsBlocked && item.HasFingerprint) <= 1)
{ {
SetStatus("Нельзя удалить последний способ входа администратора.", InfoBarSeverity.Warning); SetStatus("Нельзя удалить последний способ входа администратора.", InfoBarSeverity.Warning);
return; return;
@ -518,8 +753,9 @@ public sealed partial class MainPage : Page
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning); SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
return; return;
} }
if (SecurityConfigured && !await AuthorizeAdminAsync("изменение прав администратора")) return; if (AdministratorEmployeeConfigured && !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); SetStatus("Нельзя снять права с единственного администратора с зарегистрированным отпечатком.", InfoBarSeverity.Warning);
return; return;
@ -542,7 +778,7 @@ public sealed partial class MainPage : Page
{ {
XamlRoot = XamlRoot, XamlRoot = XamlRoot,
Title = "Удалить сотрудника и отпечаток?", Title = "Удалить сотрудника и отпечаток?",
Content = $"{employee.DisplayName}, его отпечаток и право доступа будут удалены из терминала.", Content = $"{employee.DisplayName}, его отпечатки, право доступа и привязки личных уведомлений будут удалены из терминала. История диалога в самом мессенджере останется у пользователя.",
PrimaryButtonText = "Продолжить", PrimaryButtonText = "Продолжить",
CloseButtonText = "Отмена", CloseButtonText = "Отмена",
DefaultButton = ContentDialogButton.Close DefaultButton = ContentDialogButton.Close
@ -554,7 +790,8 @@ public sealed partial class MainPage : Page
return; return;
} }
if (!await AuthorizeAdminAsync("удаление сотрудника и отпечатка")) 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); SetStatus("Нельзя удалить единственного администратора.", InfoBarSeverity.Warning);
return; return;
@ -572,10 +809,34 @@ public sealed partial class MainPage : Page
var employee = _state.Employees.FirstOrDefault(item => var employee = _state.Employees.FirstOrDefault(item =>
item.Fingerprints.Any(fingerprint => fingerprint.Id == fingerprintId)); item.Fingerprints.Any(fingerprint => fingerprint.Id == fingerprintId));
if (employee is null) return; 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 (_adminAuthCompletion is not null)
{ {
if (employee.IsAdministrator) 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}"; _lastAdminActor = $"employee:{employee.Id}";
TerminalMessage.Text = $"Администратор {TerminalDisplayName(employee)} подтверждён"; TerminalMessage.Text = $"Администратор {TerminalDisplayName(employee)} подтверждён";
_adminAuthCompletion.TrySetResult(true); _adminAuthCompletion.TrySetResult(true);
@ -583,10 +844,18 @@ public sealed partial class MainPage : Page
} }
else else
{ {
await RecordAttendanceAsync(employee, score); SetStatus("Этот отпечаток не принадлежит администратору. Отметка прихода или ухода не создана.", InfoBarSeverity.Warning);
} }
return; return;
} }
if (_attendanceSuppressedFingerprintId == fingerprintId &&
DateTimeOffset.UtcNow < _attendanceSuppressedUntilUtc)
{
return;
}
_attendanceSuppressedFingerprintId = null;
_attendanceSuppressedUntilUtc = default;
await RecordAttendanceAsync(employee, score); await RecordAttendanceAsync(employee, score);
} }
@ -628,7 +897,7 @@ public sealed partial class MainPage : Page
TerminalMessage.Text = $"{TerminalDisplayName(employee)}\n{eventType} отмечен в {now.ToLocalTime():HH:mm}"; TerminalMessage.Text = $"{TerminalDisplayName(employee)}\n{eventType} отмечен в {now.ToLocalTime():HH:mm}";
SetStatus($"{eventType} отмечен. Точность распознавания: {score}.", InfoBarSeverity.Success); 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.NotificationDelivered = delivery.Delivered;
record.NotificationError = delivery.Error; record.NotificationError = delivery.Error;
await _store.SaveAsync(_state); await _store.SaveAsync(_state);
@ -736,7 +1005,10 @@ public sealed partial class MainPage : Page
!string.IsNullOrWhiteSpace(_state.Settings.AdminPinHash); !string.IsNullOrWhiteSpace(_state.Settings.AdminPinHash);
private bool AdministratorFingerprintConfigured => 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 => private bool SecurityConfigured =>
PinConfigured || AdministratorFingerprintConfigured; PinConfigured || AdministratorFingerprintConfigured;
@ -793,18 +1065,21 @@ public sealed partial class MainPage : Page
_adminAuthCompletion.Task.Result; _adminAuthCompletion.Task.Result;
_adminAuthCompletion.TrySetResult(false); _adminAuthCompletion.TrySetResult(false);
var pinAuthorized = 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) if (dialogResult == ContentDialogResult.Primary && !pinBlocked && !requireIdentifiedActor)
{ {
pinAuthorized = await Task.Run(() => pinAuthorized = await Task.Run(() =>
AdminPinService.Verify( AdminPinService.Verify(
pinBox.Password, enteredPin,
_state.Settings.AdminPinSalt, _state.Settings.AdminPinSalt,
_state.Settings.AdminPinHash, _state.Settings.AdminPinHash,
_state.Settings.AdminPinIterations) || _state.Settings.AdminPinIterations) ||
(!string.Equals(pinBox.Password, pinBox.Password.Trim(), StringComparison.Ordinal) && (!string.Equals(enteredPin, enteredPin.Trim(), StringComparison.Ordinal) &&
AdminPinService.Verify( AdminPinService.Verify(
pinBox.Password.Trim(), enteredPin.Trim(),
_state.Settings.AdminPinSalt, _state.Settings.AdminPinSalt,
_state.Settings.AdminPinHash, _state.Settings.AdminPinHash,
_state.Settings.AdminPinIterations))); _state.Settings.AdminPinIterations)));
@ -858,7 +1133,7 @@ public sealed partial class MainPage : Page
SetStatus("PIN должен содержать не менее 6 символов.", InfoBarSeverity.Warning); SetStatus("PIN должен содержать не менее 6 символов.", InfoBarSeverity.Warning);
return; 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)); var pin = await Task.Run(() => AdminPinService.Hash(newPin));
_state.Settings.AdminPinSalt = pin.Salt; _state.Settings.AdminPinSalt = pin.Salt;
_state.Settings.AdminPinHash = pin.Hash; _state.Settings.AdminPinHash = pin.Hash;
@ -870,7 +1145,14 @@ public sealed partial class MainPage : Page
private async void SaveSettings_Click(object sender, RoutedEventArgs e) 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(); CopySettingsFromControls();
await _store.SaveAsync(_state, createRecoveryPoint: true); await _store.SaveAsync(_state, createRecoveryPoint: true);
ApplyStartupSetting(); ApplyStartupSetting();
@ -992,9 +1274,17 @@ public sealed partial class MainPage : Page
private async void TestNotification_Click(object sender, RoutedEventArgs e) private async void TestNotification_Click(object sender, RoutedEventArgs e)
{ {
if (!await EnsureAdminSessionAsync("проверка уведомлений")) return; if (!await EnsureAdminSessionAsync("проверка уведомлений")) return;
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
{
SetStatus("Выберите сотрудника в разделе «Сотрудники», затем повторите тест.", InfoBarSeverity.Warning);
return;
}
CopySettingsFromControls(); CopySettingsFromControls();
var result = await _notifications.SendTestAsync(_state.Settings); var result = await _notifications.SendTestAsync(_state.Settings, employee);
SetStatus(result.Delivered ? "Тестовое сообщение отправлено." : result.Error ?? "Включите Telegram или MAX и заполните реквизиты.", result.Delivered ? InfoBarSeverity.Success : InfoBarSeverity.Warning); SetStatus(result.Delivered
? $"Тестовое сообщение отправлено лично сотруднику «{employee.DisplayName}»."
: result.Error ?? "У сотрудника не указан личный ID или канал выключен.",
result.Delivered ? InfoBarSeverity.Success : InfoBarSeverity.Warning);
} }
private void CopySettingsFromControls() private void CopySettingsFromControls()
@ -1006,10 +1296,10 @@ public sealed partial class MainPage : Page
_state.Settings.ShowEmployeeNamesOnTerminal = ShowEmployeeNamesSwitch.IsOn; _state.Settings.ShowEmployeeNamesOnTerminal = ShowEmployeeNamesSwitch.IsOn;
_state.Settings.TelegramEnabled = TelegramEnabledSwitch.IsOn; _state.Settings.TelegramEnabled = TelegramEnabledSwitch.IsOn;
_state.Settings.TelegramBotToken = TelegramTokenBox.Password.Trim(); _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.MaxEnabled = MaxEnabledSwitch.IsOn;
_state.Settings.MaxBotToken = MaxTokenBox.Password.Trim(); _state.Settings.MaxBotToken = MaxTokenBox.Password.Trim();
_state.Settings.MaxChatId = MaxChatBox.Text.Trim();
CopyUpdateSettingsFromControls(); CopyUpdateSettingsFromControls();
} }
@ -1028,10 +1318,10 @@ public sealed partial class MainPage : Page
ShowEmployeeNamesSwitch.IsOn = _state.Settings.ShowEmployeeNamesOnTerminal; ShowEmployeeNamesSwitch.IsOn = _state.Settings.ShowEmployeeNamesOnTerminal;
TelegramEnabledSwitch.IsOn = _state.Settings.TelegramEnabled; TelegramEnabledSwitch.IsOn = _state.Settings.TelegramEnabled;
TelegramTokenBox.Password = _state.Settings.TelegramBotToken; 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; MaxEnabledSwitch.IsOn = _state.Settings.MaxEnabled;
MaxTokenBox.Password = _state.Settings.MaxBotToken; MaxTokenBox.Password = _state.Settings.MaxBotToken;
MaxChatBox.Text = _state.Settings.MaxChatId;
UpdateChecksSwitch.IsOn = _state.Settings.UpdateChecksEnabled; UpdateChecksSwitch.IsOn = _state.Settings.UpdateChecksEnabled;
UpdateFeedBox.Text = _state.Settings.UpdateFeedUrl; UpdateFeedBox.Text = _state.Settings.UpdateFeedUrl;
UpdateTokenBox.Password = _state.Settings.UpdateAccessToken; UpdateTokenBox.Password = _state.Settings.UpdateAccessToken;

View file

@ -10,7 +10,12 @@ public sealed class EmployeeRecord
public string Position { get; set; } = ""; public string Position { get; set; } = "";
public string Gender { get; set; } = ""; public string Gender { get; set; } = "";
public bool IsAdministrator { get; set; } public bool IsAdministrator { get; set; }
public bool IsBlocked { get; set; }
public DateTimeOffset? BlockedAtUtc { get; set; }
public List<FingerprintRecord> Fingerprints { 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. // Legacy single-fingerprint fields are kept for one-way migration of existing encrypted state.
public int FingerprintId { get; set; } public int FingerprintId { get; set; }
@ -29,7 +34,21 @@ public sealed class EmployeeRecord
}; };
[JsonIgnore] [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] [JsonIgnore]
public string GenderDisplay => string.IsNullOrWhiteSpace(Gender) ? "—" : Gender; public string GenderDisplay => string.IsNullOrWhiteSpace(Gender) ? "—" : Gender;
@ -74,7 +93,10 @@ public sealed class TerminalSettings
public bool ShowEmployeeNamesOnTerminal { get; set; } public bool ShowEmployeeNamesOnTerminal { get; set; }
public bool TelegramEnabled { get; set; } public bool TelegramEnabled { get; set; }
public string TelegramBotToken { 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 string TelegramChatId { get; set; } = "";
public bool VkEnabled { get; set; }
public string VkGroupToken { get; set; } = "";
public bool MaxEnabled { get; set; } public bool MaxEnabled { get; set; }
public string MaxBotToken { get; set; } = ""; public string MaxBotToken { get; set; } = "";
public string MaxChatId { get; set; } = ""; public string MaxChatId { get; set; } = "";

View file

@ -4,7 +4,8 @@
## Возможности ## Возможности
- выбор одного из подключённых сканеров с автоматическим переходом на рабочее устройство; - повторный поиск сканеров по кнопке, ручной выбор одного из подключённых устройств и автоматический переход на рабочее устройство;
- первичная настройка PIN и добавление первого сотрудника-администратора без подключённого сканера;
- добавление и редактирование сотрудников с должностью, полом и правами администратора без потери отпечатков и журнала; - добавление и редактирование сотрудников с должностью, полом и правами администратора без потери отпечатков и журнала;
- несколько отпечатков у одного сотрудника, каждый регистрируется по трём последовательным сканам; - несколько отпечатков у одного сотрудника, каждый регистрируется по трём последовательным сканам;
- чередование событий «Приход» и «Уход»; - чередование событий «Приход» и «Уход»;
@ -13,10 +14,12 @@
- экспорт журнала в CSV и резервные копии зашифрованного состояния; - экспорт журнала в CSV и резервные копии зашифрованного состояния;
- защищённая очистка журнала без удаления сотрудников и отпечатков; - защищённая очистка журнала без удаления сотрудников и отпечатков;
- приватный режим, скрывающий полные имена на публичном экране; - приватный режим, скрывающий полные имена на публичном экране;
- уведомления через Telegram Bot API и MAX Bot API; - личные уведомления сотрудников через Telegram, сообщения сообщества ВКонтакте и MAX;
- блокировка сотрудника с отключением его отпечатков и безвозвратным удалением локальных привязок мессенджеров;
- автозагрузка сразу в системный трей и постоянное фоновое сканирование, даже когда открыта другая программа; - автозагрузка сразу в системный трей и постоянное фоновое сканирование, даже когда открыта другая программа;
- закрытый административный раздел с входом по PIN или отпечатку администратора; - закрытый экран входа, скрывающий сотрудников, журнал и настройки до проверки PIN или отпечатка администратора;
- автоматическая блокировка административного раздела через 5 минут и при сворачивании; - автоматическая блокировка всей программы при запуске, сворачивании и ручной блокировке;
- автоматическая блокировка административного раздела через 5 минут бездействия;
- шифрование сотрудников, токенов и шаблонов отпечатков средствами Windows DPAPI. - шифрование сотрудников, токенов и шаблонов отпечатков средствами Windows DPAPI.
- проверка новых релизов Forgejo при запуске и каждые шесть часов; - проверка новых релизов Forgejo при запуске и каждые шесть часов;
- загрузка обновления только после входа администратора и обязательной проверки SHA-256. - загрузка обновления только после входа администратора и обязательной проверки SHA-256.
@ -55,7 +58,7 @@ ZKTeco. Уже установленный драйвер продолжает р
1. Вкладки «Сотрудники» и «Уведомления и сканер» открываются только после входа администратора. 1. Вкладки «Сотрудники» и «Уведомления и сканер» открываются только после входа администратора.
2. Для входа достаточно PIN либо зарегистрированного отпечатка администратора. 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})."); throw new InvalidOperationException($"SDK ZKTeco не инициализировано (код {result}).");
} }
// ZKFinger SDK 5.3 reports one service slot in addition to physical readers. // GetDeviceCount returns the number of physical readers for the SLK20R
// The vendor C# sample uses GetDeviceCount() - 1 for this SDK generation. // driver shipped with ZKFinger SDK 5.3. Do not subtract a service slot:
DeviceCount = Math.Max(0, zkfp2.GetDeviceCount() - 1); // on a workstation with one reader that would hide the only device.
DeviceCount = Math.Max(0, zkfp2.GetDeviceCount());
if (DeviceCount <= 0) if (DeviceCount <= 0)
{ {
zkfp2.Terminate(); zkfp2.Terminate();

View file

@ -9,28 +9,33 @@ public sealed class NotificationService
{ {
private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(15) }; 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 == "Уход" ? "ушёл(ушла)" : "пришёл(пришла)"; var action = eventType == "Уход" ? "уход" : "приход";
return await SendAsync(settings, $"{employeeName} {action} в {localTime:HH:mm}"); 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 errors = new List<string>();
var sent = false; var sent = false;
if (settings.TelegramEnabled) var telegramConfigured = settings.TelegramEnabled &&
!string.IsNullOrWhiteSpace(settings.TelegramBotToken) &&
!string.IsNullOrWhiteSpace(employee.TelegramChatId);
if (telegramConfigured)
{ {
try try
{ {
var url = $"https://api.telegram.org/bot{settings.TelegramBotToken}/sendMessage"; 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) if (!response.IsSuccessStatusCode)
{ {
errors.Add($"Telegram: HTTP {(int)response.StatusCode}"); 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 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); using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.TryAddWithoutValidation("Authorization", settings.MaxBotToken); request.Headers.TryAddWithoutValidation("Authorization", settings.MaxBotToken);
request.Content = new StringContent(JsonSerializer.Serialize(new { text }), Encoding.UTF8, "application/json"); 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); return (false, null);
} }

View file

@ -4,7 +4,7 @@ SetCompressor /SOLID lzma
!include "MUI2.nsh" !include "MUI2.nsh"
!ifndef APP_VERSION !ifndef APP_VERSION
!define APP_VERSION "0.2.0" !define APP_VERSION "0.2.4"
!endif !endif
!ifndef PUBLISH_DIR !ifndef PUBLISH_DIR
!error "PUBLISH_DIR is required" !error "PUBLISH_DIR is required"

View file

@ -2,7 +2,7 @@
"$schema": "./schemas/module.schema.json", "$schema": "./schemas/module.schema.json",
"id": "ru.krona.staff", "id": "ru.krona.staff",
"name": "Krona Staff", "name": "Krona Staff",
"version": "0.2.0", "version": "0.2.4",
"min_platform_version": "3.2.0", "min_platform_version": "3.2.0",
"dependencies": ["ru.krona.core"], "dependencies": ["ru.krona.core"],
"optional_dependencies": [], "optional_dependencies": [],

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "@krona/staff", "name": "@krona/staff",
"version": "0.2.0", "version": "0.2.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@krona/staff", "name": "@krona/staff",
"version": "0.2.0", "version": "0.2.4",
"dependencies": { "dependencies": {
"@fastify/helmet": "^13.0.2", "@fastify/helmet": "^13.0.2",
"@fastify/sensible": "^6.0.3", "@fastify/sensible": "^6.0.3",

View file

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

View file

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