feat(terminal): update attendance terminal to 0.2.4
Some checks are pending
ci / verify (push) Waiting to run
Some checks are pending
ci / verify (push) Waiting to run
This commit is contained in:
parent
827d321a1a
commit
4d37350757
13 changed files with 494 additions and 74 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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; } = "";
|
||||||
|
|
|
||||||
|
|
@ -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. Сотрудник должен предварительно начать личный диалог с ботом или разрешить сообщения сообщества. Кнопка «Тест выбранному сотруднику» отправляет сообщение только этому человеку. При успешной отметке сотрудник получает дату, время и тип собственного события без имени и данных других сотрудников.
|
||||||
|
|
||||||
|
При блокировке сотрудника его личные идентификаторы удаляются из зашифрованного хранилища, отпечатки перестают создавать отметки, а отправка прекращается. После разблокировки каналы необходимо привязать заново. Удалить весь диалог из приложения мессенджера на устройстве сотрудника бот не может; уже доставленная история остаётся под управлением самого пользователя и соответствующего мессенджера.
|
||||||
|
|
||||||
## Обновления
|
## Обновления
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
4
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue