merge branch 'master' into feature/session-timeout

This commit is contained in:
2026-03-24 22:50:12 +03:00
16 changed files with 622 additions and 322 deletions

View File

@@ -7,6 +7,7 @@ public interface IPassStore
bool Locked { get; }
IPassStoreDirectory GetRootDirectory();
IPassStoreDirectory? GetGroupByType(byte groupType);
int GetTotalEntryCount();
void Unlock(CompositeKey key);
void Lock();

View File

@@ -6,4 +6,5 @@ namespace KeyKeeper.PasswordStore;
public interface IPassStoreDirectory : IEnumerable<PassStoreEntry>
{
bool DeleteEntry(Guid id);
void AddEntry(PassStoreEntry entry);
}

View File

@@ -64,6 +64,12 @@ public class PassStoreEntryGroup : PassStoreEntry, IPassStoreDirectory
}
}
public void AddEntry(PassStoreEntry entry)
{
entry.Parent = this;
ChildEntries.Add(entry);
}
public bool DeleteEntry(Guid id)
{
if (ChildEntries == null)

View File

@@ -49,6 +49,15 @@ public class PassStoreFileAccessor : IPassStore
return (IPassStoreDirectory)root!;
}
public IPassStoreDirectory? GetGroupByType(byte groupType)
{
if (Locked)
throw new InvalidOperationException();
return (root as PassStoreEntryGroup)?.ChildEntries
.OfType<PassStoreEntryGroup>()
.FirstOrDefault(g => g.GroupType == groupType);
}
public int GetTotalEntryCount()
{
throw new NotImplementedException();
@@ -252,15 +261,24 @@ public class PassStoreFileAccessor : IPassStore
private PassStoreEntry WriteInitialStoreTree(OuterEncryptionWriter w)
{
PassStoreEntry root =
new PassStoreEntryGroup(
Guid.NewGuid(),
DateTime.UtcNow,
DateTime.UtcNow,
Guid.Empty,
"",
GROUP_TYPE_ROOT
);
PassStoreEntryGroup defaultGroup = new(
Guid.NewGuid(),
DateTime.UtcNow,
DateTime.UtcNow,
Guid.Empty,
"",
GROUP_TYPE_DEFAULT
);
PassStoreEntryGroup root = new(
Guid.NewGuid(),
DateTime.UtcNow,
DateTime.UtcNow,
Guid.Empty,
"",
GROUP_TYPE_ROOT,
[defaultGroup]
);
defaultGroup.Parent = root;
root.WriteToStream(w);
return root;
}

View File

@@ -2,6 +2,7 @@ using System;
using System.Threading.Tasks;
using Avalonia.Threading;
using KeyKeeper.PasswordStore;
using static KeyKeeper.PasswordStore.FileFormatConstants;
namespace KeyKeeper.ViewModels;
@@ -44,7 +45,7 @@ public partial class RepositoryWindowViewModel : ViewModelBase
}
/// <summary>
/// <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> (<28><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>).
/// Сбрасывает таймер блокировки (вызывается при любой активности пользователя).
/// </summary>
public void ResetLockTimer()
{
@@ -54,7 +55,9 @@ public partial class RepositoryWindowViewModel : ViewModelBase
private void SwitchToUnlocked()
{
CurrentPage = new UnlockedRepositoryViewModel(passStore);
var directory = passStore.GetGroupByType(GROUP_TYPE_DEFAULT)
?? passStore.GetRootDirectory();
CurrentPage = new UnlockedRepositoryViewModel(passStore, directory);
StartLockTimer();
}
@@ -109,4 +112,4 @@ public partial class RepositoryWindowViewModel : ViewModelBase
var r = remaining ?? LockTimeout;
LockTimerDisplay = $"{r:mm\\:ss}";
}
}
}

View File

@@ -8,39 +8,64 @@ namespace KeyKeeper.ViewModels;
public class UnlockedRepositoryViewModel : ViewModelBase
{
private IPassStore passStore;
private IPassStoreDirectory currentDirectory;
private bool hasUnsavedChanges;
public IEnumerable<PassStoreEntryPassword> Passwords
{
get
{
return passStore.GetRootDirectory()
return currentDirectory
.Where(entry => entry is PassStoreEntryPassword)
.Select(entry => (entry as PassStoreEntryPassword)!);
}
}
public UnlockedRepositoryViewModel(IPassStore store)
public bool HasUnsavedChanges
{
get => hasUnsavedChanges;
private set
{
hasUnsavedChanges = value;
OnPropertyChanged(nameof(HasUnsavedChanges));
}
}
public UnlockedRepositoryViewModel(IPassStore store, IPassStoreDirectory directory)
{
passStore = store;
currentDirectory = directory;
HasUnsavedChanges = false;
}
public void AddEntry(PassStoreEntry entry)
{
if (entry is PassStoreEntryPassword)
{
(passStore.GetRootDirectory() as PassStoreEntryGroup)!.ChildEntries.Add(entry);
currentDirectory.AddEntry(entry);
HasUnsavedChanges = true;
OnPropertyChanged(nameof(Passwords));
}
}
public void DeleteEntry(Guid id)
{
(passStore.GetRootDirectory() as PassStoreEntryGroup)!.DeleteEntry(id);
currentDirectory.DeleteEntry(id);
HasUnsavedChanges = true;
OnPropertyChanged(nameof(Passwords));
}
public void UpdateEntry(PassStoreEntryPassword updatedEntry)
{
currentDirectory.DeleteEntry(updatedEntry.Id);
currentDirectory.AddEntry(updatedEntry);
HasUnsavedChanges = true;
OnPropertyChanged(nameof(Passwords));
}
public void Save()
{
passStore.Save();
HasUnsavedChanges = false;
}
}
}

View File

@@ -0,0 +1,29 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="KeyKeeper.Views.CloseConfirmationDialog"
Width="420"
Height="170"
CanResize="False"
WindowStartupLocation="CenterOwner"
Title="Confirm close"
Background="White">
<Grid Margin="16" RowDefinitions="*,Auto">
<TextBlock Text="Save changes before closing the storage?"
TextWrapping="Wrap"
Foreground="Black"
FontSize="16"/>
<StackPanel Grid.Row="1"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="8"
Margin="0,16,0,0">
<Button Content="Save"
Click="Save_Click" />
<Button Content="Do not save"
Click="Discard_Click" />
<Button Content="Cancel"
Click="Cancel_Click" />
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,52 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace KeyKeeper.Views;
public enum CloseConfirmationResult
{
Save,
Discard,
Cancel,
}
public partial class CloseConfirmationDialog : Window
{
private bool closingWithResult;
public CloseConfirmationDialog()
{
InitializeComponent();
}
protected override void OnClosing(WindowClosingEventArgs e)
{
if (!closingWithResult)
{
e.Cancel = true;
closingWithResult = true;
Close(CloseConfirmationResult.Cancel);
return;
}
base.OnClosing(e);
}
private void Save_Click(object? sender, RoutedEventArgs e)
{
closingWithResult = true;
Close(CloseConfirmationResult.Save);
}
private void Discard_Click(object? sender, RoutedEventArgs e)
{
closingWithResult = true;
Close(CloseConfirmationResult.Discard);
}
private void Cancel_Click(object? sender, RoutedEventArgs e)
{
closingWithResult = true;
Close(CloseConfirmationResult.Cancel);
}
}

View File

@@ -0,0 +1,93 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="KeyKeeper.Views.CreateVaultFileWindow"
Title="Create New Vault"
Background="#fff"
Icon="/Assets/icon.ico"
Width="600" Height="450"
WindowStartupLocation="CenterOwner"
CanResize="False"
x:Name="ThisWindow">
<Grid ColumnDefinitions="1.5*, 2*">
<!-- Левая синяя панель -->
<Border Background="#2328C4" Grid.Column="0">
<StackPanel VerticalAlignment="Center" Margin="20">
<TextBlock Text="Choose where to save your password database and set a master password"
TextWrapping="Wrap"
Foreground="#E0E0FF"
FontSize="20"
TextAlignment="Center"/>
</StackPanel>
</Border>
<!-- Правая белая панель с формой -->
<ScrollViewer Grid.Column="1" VerticalScrollBarVisibility="Auto">
<StackPanel Margin="30" VerticalAlignment="Center" Spacing="20">
<TextBlock Text="Create new vault"
FontSize="22"
FontWeight="Bold"
Foreground="#2328C4"/>
<!-- Выбор файла -->
<StackPanel Spacing="10">
<TextBlock Text="File location" FontWeight="SemiBold" Foreground="Black" />
<Grid ColumnDefinitions="*, Auto" ColumnSpacing="10">
<TextBox x:Name="FilePathTextBox"
Grid.Column="0"
Watermark="Select file path..."
Padding="10,8"/>
<Button x:Name="BrowseButton"
Grid.Column="1"
Content="Browse..."
Classes="secondaryButton"
Padding="15,8"
Click="BrowseButton_Click"/>
</Grid>
<TextBlock x:Name="PathWarning"
FontSize="12"
Foreground="Orange"
Text=" "/>
</StackPanel>
<!-- Ввод мастер-пароля -->
<StackPanel Spacing="10">
<TextBlock Text="Master password" FontWeight="SemiBold" Foreground="Black" />
<TextBox x:Name="PasswordBox"
PasswordChar="*"
Watermark="Enter password"
Padding="10,8"/>
</StackPanel>
<StackPanel Spacing="10">
<TextBlock Text="Confirm password" FontWeight="SemiBold" Foreground="Black" />
<TextBox x:Name="ConfirmPasswordBox"
PasswordChar="*"
Watermark="Confirm password"
Padding="10,8"/>
</StackPanel>
<!-- Сообщение об ошибке пароля -->
<TextBlock x:Name="PasswordErrorText"
FontSize="12"
Foreground="Red"
Text=""
IsVisible="False"/>
<!-- Кнопки действий -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10" Margin="0,20,0,0">
<Button Content="Cancel"
Classes="secondaryButton"
Width="80"
Click="CancelButton_Click"/>
<Button x:Name="CreateButton"
Content="Create"
Classes="accentButton"
Width="80"
IsEnabled="False"
Click="CreateButton_Click"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Grid>
</Window>

View File

@@ -0,0 +1,124 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using System;
using System.Threading.Tasks;
namespace KeyKeeper.Views
{
public partial class CreateVaultFileWindow : Window
{
public string FilePath { get; private set; } = string.Empty;
public string Password { get; private set; } = string.Empty;
public bool Success { get; private set; }
public CreateVaultFileWindow()
{
InitializeComponent();
#if DEBUG
this.AttachDevTools();
#endif
FilePathTextBox.TextChanged += OnTextChanged;
PasswordBox.TextChanged += OnPasswordTextChanged;
ConfirmPasswordBox.TextChanged += OnPasswordTextChanged;
}
private async void OnTextChanged(object? sender, TextChangedEventArgs e)
{
UpdateCreateButtonState();
PathWarning.Text = "";
string path = FilePathTextBox.Text ?? "";
if (string.IsNullOrWhiteSpace(path))
return;
try
{
var storageFile = await StorageProvider.TryGetFileFromPathAsync(path);
if (storageFile != null)
{
PathWarning.Text = "File already exists. It will be overwritten.";
}
}
catch
{
}
}
private void OnPasswordTextChanged(object? sender, TextChangedEventArgs e)
{
UpdateCreateButtonState();
PasswordErrorText.IsVisible = false;
}
private void UpdateCreateButtonState()
{
bool pathValid = !string.IsNullOrWhiteSpace(FilePathTextBox.Text);
bool passwordsEntered = !string.IsNullOrWhiteSpace(PasswordBox.Text) &&
!string.IsNullOrWhiteSpace(ConfirmPasswordBox.Text);
CreateButton.IsEnabled = pathValid && passwordsEntered;
}
private async void BrowseButton_Click(object? sender, RoutedEventArgs e)
{
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Create new password store",
SuggestedFileName = "passwords.kkp",
DefaultExtension = "kkp",
FileTypeChoices = new[]
{
new FilePickerFileType("KeyKeeper files")
{
Patterns = new[] { "*.kkp" }
}
}
});
if (file?.TryGetLocalPath() is string path)
{
FilePathTextBox.Text = path;
}
}
private void CreateButton_Click(object? sender, RoutedEventArgs e)
{
string path = FilePathTextBox.Text ?? "";
if (string.IsNullOrWhiteSpace(path))
return;
string password = PasswordBox.Text ?? "";
string confirm = ConfirmPasswordBox.Text ?? "";
if (string.IsNullOrEmpty(password) || string.IsNullOrEmpty(confirm))
{
ShowPasswordError("Password cannot be empty");
return;
}
if (password != confirm)
{
ShowPasswordError("Passwords don't match");
return;
}
FilePath = path;
Password = password;
Success = true;
Close();
}
private void ShowPasswordError(string message)
{
PasswordErrorText.Text = message;
PasswordErrorText.IsVisible = true;
}
private void CancelButton_Click(object? sender, RoutedEventArgs e)
{
Success = false;
Close();
}
}
}

View File

@@ -11,6 +11,7 @@ namespace KeyKeeper.Views;
public partial class EntryEditWindow : Window
{
public PassStoreEntryPassword? EditedEntry;
private PassStoreEntryPassword? _originalEntry;
public EntryEditWindow()
{
@@ -21,6 +22,14 @@ public partial class EntryEditWindow : Window
}
}
public void SetEntry(PassStoreEntryPassword entry)
{
_originalEntry = entry;
EntryNameEdit.Text = entry.Name;
UsernameEdit.Text = entry.Username.Value;
PasswordEdit.Text = entry.Password.Value;
}
private void PasswordTextChanged(object? sender, TextChangedEventArgs e)
{
string password = PasswordEdit?.Text ?? "";
@@ -39,49 +48,34 @@ public partial class EntryEditWindow : Window
}
int strength = CalculatePasswordStrength(password);
double maxWidth = PasswordStrengthIndicator.Bounds.Width;
if (maxWidth <= 0) maxWidth = 200;
if (maxWidth <= 0) maxWidth = 200;
PasswordStrengthFill.Width = (strength / 100.0) * maxWidth;
if (strength < 20)
{
PasswordStrengthFill.Background = new SolidColorBrush(Colors.Red);
}
else if (strength < 50)
{
PasswordStrengthFill.Background = new SolidColorBrush(Colors.Orange);
}
else if (strength < 70)
{
PasswordStrengthFill.Background = new SolidColorBrush(Colors.Gold);
}
else
{
PasswordStrengthFill.Background = new SolidColorBrush(Colors.Green);
}
}
private int CalculatePasswordStrength(string password)
{
int score = 0;
if (password.Length >= 8) score += 20;
if (password.Length >= 12) score += 20;
if (password.Length >= 16) score += 15;
if (Regex.IsMatch(password, @"\d")) score += 10;
if (Regex.IsMatch(password, @"[a-z]")) score += 15;
if (Regex.IsMatch(password, @"[A-Z]")) score += 15;
if (Regex.IsMatch(password, @"[!@#$%^&*()_+\-=\[\]{};':""\\|,.<>\/?]")) score += 20;
var uniqueChars = new System.Collections.Generic.HashSet<char>(password).Count;
score += Math.Min(20, uniqueChars * 2);
return Math.Min(100, score);
}
@@ -96,22 +90,17 @@ public partial class EntryEditWindow : Window
string password = PasswordEdit?.Text ?? "";
if (string.IsNullOrEmpty(password)) return;
Guid id = _originalEntry?.Id ?? Guid.NewGuid();
DateTime created = DateTime.UtcNow;
EditedEntry = new PassStoreEntryPassword(
Guid.NewGuid(),
DateTime.UtcNow,
id,
created,
DateTime.UtcNow,
EntryIconType.DEFAULT,
name,
new LoginField()
{
Type = LOGIN_FIELD_USERNAME_ID,
Value = username
},
new LoginField()
{
Type = LOGIN_FIELD_PASSWORD_ID,
Value = password
},
new LoginField() { Type = LOGIN_FIELD_USERNAME_ID, Value = username },
new LoginField() { Type = LOGIN_FIELD_PASSWORD_ID, Value = password },
null
);
Close();

View File

@@ -22,41 +22,26 @@ namespace KeyKeeper.Views
private async void CreateNewVault_Click(object sender, RoutedEventArgs e)
{
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Create new password store",
SuggestedFileName = "passwords.kkp",
DefaultExtension = "kkp",
FileTypeChoices = new[]
{
new FilePickerFileType("KeyKeeper files")
{
Patterns = new[] { "*.kkp" }
}
}
});
var createVaultDialog = new CreateVaultFileWindow();
await createVaultDialog.ShowDialog(this);
if (file != null)
if (createVaultDialog.Success &&
!string.IsNullOrEmpty(createVaultDialog.FilePath) &&
!string.IsNullOrEmpty(createVaultDialog.Password))
{
if (file.TryGetLocalPath() is string path)
{
var passwordDialog = new PasswordDialog();
await passwordDialog.ShowDialog(this);
if (passwordDialog.Created && !string.IsNullOrEmpty(passwordDialog.Password))
var path = createVaultDialog.FilePath;
var password = createVaultDialog.Password;
var compositeKey = new CompositeKey(password, null);
var passStoreAccessor = new PassStoreFileAccessor(
filename: path,
create: true,
createOptions: new StoreCreationOptions()
{
var compositeKey = new CompositeKey(passwordDialog.Password, null);
var passStoreAccessor = new PassStoreFileAccessor(
filename: path,
create: true,
createOptions: new StoreCreationOptions()
{
Key = compositeKey,
LockTimeoutSeconds = 800
});
IPassStore passStore = passStoreAccessor;
OpenRepositoryWindow(passStore);
}
}
Key = compositeKey,
LockTimeoutSeconds = 800
});
IPassStore passStore = passStoreAccessor;
OpenRepositoryWindow(passStore);
}
}

View File

@@ -1,54 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="KeyKeeper.Views.PasswordDialog"
Title="Создание хранилища"
Width="350"
Height="230">
<StackPanel Margin="20" VerticalAlignment="Center">
<TextBlock Text="Set Master Password"
FontSize="16"
FontWeight="Bold"
Margin="0,0,0,15"/>
<!-- Поле для пароля (звездочки) -->
<StackPanel Margin="0,0,0,10">
<TextBlock Text="Password:" Margin="0,0,0,5"/>
<TextBox x:Name="PasswordBox"
PasswordChar="*"
Width="250"/>
</StackPanel>
<!-- Поле для подтверждения (звездочки) -->
<StackPanel Margin="0,0,0,15">
<TextBlock Text="Confirm Password:" Margin="0,0,0,5"/>
<TextBox x:Name="ConfirmPasswordBox"
PasswordChar="*"
Width="250"/>
</StackPanel>
<!-- Сообщение об ошибке -->
<TextBlock x:Name="ErrorText"
Text="Passwords do not match"
Foreground="Red"
FontSize="12"
Margin="0,0,0,10"
IsVisible="False"/>
<!-- Кнопки -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="10">
<Button Content="Cancel"
Width="80"
Height="30"
Click="CancelButton_Click"/>
<Button Content="Create"
Width="80"
Height="30"
Click="CreateButton_Click"/>
</StackPanel>
</StackPanel>
</Window>

View File

@@ -1,65 +0,0 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Interactivity;
namespace KeyKeeper.Views
{
public partial class PasswordDialog : Window
{
public string Password { get; private set; } = "";
public bool Created { get; private set; } = false;
public PasswordDialog()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private void CreateButton_Click(object sender, RoutedEventArgs e)
{
var passwordBox = this.FindControl<TextBox>("PasswordBox");
var confirmBox = this.FindControl<TextBox>("ConfirmPasswordBox");
var errorText = this.FindControl<TextBlock>("ErrorText");
string password = passwordBox?.Text ?? "";
string confirmPassword = confirmBox?.Text ?? "";
if (string.IsNullOrEmpty(password) || string.IsNullOrEmpty(confirmPassword))
{
ShowError("Password cannot be empty");
return;
}
if (password != confirmPassword)
{
ShowError("Passwords don't match");
return;
}
Password = password;
Created = true;
Close();
}
private void ShowError(string message)
{
var errorText = this.FindControl<TextBlock>("ErrorText");
if (errorText != null)
{
errorText.Text = message;
errorText.IsVisible = true;
}
}
private void CancelButton_Click(object sender, RoutedEventArgs e)
{
Created = false;
Close();
}
}
}

View File

@@ -4,6 +4,7 @@
xmlns:i="using:Avalonia.Interactivity"
xmlns:kkp="using:KeyKeeper.Views"
x:Class="KeyKeeper.Views.RepositoryWindow"
Closing="RepositoryWindow_Closing"
Title="KeyKeeper - Password store"
CanResize="False"
Width="800"
@@ -11,21 +12,21 @@
Background="White"
x:DataType="vm:RepositoryWindowViewModel">
<Window.DataTemplates>
<DataTemplate DataType="{x:Type vm:UnlockedRepositoryViewModel}">
<Grid>
<!-- Синий левый край -->
<Border Width="200"
Background="#2328C4"
HorizontalAlignment="Left"
VerticalAlignment="Stretch"/>
<StackPanel Margin="20" HorizontalAlignment="Left">
<!-- Надпись KeyKeeper -->
<TextBlock Text="KeyKeeper"
FontSize="32"
FontWeight="Bold"
HorizontalAlignment="Left"
Margin="0,0,0,8"/>
<Window.DataTemplates>
<DataTemplate DataType="{x:Type vm:UnlockedRepositoryViewModel}">
<Grid>
<!-- Синий левый край -->
<Border Width="200"
Background="#2328C4"
HorizontalAlignment="Left"
VerticalAlignment="Stretch"/>
<StackPanel Margin="20" HorizontalAlignment="Left">
<!-- Надпись KeyKeeper -->
<TextBlock Text="KeyKeeper"
FontSize="32"
FontWeight="Bold"
HorizontalAlignment="Left"
Margin="0,0,0,20"/>
<!-- Таймер блокировки под надписью KeyKeeper -->
<Border Background="#CC000000"
@@ -51,21 +52,21 @@
</Border>
<!-- Рамочка -->
<Border BorderBrush="Gray"
<!-- <Border BorderBrush="Gray"
BorderThickness="1"
CornerRadius="5"
Padding="20"
Background="#F5F5F5"
HorizontalAlignment="Left">
<StackPanel HorizontalAlignment="Left">
<Button Content="All Passwords"
Width="120"
Height="30"
HorizontalAlignment="Left"/>
</StackPanel>
<StackPanel HorizontalAlignment="Left">
<Button Content="All Passwords"
Width="120"
Height="30"
HorizontalAlignment="Left"/>
</StackPanel>
</Border>
</Border> -->
<!-- Save Passwords -->
<Button Content="Save Passwords"
Classes="accentSidebarButton"
@@ -74,73 +75,93 @@
HorizontalAlignment="Left"
Margin="0,20,0,0"/>
<Button Content="New Entry"
Classes="accentSidebarButton"
Click="AddEntryButton_Click"
Height="30"
HorizontalAlignment="Left"
Margin="0,20,0,0"/>
</StackPanel>
<ListBox Width="580"
Margin="210 10 10 10"
ItemsSource="{Binding Passwords}"
Background="Transparent">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<!-- New Entry -->
<Button Content="New Entry"
Classes="accentSidebarButton"
Click="AddEntryButton_Click"
Height="30"
HorizontalAlignment="Left"
Margin="0,20,0,0"/>
<ListBox.ItemTemplate>
<DataTemplate>
<Border Background="Transparent" DoubleTapped="Entry_DoubleTapped">
<StackPanel Width="100"
Margin="10"
HorizontalAlignment="Center">
<Svg Path="{Binding IconPath}" Width="48" Height="48"
/>
<TextBlock Text="{Binding Name}"
HorizontalAlignment="Center"
Foreground="Black" />
<TextBlock Text="{Binding Username.Value}"
Foreground="#666"
HorizontalAlignment="Center" />
</StackPanel>
<Border.ContextMenu>
<ContextMenu>
<MenuItem Name="entryCtxMenuCopyUsername" Header="Copy username" Click="EntryContextMenuItem_Click"/>
<MenuItem Name="entryCtxMenuCopyPassword" Header="Copy password" Click="EntryContextMenuItem_Click"/>
<MenuItem Name="entryCtxMenuDelete" Header="Delete" Click="EntryContextMenuItem_Click"/>
</ContextMenu>
</Border.ContextMenu>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<kkp:ToastNotificationHost x:Name="NotificationHost" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="20" Duration="0:0:2" />
</Grid>
</DataTemplate>
<DataTemplate DataType="{x:Type vm:LockedRepositoryViewModel}">
<StackPanel Margin="20"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="10">
<TextBlock Text="Enter credentials to unlock"
Foreground="#2328C4"
FontSize="32" />
<!-- Edit Selected Entry -->
<Button Content="Edit Selected Entry"
Classes="accentSidebarButton"
Click="EditEntryButton_Click"
Height="30"
HorizontalAlignment="Left"
Margin="0,20,0,0"/>
</StackPanel>
<TextBox x:Name="UnlockPasswordEdit"
Text="{Binding UnlockPassword, Mode=TwoWay}"
PasswordChar="*"
Width="450" />
<!-- ListBox с паролями -->
<ListBox x:Name="PasswordsListBox"
Width="580"
Margin="210 10 10 10"
ItemsSource="{Binding Passwords}"
Background="Transparent"
SelectionMode="Single">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<Button x:Name="UnlockButton"
Command="{Binding TryUnlock}"
HorizontalAlignment="Center"
Content="Unlock!" />
<ListBox.ItemTemplate>
<DataTemplate>
<Border Background="Transparent" DoubleTapped="Entry_DoubleTapped">
<StackPanel Width="100"
Margin="10"
HorizontalAlignment="Center">
<Svg Path="{Binding IconPath}" Width="48" Height="48"/>
<TextBlock Text="{Binding Name}"
HorizontalAlignment="Center"
Foreground="Black" />
<TextBlock Text="{Binding Username.Value}"
Foreground="#666"
HorizontalAlignment="Center" />
</StackPanel>
<Border.ContextMenu>
<ContextMenu>
<MenuItem Name="entryCtxMenuCopyUsername" Header="Copy username" Click="EntryContextMenuItem_Click"/>
<MenuItem Name="entryCtxMenuCopyPassword" Header="Copy password" Click="EntryContextMenuItem_Click"/>
<!-- Новый пункт меню "Edit" -->
<MenuItem Name="entryCtxMenuEdit" Header="Edit" Click="EntryContextMenuItem_Click"/>
<MenuItem Name="entryCtxMenuDelete" Header="Delete" Click="EntryContextMenuItem_Click"/>
</ContextMenu>
</Border.ContextMenu>
</Border>
</DataTemplate>
</Window.DataTemplates>
</ListBox.ItemTemplate>
</ListBox>
<ContentControl Content="{Binding CurrentPage}"/>
<kkp:ToastNotificationHost x:Name="NotificationHost"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="20"
Duration="0:0:2" />
</Grid>
</DataTemplate>
<DataTemplate DataType="{x:Type vm:LockedRepositoryViewModel}">
<StackPanel Margin="20"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="10">
<TextBlock Text="Enter credentials to unlock"
Foreground="#2328C4"
FontSize="32" />
<TextBox x:Name="UnlockPasswordEdit"
Text="{Binding UnlockPassword, Mode=TwoWay}"
PasswordChar="*"
Width="450" />
<Button x:Name="UnlockButton"
Command="{Binding TryUnlock}"
HorizontalAlignment="Center"
Content="Unlock!" />
</StackPanel>
</DataTemplate>
</Window.DataTemplates>
<ContentControl Content="{Binding CurrentPage}"/>
</Window>

View File

@@ -12,6 +12,9 @@ namespace KeyKeeper.Views;
public partial class RepositoryWindow : Window
{
private bool allowClose;
private bool closeConfirmationShown;
public RepositoryWindow(RepositoryWindowViewModel model)
{
InitializeComponent();
@@ -36,6 +39,48 @@ public partial class RepositoryWindow : Window
vm.ResetLockTimer();
}
private async void RepositoryWindow_Closing(object? sender, WindowClosingEventArgs e)
{
if (allowClose || closeConfirmationShown)
{
return;
}
if (DataContext is RepositoryWindowViewModel checkVm)
{
if ((checkVm.CurrentPage is UnlockedRepositoryViewModel unlockedVm &&
!unlockedVm.HasUnsavedChanges)
|| checkVm.CurrentPage is LockedRepositoryViewModel)
{
allowClose = true;
return;
}
}
e.Cancel = true;
closeConfirmationShown = true;
var dialog = new CloseConfirmationDialog();
var result = await dialog.ShowDialog<CloseConfirmationResult?>(this);
closeConfirmationShown = false;
if (result == null || result == CloseConfirmationResult.Cancel)
{
return;
}
if (result == CloseConfirmationResult.Save &&
DataContext is RepositoryWindowViewModel vm &&
vm.CurrentPage is UnlockedRepositoryViewModel pageVm)
{
pageVm.Save();
}
allowClose = true;
Close();
}
private async void AddEntryButton_Click(object sender, RoutedEventArgs args)
{
if (DataContext is RepositoryWindowViewModel vm_ && vm_.CurrentPage is UnlockedRepositoryViewModel vm)
@@ -53,6 +98,36 @@ public partial class RepositoryWindow : Window
}
}
private async void EditEntryButton_Click(object sender, RoutedEventArgs args)
{
if (DataContext is RepositoryWindowViewModel vm_ && vm_.CurrentPage is UnlockedRepositoryViewModel vm)
{
var listBox = this.FindControlRecursive<ListBox>("PasswordsListBox");
if (listBox == null)
{
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("ListBox not found");
return;
}
var selectedEntry = listBox.SelectedItem as PassStoreEntryPassword;
if (selectedEntry == null)
{
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("No entry selected");
return;
}
EntryEditWindow dialog = new();
dialog.SetEntry(selectedEntry);
await dialog.ShowDialog(this);
if (dialog.EditedEntry != null)
{
vm.UpdateEntry(dialog.EditedEntry);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Entry updated");
}
}
}
private void SaveButton_Click(object sender, RoutedEventArgs args)
{
if (DataContext is RepositoryWindowViewModel vm && vm.CurrentPage is UnlockedRepositoryViewModel pageVm)
@@ -63,41 +138,46 @@ public partial class RepositoryWindow : Window
private void Entry_DoubleTapped(object sender, TappedEventArgs args)
{
if (args.Source is StyledElement s)
if (args.Source is StyledElement s && s.DataContext is PassStoreEntryPassword pwd)
{
if (s.DataContext is PassStoreEntryPassword pwd)
Clipboard!.SetTextAsync(pwd.Password.Value);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Password copied to clipboard");
}
}
private async void EntryContextMenuItem_Click(object sender, RoutedEventArgs args)
{
if (args.Source is StyledElement s && s.DataContext is PassStoreEntryPassword pwd)
{
if (s.Name == "entryCtxMenuCopyUsername")
{
Clipboard!.SetTextAsync(pwd.Username.Value);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Username copied to clipboard");
}
else if (s.Name == "entryCtxMenuCopyPassword")
{
Clipboard!.SetTextAsync(pwd.Password.Value);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Password copied to clipboard");
}
}
}
private void EntryContextMenuItem_Click(object sender, RoutedEventArgs args)
{
if (args.Source is StyledElement s)
{
if (s.DataContext is PassStoreEntryPassword pwd)
else if (s.Name == "entryCtxMenuEdit")
{
if (s.Name == "entryCtxMenuCopyUsername")
if (DataContext is RepositoryWindowViewModel vm && vm.CurrentPage is UnlockedRepositoryViewModel pageVm)
{
Clipboard!.SetTextAsync(pwd.Username.Value);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Username copied to clipboard");
}
else if (s.Name == "entryCtxMenuCopyPassword")
{
Clipboard!.SetTextAsync(pwd.Password.Value);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Password copied to clipboard");
}
else if (s.Name == "entryCtxMenuDelete")
{
if (s.DataContext is PassStoreEntryPassword entry)
EntryEditWindow dialog = new();
dialog.SetEntry(pwd);
await dialog.ShowDialog(this);
if (dialog.EditedEntry != null)
{
if (DataContext is RepositoryWindowViewModel vm && vm.CurrentPage is UnlockedRepositoryViewModel pageVm)
{
pageVm.DeleteEntry(entry.Id);
}
pageVm.UpdateEntry(dialog.EditedEntry);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Entry updated");
}
}
}
else if (s.Name == "entryCtxMenuDelete")
{
if (DataContext is RepositoryWindowViewModel vm && vm.CurrentPage is UnlockedRepositoryViewModel pageVm)
{
pageVm.DeleteEntry(pwd.Id);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Entry deleted");
}
}
@@ -114,34 +194,26 @@ public static class VisualTreeExtensions
private static T? FindControlRecursiveInternal<T>(Visual parent, string name, int depth) where T : Visual
{
if (parent == null)
return null;
if (parent is T t && parent.Name == name)
return t;
if (parent == null) return null;
if (parent is T t && parent.Name == name) return t;
foreach (var child in parent.GetVisualChildren())
{
if (child == null)
continue;
if (child == null) continue;
var result = FindControlRecursiveInternal<T>(child, name, depth + 1);
if (result != null)
return result;
if (result != null) return result;
}
// Also check logical children if they're not in visual tree
if (parent is ContentPresenter contentPresenter)
{
var content = contentPresenter.Content as Visual;
if (content != null)
{
var result = FindControlRecursiveInternal<T>(content, name, depth + 1);
if (result != null)
return result;
if (result != null) return result;
}
}
return null;
}
}
}