merge branch 'feature/password-groups'

This commit is contained in:
2026-05-08 13:16:59 +03:00
20 changed files with 991 additions and 123 deletions

View File

@@ -0,0 +1,5 @@
using System;
namespace KeyKeeper.Models;
public record GroupEditData(string Name, Guid IconType);

View File

@@ -2,7 +2,7 @@ using System;
namespace KeyKeeper.PasswordStore; namespace KeyKeeper.PasswordStore;
public static class EntryIconType public static class BuiltinEntryIconType
{ {
public static readonly Guid KEY = Guid.Parse("65ab3d55-1652-4f66-aac9-c3617f14e308"); public static readonly Guid KEY = Guid.Parse("65ab3d55-1652-4f66-aac9-c3617f14e308");
public static readonly Guid DEFAULT = KEY; public static readonly Guid DEFAULT = KEY;

View File

@@ -19,6 +19,7 @@ static class FileFormatConstants
public const uint FILE_FIELD_END = 0x010ba81a; public const uint FILE_FIELD_END = 0x010ba81a;
public const byte ENTRY_PASS_ID = 0x00; public const byte ENTRY_PASS_ID = 0x00;
public const byte ENTRY_GROUP_ID = 0x01; public const byte ENTRY_GROUP_ID = 0x01;
public const byte ENTRY_LINK_ID = 0x02;
public const byte LOGIN_FIELD_PASSWORD_ID = 0x00; public const byte LOGIN_FIELD_PASSWORD_ID = 0x00;
public const byte LOGIN_FIELD_USERNAME_ID = 0x01; public const byte LOGIN_FIELD_USERNAME_ID = 0x01;
public const byte LOGIN_FIELD_EMAIL_ID = 0x02; public const byte LOGIN_FIELD_EMAIL_ID = 0x02;

View File

@@ -1,3 +1,4 @@
using System;
using KeyKeeper.PasswordStore.Crypto; using KeyKeeper.PasswordStore.Crypto;
namespace KeyKeeper.PasswordStore; namespace KeyKeeper.PasswordStore;
@@ -6,10 +7,15 @@ public interface IPassStore
{ {
bool Locked { get; } bool Locked { get; }
IPassStoreDirectory GetRootDirectory(); PassStoreEntryGroup GetRootDirectory();
IPassStoreDirectory? GetGroupByType(byte groupType); PassStoreEntryGroup? GetGroupByType(byte groupType);
public PassStoreEntry GetEntryById(Guid id);
int GetTotalEntryCount(); int GetTotalEntryCount();
void Unlock(CompositeKey key); void Unlock(CompositeKey key);
void Lock(); void Lock();
void Save(); void Save();
bool DeleteEntry(PassStoreEntryGroup? group, Guid id);
void AddEntry(PassStoreEntryGroup group, PassStoreEntry entry);
void UpdateEntry(PassStoreEntryGroup? group, Guid id, PassStoreEntry entry);
} }

View File

@@ -1,11 +0,0 @@
using System;
using System.Collections.Generic;
namespace KeyKeeper.PasswordStore;
public interface IPassStoreDirectory : IEnumerable<PassStoreEntry>
{
bool DeleteEntry(Guid id);
void AddEntry(PassStoreEntry entry);
void UpdateEntry(Guid id, PassStoreEntry entry);
}

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using static KeyKeeper.PasswordStore.FileFormatConstants; using static KeyKeeper.PasswordStore.FileFormatConstants;
@@ -13,6 +14,7 @@ public abstract class PassStoreEntry
public Guid IconType { get; set; } public Guid IconType { get; set; }
public string Name { get; set; } public string Name { get; set; }
public PassStoreEntryType Type { get; set; } public PassStoreEntryType Type { get; set; }
public List<PassStoreEntry> Backlinks = new();
public string IconPath public string IconPath
{ {
get get
@@ -20,6 +22,7 @@ public abstract class PassStoreEntry
return $"avares://KeyKeeper/Assets/builtin-entry-icon-{IconType}.svg"; return $"avares://KeyKeeper/Assets/builtin-entry-icon-{IconType}.svg";
} }
} }
public virtual string DisplayName => Name;
public void WriteToStream(Stream str) public void WriteToStream(Stream str)
{ {
@@ -77,6 +80,9 @@ public abstract class PassStoreEntry
} else if (entryType == ENTRY_PASS_ID) } else if (entryType == ENTRY_PASS_ID)
{ {
return PassStoreEntryPassword.ReadFromStream(entryStream, id, createdAt, modifiedAt, iconType, name); return PassStoreEntryPassword.ReadFromStream(entryStream, id, createdAt, modifiedAt, iconType, name);
} else if (entryType == ENTRY_LINK_ID)
{
return PassStoreEntryLink.ReadFromStream(entryStream, id, createdAt, modifiedAt, iconType, name);
} else } else
{ {
throw PassStoreFileException.InvalidPassStoreEntry; throw PassStoreFileException.InvalidPassStoreEntry;

View File

@@ -2,16 +2,27 @@ using System;
using System.Collections; using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using static KeyKeeper.PasswordStore.FileFormatConstants; using static KeyKeeper.PasswordStore.FileFormatConstants;
namespace KeyKeeper.PasswordStore; namespace KeyKeeper.PasswordStore;
public class PassStoreEntryGroup : PassStoreEntry, IPassStoreDirectory public class PassStoreEntryGroup : PassStoreEntry, IEnumerable<PassStoreEntry>
{ {
public byte GroupType { get; set; } public byte GroupType { get; set; }
public Guid? CustomGroupSubtype { get; set; } public Guid? CustomGroupSubtype { get; set; }
public List<PassStoreEntry> ChildEntries { get; set; } public List<PassStoreEntry> ChildEntries { get; set; }
public override string DisplayName => GroupType switch
{
GROUP_TYPE_DEFAULT => "All Passwords",
GROUP_TYPE_FAVOURITES => "Favourites",
GROUP_TYPE_ROOT => ":root:",
_ => Name
};
public IEnumerable<PassStoreEntryGroup> ChildGroups => ChildEntries.OfType<PassStoreEntryGroup>();
public PassStoreEntryGroup(Guid id, DateTime createdAt, DateTime modifiedAt, public PassStoreEntryGroup(Guid id, DateTime createdAt, DateTime modifiedAt,
Guid iconType, string name, byte groupType, Guid iconType, string name, byte groupType,
List<PassStoreEntry>? children = null, List<PassStoreEntry>? children = null,
@@ -64,42 +75,6 @@ public class PassStoreEntryGroup : PassStoreEntry, IPassStoreDirectory
} }
} }
public void AddEntry(PassStoreEntry entry)
{
entry.Parent = this;
ChildEntries.Add(entry);
}
public bool DeleteEntry(Guid id)
{
if (ChildEntries == null)
return false;
for (int i = 0; i < ChildEntries.Count; i++)
{
if (ChildEntries[i].Id == id)
{
ChildEntries.RemoveAt(i);
return true;
}
}
return false;
}
public void UpdateEntry(Guid id, PassStoreEntry entry)
{
entry.Parent = this;
if (ChildEntries == null)
return;
for (int i = 0; i < ChildEntries.Count; i++)
{
if (ChildEntries[i].Id == id)
{
ChildEntries[i] = entry;
return;
}
}
}
IEnumerator<PassStoreEntry> IEnumerable<PassStoreEntry>.GetEnumerator() IEnumerator<PassStoreEntry> IEnumerable<PassStoreEntry>.GetEnumerator()
{ {
return ChildEntries.GetEnumerator(); return ChildEntries.GetEnumerator();

View File

@@ -0,0 +1,58 @@
using System;
using System.IO;
using static KeyKeeper.PasswordStore.FileFormatConstants;
namespace KeyKeeper.PasswordStore;
public class PassStoreEntryLink : PassStoreEntry
{
public PassStoreEntry? LinkTarget;
public Guid LinkTargetId;
public override string DisplayName => LinkTarget?.DisplayName ?? Name;
public PassStoreEntryLink(Guid id, DateTime createdAt, DateTime modifiedAt,
Guid targetId, PassStoreEntry? target = null)
{
Id = id;
CreationDate = createdAt;
ModificationDate = modifiedAt;
IconType = BuiltinEntryIconType.DEFAULT;
Name = "";
LinkTargetId = targetId;
LinkTarget = target;
}
public static PassStoreEntry ReadFromStream(Stream str, Guid id, DateTime createdAt, DateTime modifiedAt, Guid iconType, string name)
{
BinaryReader rd = new(str);
try
{
byte[] guidBuffer = new byte[16];
if (rd.Read(guidBuffer) < 16)
throw PassStoreFileException.UnexpectedEndOfFile;
Guid linkTargetId = new(guidBuffer);
return new PassStoreEntryLink(id, createdAt, modifiedAt, linkTargetId);
} catch (EndOfStreamException)
{
throw PassStoreFileException.UnexpectedEndOfFile;
}
}
public override string ToString()
{
return string.Format(
"EntryLink (id={0} target_id={1} target={2})",
Id, LinkTargetId, LinkTarget);
}
protected override byte[] InnerSerialize()
{
MemoryStream str = new();
str.WriteByte(ENTRY_LINK_ID);
str.Write(LinkTargetId.ToByteArray());
return str.ToArray();
}
}

View File

@@ -4,4 +4,5 @@ public enum PassStoreEntryType
{ {
Password, Password,
Directory, Directory,
Link,
} }

View File

@@ -14,20 +14,22 @@ namespace KeyKeeper.PasswordStore;
/// </summary> /// </summary>
public class PassStoreFileAccessor : IPassStore public class PassStoreFileAccessor : IPassStore
{ {
private const ushort FORMAT_VERSION_MAJOR = 0; private const ushort FORMAT_VERSION_MAJOR = 1;
private const ushort FORMAT_VERSION_MINOR = 0; private const ushort FORMAT_VERSION_MINOR = 1;
private static readonly byte[] FORMAT_MAGIC = [0xf5, 0x3a, 0xa4, 0xb7, 0xeb, 0xd9, 0xc2, 0x12]; private static readonly byte[] FORMAT_MAGIC = [0xf5, 0x3a, 0xa4, 0xb7, 0xeb, 0xd9, 0xc2, 0x12];
private string filename; private string filename;
private byte[]? key; private byte[]? key;
private InnerEncryptionInfo? innerCrypto; private InnerEncryptionInfo? innerCrypto;
private OuterEncryptionHeader? outerCryptoHdr; private OuterEncryptionHeader? outerCryptoHdr;
private PassStoreEntry? root; private PassStoreEntryGroup? root;
private Dictionary<Guid, PassStoreEntry> allEntries;
public PassStoreFileAccessor(string filename, bool create, StoreCreationOptions? createOptions) public PassStoreFileAccessor(string filename, bool create, StoreCreationOptions? createOptions)
{ {
this.filename = filename; this.filename = filename;
this.key = null; this.key = null;
this.allEntries = new();
if (!create) if (!create)
{ {
CheckStoreFile(); CheckStoreFile();
@@ -42,14 +44,14 @@ public class PassStoreFileAccessor : IPassStore
get { return key == null; } get { return key == null; }
} }
public IPassStoreDirectory GetRootDirectory() public PassStoreEntryGroup GetRootDirectory()
{ {
if (Locked) if (Locked)
throw new InvalidOperationException(); throw new InvalidOperationException();
return (IPassStoreDirectory)root!; return root!;
} }
public IPassStoreDirectory? GetGroupByType(byte groupType) public PassStoreEntryGroup? GetGroupByType(byte groupType)
{ {
if (Locked) if (Locked)
throw new InvalidOperationException(); throw new InvalidOperationException();
@@ -102,7 +104,9 @@ public class PassStoreFileAccessor : IPassStore
case FILE_FIELD_CONFIG: case FILE_FIELD_CONFIG:
break; break;
case FILE_FIELD_STORE: case FILE_FIELD_STORE:
this.root = PassStoreEntry.ReadFromStream(cryptoReader); root = PassStoreEntry.ReadFromStream(cryptoReader) as PassStoreEntryGroup;
AddEntriesToDict(root!);
ResolveLinks();
break; break;
case FILE_FIELD_END: case FILE_FIELD_END:
end = true; end = true;
@@ -123,6 +127,8 @@ public class PassStoreFileAccessor : IPassStore
Save(); Save();
Array.Fill<byte>(key!, 0); Array.Fill<byte>(key!, 0);
key = null; key = null;
root = null;
allEntries = new();
} }
public void Save() public void Save()
@@ -163,6 +169,97 @@ public class PassStoreFileAccessor : IPassStore
file.SetLength(file.Position); file.SetLength(file.Position);
} }
public void AddEntry(PassStoreEntryGroup group, PassStoreEntry entry)
{
if (Locked) throw new InvalidOperationException("store locked");
entry.Parent = group;
group.ChildEntries.Add(entry);
if (entry is PassStoreEntryLink link)
{
link.LinkTarget ??= allEntries[link.LinkTargetId];
if (link.LinkTarget == null)
throw new ArgumentException("invalid link target");
PassStoreEntry t = link.LinkTarget;
if (!t.Backlinks.Contains(entry))
t.Backlinks.Add(entry);
}
allEntries[entry.Id] = entry;
}
public bool DeleteEntry(PassStoreEntryGroup? group, Guid id)
{
if (Locked) throw new InvalidOperationException("store locked");
if (group == null)
group = allEntries[id]?.Parent;
if (group == null || group.ChildEntries == null)
return false;
var ch = group.ChildEntries;
for (int i = 0; i < ch.Count; i++)
{
PassStoreEntry child = ch[i];
if (child.Id == id)
{
if (child is PassStoreEntryLink lnk && lnk.LinkTarget != null)
{
lnk.LinkTarget.Backlinks.Remove(lnk);
}
while (child.Backlinks.Count > 0)
{
PassStoreEntry bl = child.Backlinks[0];
if (bl is PassStoreEntryLink)
DeleteEntry(bl.Parent, bl.Id);
}
allEntries.Remove(child.Id);
ch.RemoveAt(i);
return true;
}
}
return false;
}
public void UpdateEntry(PassStoreEntryGroup? group, Guid id, PassStoreEntry entry)
{
if (Locked) throw new InvalidOperationException("store locked");
if (group == null)
group = allEntries[id]?.Parent;
if (group == null || group.ChildEntries == null)
return;
entry.Parent = group;
entry.Backlinks = allEntries[id].Backlinks;
foreach (PassStoreEntry bl in entry.Backlinks)
{
if (bl is PassStoreEntryLink lnk)
{
lnk.LinkTarget = entry;
}
}
var ch = group.ChildEntries;
for (int i = 0; i < ch.Count; i++)
{
if (ch[i].Id == id)
{
allEntries.Remove(ch[i].Id); // убрать
allEntries[entry.Id] = entry;
ch[i] = entry;
return;
}
}
}
public PassStoreEntry GetEntryById(Guid id)
{
if (Locked) throw new InvalidOperationException("store locked");
return allEntries[id];
}
/// <summary> /// <summary>
/// Проверяет внешнюю целостность файла хранилища, то есть: /// Проверяет внешнюю целостность файла хранилища, то есть:
/// 1. совпадает сигнатура (magic number) /// 1. совпадает сигнатура (magic number)
@@ -213,6 +310,30 @@ public class PassStoreFileAccessor : IPassStore
OuterEncryptionUtil.CheckOuterEncryptionHeader(file); OuterEncryptionUtil.CheckOuterEncryptionHeader(file);
} }
private void AddEntriesToDict(PassStoreEntryGroup root)
{
allEntries.Add(root.Id, root);
foreach (PassStoreEntry entry in root.ChildEntries)
{
if (entry is PassStoreEntryGroup group)
AddEntriesToDict(group);
else
allEntries.Add(entry.Id, entry);
}
}
private void ResolveLinks()
{
foreach (var kv in allEntries)
{
if (kv.Value is PassStoreEntryLink lnk)
{
lnk.LinkTarget = allEntries[lnk.LinkTargetId];
lnk.LinkTarget.Backlinks.Add(lnk);
}
}
}
private void CreateNewAndUnlock(StoreCreationOptions options) private void CreateNewAndUnlock(StoreCreationOptions options)
{ {
using FileStream file = File.Open(filename, FileMode.Create, FileAccess.Write, FileShare.None); using FileStream file = File.Open(filename, FileMode.Create, FileAccess.Write, FileShare.None);
@@ -257,9 +378,12 @@ public class PassStoreFileAccessor : IPassStore
cryptoWriter.Flush(); cryptoWriter.Flush();
cryptoWriter.Dispose(); cryptoWriter.Dispose();
AddEntriesToDict(root);
ResolveLinks();
} }
private PassStoreEntry WriteInitialStoreTree(OuterEncryptionWriter w) private PassStoreEntryGroup WriteInitialStoreTree(OuterEncryptionWriter w)
{ {
PassStoreEntryGroup defaultGroup = new( PassStoreEntryGroup defaultGroup = new(
Guid.NewGuid(), Guid.NewGuid(),
@@ -269,6 +393,15 @@ public class PassStoreFileAccessor : IPassStore
"", "",
GROUP_TYPE_DEFAULT GROUP_TYPE_DEFAULT
); );
PassStoreEntryGroup favourites = new(
Guid.NewGuid(),
DateTime.UtcNow,
DateTime.UtcNow,
Guid.Empty,
"",
GROUP_TYPE_FAVOURITES
);
PassStoreEntryGroup root = new( PassStoreEntryGroup root = new(
Guid.NewGuid(), Guid.NewGuid(),
DateTime.UtcNow, DateTime.UtcNow,
@@ -276,9 +409,10 @@ public class PassStoreFileAccessor : IPassStore
Guid.Empty, Guid.Empty,
"", "",
GROUP_TYPE_ROOT, GROUP_TYPE_ROOT,
[defaultGroup] [defaultGroup, favourites]
); );
defaultGroup.Parent = root; defaultGroup.Parent = root;
favourites.Parent = root;
root.WriteToStream(w); root.WriteToStream(w);
return root; return root;
} }

View File

@@ -323,7 +323,6 @@ public class EntryEditViewModel : ViewModelBase
} }
catch (Exception) catch (Exception)
{ {
// Validation should have caught this, but handle gracefully
totp = null; totp = null;
} }
} }
@@ -332,7 +331,7 @@ public class EntryEditViewModel : ViewModelBase
id, id,
created, created,
DateTime.UtcNow, DateTime.UtcNow,
EntryIconType.DEFAULT, BuiltinEntryIconType.DEFAULT,
EntryName.Trim(), EntryName.Trim(),
new LoginField() { Type = LOGIN_FIELD_USERNAME_ID, Value = Username.Trim() }, new LoginField() { Type = LOGIN_FIELD_USERNAME_ID, Value = Username.Trim() },
new LoginField() { Type = LOGIN_FIELD_PASSWORD_ID, Value = Password }, new LoginField() { Type = LOGIN_FIELD_PASSWORD_ID, Value = Password },

View File

@@ -10,18 +10,43 @@ namespace KeyKeeper.ViewModels;
public class UnlockedRepositoryViewModel : ViewModelBase public class UnlockedRepositoryViewModel : ViewModelBase
{ {
private IPassStore passStore; private IPassStore passStore;
private IPassStoreDirectory currentDirectory; private PassStoreEntryGroup currentDirectory;
private PassStoreEntryGroup? rootDirectory;
private bool hasUnsavedChanges; private bool hasUnsavedChanges;
private DispatcherTimer? _totpRefreshTimer; private DispatcherTimer? _totpRefreshTimer;
private Dictionary<Guid, string> _totpCodes = new(); private Dictionary<Guid, string> _totpCodes = new();
public IEnumerable<PassStoreEntryPassword> Passwords public IEnumerable<PassStoreEntry> Passwords
{ {
get get
{ {
return currentDirectory return currentDirectory
.Where(entry => entry is PassStoreEntryPassword) .Where(entry => entry is PassStoreEntryPassword || entry is PassStoreEntryLink lnk && lnk.LinkTarget is PassStoreEntryPassword);
.Select(entry => (entry as PassStoreEntryPassword)!); }
}
public IEnumerable<PassStoreEntryGroup> PasswordGroups
{
get
{
if (rootDirectory == null) return [];
return rootDirectory
.Where(entry => entry is PassStoreEntryGroup)
.Select(entry => (entry as PassStoreEntryGroup)!);
}
}
public PassStoreEntryGroup SelectedPasswordGroup
{
get
{
return PasswordGroups.First(group => group == currentDirectory);
}
set
{
if (PasswordGroups.Any(group => group == value))
{
ChangeDirectory(value);
}
} }
} }
@@ -35,10 +60,11 @@ public class UnlockedRepositoryViewModel : ViewModelBase
} }
} }
public UnlockedRepositoryViewModel(IPassStore store, IPassStoreDirectory directory) public UnlockedRepositoryViewModel(IPassStore store, PassStoreEntryGroup group)
{ {
passStore = store; passStore = store;
currentDirectory = directory; currentDirectory = group;
rootDirectory = group.Parent;
HasUnsavedChanges = false; HasUnsavedChanges = false;
InitializeTotpCodes(); InitializeTotpCodes();
StartTotpRefreshTimer(); StartTotpRefreshTimer();
@@ -62,24 +88,118 @@ public class UnlockedRepositoryViewModel : ViewModelBase
{ {
if (entry is PassStoreEntryPassword) if (entry is PassStoreEntryPassword)
{ {
currentDirectory.AddEntry(entry); if (currentDirectory.GroupType == FileFormatConstants.GROUP_TYPE_DEFAULT)
{
passStore.AddEntry(currentDirectory, entry);
} else
{
// добавляем в All Passwords, но оставляем ссылку в текущей папке
passStore.AddEntry(passStore.GetGroupByType(FileFormatConstants.GROUP_TYPE_DEFAULT)!, entry);
passStore.AddEntry(currentDirectory, new PassStoreEntryLink(Guid.NewGuid(), DateTime.Now, DateTime.Now, entry.Id, entry));
}
HasUnsavedChanges = true; HasUnsavedChanges = true;
OnPropertyChanged(nameof(Passwords)); OnPropertyChanged(nameof(Passwords));
} }
} }
public void AddGroup(PassStoreEntryGroup group)
{
if (rootDirectory == null)
return;
passStore.AddEntry(rootDirectory, group);
HasUnsavedChanges = true;
OnPropertyChanged(nameof(PasswordGroups));
}
public void DeleteEntry(Guid id) public void DeleteEntry(Guid id)
{ {
currentDirectory.DeleteEntry(id); PassStoreEntry entry = passStore.GetEntryById(id);
if (entry is PassStoreEntryLink lnk)
{
passStore.DeleteEntry(lnk.LinkTarget!.Parent, lnk.LinkTargetId);
passStore.DeleteEntry(currentDirectory, id);
} else
{
passStore.DeleteEntry(currentDirectory, id);
}
HasUnsavedChanges = true; HasUnsavedChanges = true;
OnPropertyChanged(nameof(Passwords)); OnPropertyChanged(nameof(Passwords));
} }
public void UpdateEntry(PassStoreEntryPassword updatedEntry) public bool AddEntryToGroup(PassStoreEntry entry, PassStoreEntryGroup targetGroup)
{ {
currentDirectory.UpdateEntry(updatedEntry.Id, updatedEntry); PassStoreEntryPassword? pwd = FollowLinkIfNeeded(entry);
if (pwd == null) return false;
foreach (var bl in pwd.Backlinks)
{
if (bl is PassStoreEntryLink lnk && lnk.Parent == targetGroup)
return false;
}
passStore.AddEntry(targetGroup, new PassStoreEntryLink(Guid.NewGuid(), DateTime.Now, DateTime.Now, pwd.Id, pwd));
HasUnsavedChanges = true; HasUnsavedChanges = true;
OnPropertyChanged(nameof(Passwords)); OnPropertyChanged(nameof(Passwords));
return true;
}
public void RemoveEntryFromGroup(PassStoreEntry entry)
{
if (currentDirectory.GroupType == FileFormatConstants.GROUP_TYPE_DEFAULT)
return;
passStore.DeleteEntry(currentDirectory, entry.Id);
HasUnsavedChanges = true;
OnPropertyChanged(nameof(Passwords));
}
public void RemoveEntryFromFavourites(PassStoreEntry entry)
{
var favouritesGroup = PasswordGroups.FirstOrDefault(g => g.GroupType == FileFormatConstants.GROUP_TYPE_FAVOURITES);
if (favouritesGroup == null)
return;
PassStoreEntryPassword? pwd = FollowLinkIfNeeded(entry);
if (pwd == null)
return;
var linkToRemove = pwd.Backlinks.FirstOrDefault(bl => bl is PassStoreEntryLink lnk && lnk.Parent == favouritesGroup);
if (linkToRemove != null)
{
passStore.DeleteEntry(favouritesGroup, linkToRemove.Id);
HasUnsavedChanges = true;
OnPropertyChanged(nameof(Passwords));
}
}
public void UpdateEntry(PassStoreEntryPassword updatedEntry)
{
passStore.UpdateEntry(null, updatedEntry.Id, updatedEntry);
HasUnsavedChanges = true;
OnPropertyChanged(nameof(Passwords));
}
public void UpdateGroup(PassStoreEntryGroup group, string newName, Guid newIconType)
{
group.Name = newName;
group.IconType = newIconType;
group.ModificationDate = DateTime.UtcNow;
HasUnsavedChanges = true;
OnPropertyChanged(nameof(PasswordGroups));
}
public void DeleteGroup(PassStoreEntryGroup group)
{
if (rootDirectory == null)
return;
passStore.DeleteEntry(rootDirectory, group.Id);
if (currentDirectory == group && rootDirectory != null)
{
ChangeDirectory(rootDirectory.ChildGroups.FirstOrDefault() ?? rootDirectory);
}
HasUnsavedChanges = true;
OnPropertyChanged(nameof(PasswordGroups));
} }
public void Save() public void Save()
@@ -88,10 +208,36 @@ public class UnlockedRepositoryViewModel : ViewModelBase
HasUnsavedChanges = false; HasUnsavedChanges = false;
} }
public static PassStoreEntryPassword? FollowLinkIfNeeded(PassStoreEntry entry)
{
if (entry is PassStoreEntryPassword passwordEntry)
{
return passwordEntry;
}
else if (entry is PassStoreEntryLink link && link.LinkTarget is PassStoreEntryPassword t)
{
return t;
}
return null;
}
private void ChangeDirectory(PassStoreEntryGroup newDir)
{
if (newDir == currentDirectory)
return;
currentDirectory = newDir;
InitializeTotpCodes();
StartTotpRefreshTimer();
OnPropertyChanged(nameof(SelectedPasswordGroup));
OnPropertyChanged(nameof(Passwords));
}
private void InitializeTotpCodes() private void InitializeTotpCodes()
{ {
_totpCodes.Clear(); _totpCodes.Clear();
foreach (var entry in Passwords.Where(e => e.Totp != null)) foreach (var entry in Passwords.Select(FollowLinkIfNeeded).Where(e => e?.Totp != null))
{ {
_totpCodes[entry.Id] = TotpCodeGenerator.GenerateCode(entry.Totp!); _totpCodes[entry.Id] = TotpCodeGenerator.GenerateCode(entry.Totp!);
} }
@@ -102,6 +248,10 @@ public class UnlockedRepositoryViewModel : ViewModelBase
// Calculate time until next TOTP period boundary // Calculate time until next TOTP period boundary
int secondsUntilNextCode = CalculateSecondsUntilNextTotpRefresh(); int secondsUntilNextCode = CalculateSecondsUntilNextTotpRefresh();
if (_totpRefreshTimer != null)
{
_totpRefreshTimer.Stop();
}
_totpRefreshTimer = new DispatcherTimer _totpRefreshTimer = new DispatcherTimer
{ {
Interval = TimeSpan.FromSeconds(secondsUntilNextCode) Interval = TimeSpan.FromSeconds(secondsUntilNextCode)
@@ -129,7 +279,7 @@ public class UnlockedRepositoryViewModel : ViewModelBase
private int CalculateSecondsUntilNextTotpRefresh() private int CalculateSecondsUntilNextTotpRefresh()
{ {
// Find the minimum seconds until next code change across all TOTP entries // Find the minimum seconds until next code change across all TOTP entries
var totpEntries = Passwords.Where(e => e.Totp != null).ToList(); var totpEntries = Passwords.Select(FollowLinkIfNeeded).Where(e => e?.Totp != null).ToList();
if (totpEntries.Count == 0) if (totpEntries.Count == 0)
return 60; // Default to 60 seconds if no TOTP entries return 60; // Default to 60 seconds if no TOTP entries

View File

@@ -16,6 +16,7 @@
<StackPanel Grid.Row="1" <StackPanel Grid.Row="1"
Orientation="Horizontal" Orientation="Horizontal"
HorizontalAlignment="Right" HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Spacing="8" Spacing="8"
Margin="0,16,0,0"> Margin="0,16,0,0">
<Button Content="Save" <Button Content="Save"

View File

@@ -0,0 +1,38 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="KeyKeeper.Views.ConfirmationDialog"
Title="Confirm Action"
Background="White"
Icon="/Assets/icon.ico"
Width="380" Height="180"
WindowStartupLocation="CenterOwner"
CanResize="False">
<Grid Margin="20" RowDefinitions="*,*,Auto">
<TextBlock Grid.Row="0"
x:Name="TitleText"
FontSize="18"
FontWeight="Bold"
Foreground="#2328C4"
TextWrapping="Wrap"/>
<TextBlock Grid.Row="1"
x:Name="MessageText"
FontSize="14"
Foreground="Black"
TextWrapping="Wrap"/>
<StackPanel Grid.Row="2" Orientation="Horizontal"
HorizontalAlignment="Right" Spacing="10" VerticalAlignment="Bottom">
<Button Content="Cancel"
Width="80"
Click="CancelButton_Click"/>
<Button x:Name="ConfirmButton"
Content="Confirm"
Width="80"
Click="ConfirmButton_Click"/>
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,40 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace KeyKeeper.Views;
public partial class ConfirmationDialog : Window
{
public bool Confirmed { get; private set; }
public ConfirmationDialog()
{
InitializeComponent();
}
public void SetContent(string title, string message)
{
TitleText.Text = title;
MessageText.Text = message;
ConfirmButton.Content = "Confirm";
}
public void SetContent(string title, string message, string confirmButtonText)
{
TitleText.Text = title;
MessageText.Text = message;
ConfirmButton.Content = confirmButtonText;
}
private void CancelButton_Click(object? sender, RoutedEventArgs e)
{
Confirmed = false;
Close();
}
private void ConfirmButton_Click(object? sender, RoutedEventArgs e)
{
Confirmed = true;
Close();
}
}

View File

@@ -0,0 +1,49 @@
using System;
using System.Globalization;
using Avalonia.Data;
using Avalonia.Data.Converters;
using KeyKeeper.PasswordStore;
namespace KeyKeeper.Views.Converters;
public class PasswordEntryConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
PassStoreEntry? entry = value as PassStoreEntry;
if (entry is PassStoreEntryLink link)
{
entry = link.LinkTarget;
}
if (entry == null)
return value;
if (parameter is string propertyPath && !string.IsNullOrEmpty(propertyPath))
{
try
{
object? current = entry;
foreach (var prop in propertyPath.Split('.'))
{
if (current == null)
return null;
var propInfo = current.GetType().GetProperty(prop);
current = propInfo?.GetValue(current);
}
return current;
}
catch
{
return value;
}
}
return entry;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return BindingNotification.UnsetValue;
}
}

View File

@@ -0,0 +1,61 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="KeyKeeper.Views.CreateGroupDialog"
Title="Create New Group"
Background="White"
Icon="/Assets/icon.ico"
Width="420" Height="320"
WindowStartupLocation="CenterOwner"
CanResize="False">
<StackPanel Margin="20" Spacing="16">
<TextBlock x:Name="TitleText"
Text="Create new group"
FontSize="20"
FontWeight="Bold"
Foreground="#2328C4"/>
<StackPanel Spacing="6">
<TextBlock Text="Group name" FontWeight="SemiBold" Foreground="Black" />
<TextBox x:Name="NameTextBox"
Watermark="Enter group name"
Padding="10,8"/>
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="Icon" FontWeight="SemiBold" Foreground="Black" />
<ListBox x:Name="IconListBox"
Height="80"
SelectionMode="Single"
Background="#F5F5F5">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate x:CompileBindings="False">
<Svg Path="{Binding IconPath}" Width="48" Height="48" Margin="6"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</StackPanel>
<TextBlock x:Name="ErrorText"
FontSize="12"
Foreground="Red"
Text=""
IsVisible="False"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10">
<Button Content="Cancel"
Width="80"
Click="CancelButton_Click"/>
<Button x:Name="ActionButton"
Content="Create"
Width="80"
IsEnabled="False"
Click="ActionButton_Click"/>
</StackPanel>
</StackPanel>
</Window>

View File

@@ -0,0 +1,89 @@
using System;
using System.Collections.Generic;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using KeyKeeper.Models;
using KeyKeeper.PasswordStore;
namespace KeyKeeper.Views;
public partial class CreateGroupDialog : Window
{
public GroupEditData? GroupData { get; private set; }
private record IconChoice(Guid Id)
{
public string IconPath => $"avares://KeyKeeper/Assets/builtin-entry-icon-{Id}.svg";
}
public CreateGroupDialog()
{
InitializeComponent();
var icons = new List<IconChoice>
{
new(BuiltinEntryIconType.KEY),
};
IconListBox.ItemsSource = icons;
IconListBox.SelectedIndex = 0;
NameTextBox.TextChanged += (_, _) => UpdateActionButtonState();
KeyDown += OnKeyDown;
}
public void SetupForEdit(PassStoreEntryGroup group)
{
TitleText.Text = "Edit group";
ActionButton.Content = "Save";
NameTextBox.Text = group.Name;
for (int i = 0; i < IconListBox.ItemCount; i++)
{
if (IconListBox.Items[i] is IconChoice choice && choice.Id == group.IconType)
{
IconListBox.SelectedIndex = i;
break;
}
}
}
private void UpdateActionButtonState()
{
ActionButton.IsEnabled = !string.IsNullOrWhiteSpace(NameTextBox.Text);
}
private void OnKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.Escape)
Close();
else if (e.Key == Key.Enter && ActionButton.IsEnabled)
Submit();
}
private void ActionButton_Click(object? sender, RoutedEventArgs e) => Submit();
private void CancelButton_Click(object? sender, RoutedEventArgs e)
{
GroupData = null;
Close();
}
private void Submit()
{
var name = NameTextBox.Text?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(name))
{
ErrorText.Text = "Name cannot be empty";
ErrorText.IsVisible = true;
return;
}
var iconType = BuiltinEntryIconType.DEFAULT;
if (IconListBox.SelectedItem is IconChoice choice)
iconType = choice.Id;
GroupData = new GroupEditData(name, iconType);
Close();
}
}

View File

@@ -15,6 +15,7 @@
<Window.Resources> <Window.Resources>
<converters:TotpCodeConverter x:Key="TotpCodeConverter" /> <converters:TotpCodeConverter x:Key="TotpCodeConverter" />
<converters:PasswordEntryConverter x:Key="PasswordEntryConverter" />
<converters:StringPresenceToOpacityConverter x:Key="StringPresenceToOpacityConverter" /> <converters:StringPresenceToOpacityConverter x:Key="StringPresenceToOpacityConverter" />
</Window.Resources> </Window.Resources>
@@ -57,22 +58,32 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Рамочка --> <!-- Группы паролей -->
<!-- <Border BorderBrush="Gray" <TreeView x:Name="GroupTree"
BorderThickness="1" ItemsSource="{Binding PasswordGroups}"
CornerRadius="5" SelectedItem="{Binding SelectedPasswordGroup}"
Padding="20" Background="#FFFFFFFF"
Background="#F5F5F5" SelectionMode="Single"
HorizontalAlignment="Left"> ScrollViewer.HorizontalScrollBarVisibility="Disabled" >
<TreeView.ItemTemplate>
<TreeDataTemplate ItemsSource="{Binding ChildGroups}">
<Border Background="Transparent"
DoubleTapped="Entry_DoubleTapped">
<TextBlock Text="{Binding DisplayName}"
Foreground="Black"
TextTrimming="CharacterEllipsis"
MaxWidth="180" />
<Border.ContextMenu>
<ContextMenu>
<MenuItem Name="groupCtxMenuEdit" Header="Edit" Click="GroupContextMenuItem_Click" />
<MenuItem Name="groupCtxMenuDelete" Header="Delete" Click="GroupContextMenuItem_Click" />
</ContextMenu>
</Border.ContextMenu>
</Border>
</TreeDataTemplate>
</TreeView.ItemTemplate>
</TreeView>
<StackPanel HorizontalAlignment="Left">
<Button Content="All Passwords"
Width="120"
Height="30"
HorizontalAlignment="Left"/>
</StackPanel>
</Border> -->
<!-- Save Passwords --> <!-- Save Passwords -->
<Button Content="Save Passwords" <Button Content="Save Passwords"
Classes="accentSidebarButton" Classes="accentSidebarButton"
@@ -96,6 +107,14 @@
Height="30" Height="30"
HorizontalAlignment="Left" HorizontalAlignment="Left"
Margin="0,20,0,0"/> Margin="0,20,0,0"/>
<!-- New Group -->
<Button Content="New Group"
Classes="accentSidebarButton"
Click="AddGroupButton_Click"
Height="30"
HorizontalAlignment="Left"
Margin="0,20,0,0"/>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -122,11 +141,11 @@
Margin="10" Margin="10"
HorizontalAlignment="Center"> HorizontalAlignment="Center">
<Svg Path="{Binding IconPath}" Width="48" Height="48"/> <Svg Path="{Binding ., Converter={StaticResource PasswordEntryConverter}, ConverterParameter=IconPath}" Width="48" Height="48"/>
<TextBlock Text="{Binding Name}" <TextBlock Text="{Binding ., Converter={StaticResource PasswordEntryConverter}, ConverterParameter=Name}"
HorizontalAlignment="Center" HorizontalAlignment="Center"
Foreground="Black" /> Foreground="Black" />
<TextBlock Text="{Binding Username.Value}" <TextBlock Text="{Binding ., Converter={StaticResource PasswordEntryConverter}, ConverterParameter=Username.Value}"
Foreground="#666" Foreground="#666"
HorizontalAlignment="Center" /> HorizontalAlignment="Center" />
@@ -138,17 +157,21 @@
Margin="0,4,0,0"> Margin="0,4,0,0">
<TextBlock.Text> <TextBlock.Text>
<MultiBinding Converter="{StaticResource TotpCodeConverter}"> <MultiBinding Converter="{StaticResource TotpCodeConverter}">
<Binding /> <Binding Converter="{StaticResource PasswordEntryConverter}" />
<Binding Path="DataContext" RelativeSource="{RelativeSource AncestorType=ListBox}" /> <Binding Path="DataContext" RelativeSource="{RelativeSource AncestorType=ListBox}" />
</MultiBinding> </MultiBinding>
</TextBlock.Text> </TextBlock.Text>
</TextBlock> </TextBlock>
</StackPanel> </StackPanel>
<Border.ContextMenu> <Border.ContextMenu>
<ContextMenu> <ContextMenu Opened="EntryContextMenu_Opening">
<MenuItem Name="entryCtxMenuCopyUsername" Header="Copy username" Click="EntryContextMenuItem_Click"/> <MenuItem Name="entryCtxMenuCopyUsername" Header="Copy username" Click="EntryContextMenuItem_Click"/>
<MenuItem Name="entryCtxMenuCopyPassword" Header="Copy password" Click="EntryContextMenuItem_Click"/> <MenuItem Name="entryCtxMenuCopyPassword" Header="Copy password" Click="EntryContextMenuItem_Click"/>
<MenuItem Name="entryCtxMenuEdit" Header="Edit" Click="EntryContextMenuItem_Click"/> <MenuItem Name="entryCtxMenuEdit" Header="Edit" Click="EntryContextMenuItem_Click"/>
<MenuItem Name="entryCtxMenuAddToGroup" Header="Add to group" />
<MenuItem Name="entryCtxMenuRemoveFromGroup" Header="Remove from this group" Click="EntryContextMenuItem_Click" />
<MenuItem Name="entryCtxMenuAddToFavourites" Header="Add to Favourites" Click="EntryContextMenuItem_Click" />
<MenuItem Name="entryCtxMenuRemoveFromFavourites" Header="Remove from Favourites" Click="EntryContextMenuItem_Click" />
<MenuItem Name="entryCtxMenuDelete" Header="Delete" Click="EntryContextMenuItem_Click"/> <MenuItem Name="entryCtxMenuDelete" Header="Delete" Click="EntryContextMenuItem_Click"/>
</ContextMenu> </ContextMenu>
</Border.ContextMenu> </Border.ContextMenu>

View File

@@ -1,9 +1,12 @@
using System; using System;
using System.Linq;
using System.Threading.Tasks;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Input; using Avalonia.Input;
using KeyKeeper.PasswordStore; using KeyKeeper.PasswordStore;
using KeyKeeper.Models;
using KeyKeeper.ViewModels; using KeyKeeper.ViewModels;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using Avalonia.Controls.Presenters; using Avalonia.Controls.Presenters;
@@ -14,6 +17,7 @@ public partial class RepositoryWindow : Window
{ {
private bool allowClose; private bool allowClose;
private bool closeConfirmationShown; private bool closeConfirmationShown;
private PassStoreEntry? _contextMenuEntry;
public RepositoryWindow(RepositoryWindowViewModel model) public RepositoryWindow(RepositoryWindowViewModel model)
{ {
@@ -118,6 +122,37 @@ public partial class RepositoryWindow : Window
} }
} }
private async void EditEntry(RepositoryWindowViewModel vm1, UnlockedRepositoryViewModel vm2, PassStoreEntry entry)
{
if (entry == null)
{
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("No entry selected");
return;
}
EntryEditWindow dialog = new();
PassStoreEntry realEntry = entry;
if (realEntry is PassStoreEntryLink lnk)
realEntry = lnk.LinkTarget!;
dialog.SetEntry((realEntry as PassStoreEntryPassword)!);
vm1.StopLockTimer();
await dialog.ShowDialog(this);
vm1.StartLockTimer();
if (dialog.EditedEntry != null)
{
if (entry is PassStoreEntryLink l)
l.LinkTarget = dialog.EditedEntry;
vm2.UpdateEntry(dialog.EditedEntry);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Entry updated");
}
}
private async void EditEntryButton_Click(object sender, RoutedEventArgs args) private async void EditEntryButton_Click(object sender, RoutedEventArgs args)
{ {
if (DataContext is RepositoryWindowViewModel vm_ && vm_.CurrentPage is UnlockedRepositoryViewModel vm) if (DataContext is RepositoryWindowViewModel vm_ && vm_.CurrentPage is UnlockedRepositoryViewModel vm)
@@ -129,15 +164,17 @@ public partial class RepositoryWindow : Window
return; return;
} }
var selectedEntry = listBox.SelectedItem as PassStoreEntryPassword; PassStoreEntry? entry = listBox.SelectedItem as PassStoreEntry;
if (selectedEntry == null) if (entry == null) return;
{ EditEntry(vm_, vm, entry);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("No entry selected"); }
return; }
}
EntryEditWindow dialog = new(); private async void AddGroupButton_Click(object sender, RoutedEventArgs args)
dialog.SetEntry(selectedEntry); {
if (DataContext is RepositoryWindowViewModel vm_ && vm_.CurrentPage is UnlockedRepositoryViewModel vm)
{
CreateGroupDialog dialog = new();
vm_.StopLockTimer(); vm_.StopLockTimer();
@@ -145,10 +182,18 @@ public partial class RepositoryWindow : Window
vm_.StartLockTimer(); vm_.StartLockTimer();
if (dialog.EditedEntry != null) if (dialog.GroupData != null)
{ {
vm.UpdateEntry(dialog.EditedEntry); var group = new PassStoreEntryGroup(
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Entry updated"); Guid.NewGuid(),
DateTime.UtcNow,
DateTime.UtcNow,
dialog.GroupData.IconType,
dialog.GroupData.Name,
FileFormatConstants.GROUP_TYPE_SIMPLE
);
vm.AddGroup(group);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Group created");
} }
} }
} }
@@ -163,10 +208,9 @@ public partial class RepositoryWindow : Window
private void Entry_DoubleTapped(object sender, TappedEventArgs args) private void Entry_DoubleTapped(object sender, TappedEventArgs args)
{ {
if (args.Source is StyledElement s && s.DataContext is PassStoreEntryPassword pwd) if (args.Source is StyledElement s && s.DataContext is PassStoreEntry ent)
{ {
Clipboard!.SetTextAsync(pwd.Password.Value); CopyPassword(ent);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Password copied to clipboard");
} }
} }
@@ -174,49 +218,248 @@ public partial class RepositoryWindow : Window
{ {
if (args.Key == Key.C && args.KeyModifiers == KeyModifiers.Control) if (args.Key == Key.C && args.KeyModifiers == KeyModifiers.Control)
{ {
if (sender is ListBox list && list.SelectedItem is PassStoreEntryPassword pwd) if (sender is ListBox list && list.SelectedItem is PassStoreEntry ent)
{ {
Clipboard!.SetTextAsync(pwd.Password.Value); CopyPassword(ent);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Password copied to clipboard"); }
}
}
private void CopyPassword(PassStoreEntry ent)
{
PassStoreEntryPassword? pwd = null;
if (ent is PassStoreEntryPassword p) pwd = p;
else if (ent is PassStoreEntryLink lnk && lnk.LinkTarget is PassStoreEntryPassword p1) pwd = p1;
if (pwd == null) return;
Clipboard!.SetTextAsync(pwd.Password.Value);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Password copied to clipboard");
}
private void EntryContextMenu_Opening(object? sender, RoutedEventArgs args)
{
if (sender is not ContextMenu contextMenu || DataContext is not RepositoryWindowViewModel vm ||
vm.CurrentPage is not UnlockedRepositoryViewModel pageVm)
return;
_contextMenuEntry = null;
if (contextMenu.Parent?.Parent is Border border && border.DataContext is PassStoreEntry entry)
{
_contextMenuEntry = entry;
}
var addToGroupItem = contextMenu.Items
.OfType<MenuItem>()
.FirstOrDefault(m => m.Name == "entryCtxMenuAddToGroup");
var removeFromGroupItem = contextMenu.Items
.OfType<MenuItem>()
.FirstOrDefault(m => m.Name == "entryCtxMenuRemoveFromGroup");
var addToFavouritesItem = contextMenu.Items
.OfType<MenuItem>()
.FirstOrDefault(m => m.Name == "entryCtxMenuAddToFavourites");
var removeFromFavouritesItem = contextMenu.Items
.OfType<MenuItem>()
.FirstOrDefault(m => m.Name == "entryCtxMenuRemoveFromFavourites");
var isNonDefaultGroup = pageVm.SelectedPasswordGroup.GroupType != FileFormatConstants.GROUP_TYPE_DEFAULT;
if (removeFromGroupItem != null)
{
removeFromGroupItem.IsVisible = isNonDefaultGroup;
}
// Check if entry is in Favourites group
var favouritesGroup = pageVm.PasswordGroups.FirstOrDefault(g => g.GroupType == FileFormatConstants.GROUP_TYPE_FAVOURITES);
var isInFavourites = false;
if (favouritesGroup != null && _contextMenuEntry != null)
{
PassStoreEntryPassword? pwd = UnlockedRepositoryViewModel.FollowLinkIfNeeded(_contextMenuEntry);
if (pwd != null)
{
isInFavourites = pwd.Backlinks.Any(bl => bl is PassStoreEntryLink lnk && lnk.Parent == favouritesGroup);
}
}
if (addToFavouritesItem != null)
addToFavouritesItem.IsVisible = !isInFavourites;
if (removeFromFavouritesItem != null)
removeFromFavouritesItem.IsVisible = isInFavourites;
if (addToGroupItem == null)
return;
addToGroupItem.Items.Clear();
var nonDefaultGroups = pageVm.PasswordGroups
.Where(g => g.GroupType != FileFormatConstants.GROUP_TYPE_DEFAULT)
.ToList();
EventHandler<RoutedEventArgs> onSubmenuClick = (sender, args) => AddToGroup_Click(sender, args, _contextMenuEntry!);
foreach (var group in nonDefaultGroups)
{
var menuItem = new MenuItem
{
Header = group.DisplayName,
Tag = group
};
menuItem.Click += onSubmenuClick;
addToGroupItem.Items.Add(menuItem);
}
}
private void AddToGroup_Click(object? sender, RoutedEventArgs args, PassStoreEntry entry)
{
if (sender is not MenuItem item || item.Tag is not PassStoreEntryGroup targetGroup)
return;
if (entry == null)
return;
if (DataContext is not RepositoryWindowViewModel vm ||
vm.CurrentPage is not UnlockedRepositoryViewModel pageVm)
return;
var notificationHost = this.FindControlRecursive<ToastNotificationHost>("NotificationHost");
if (pageVm.AddEntryToGroup(entry, targetGroup))
notificationHost?.Show($"Added to {targetGroup.DisplayName}");
else
notificationHost?.Show($"This entry is already in {targetGroup.DisplayName}!");
_contextMenuEntry = null;
}
private async void GroupContextMenuItem_Click(object sender, RoutedEventArgs args)
{
if (args.Source is not StyledElement s || s.DataContext is not PassStoreEntryGroup group)
return;
if (DataContext is not RepositoryWindowViewModel vm ||
vm.CurrentPage is not UnlockedRepositoryViewModel pageVm)
return;
if (group.GroupType == FileFormatConstants.GROUP_TYPE_DEFAULT ||
group.GroupType == FileFormatConstants.GROUP_TYPE_FAVOURITES ||
group.GroupType == FileFormatConstants.GROUP_TYPE_ROOT)
return;
if (s.Name == "groupCtxMenuEdit")
{
await EditGroup(vm, pageVm, group);
}
else if (s.Name == "groupCtxMenuDelete")
{
await DeleteGroup(group);
}
}
private async Task EditGroup(RepositoryWindowViewModel vm, UnlockedRepositoryViewModel pageVm, PassStoreEntryGroup group)
{
CreateGroupDialog dialog = new();
dialog.SetupForEdit(group);
vm.StopLockTimer();
await dialog.ShowDialog(this);
vm.StartLockTimer();
if (dialog.GroupData != null)
{
pageVm.UpdateGroup(group, dialog.GroupData.Name, dialog.GroupData.IconType);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Group updated");
}
}
private async Task DeleteGroup(PassStoreEntryGroup group)
{
ConfirmationDialog confirmDialog = new();
confirmDialog.SetContent(
"Delete Group",
$"Are you sure you want to delete the group '{group.DisplayName}'? This action cannot be undone.",
"Delete"
);
if (DataContext is not RepositoryWindowViewModel vm)
return;
vm.StopLockTimer();
await confirmDialog.ShowDialog(this);
vm.StartLockTimer();
if (confirmDialog.Confirmed)
{
if (vm.CurrentPage is UnlockedRepositoryViewModel pageVm)
{
pageVm.DeleteGroup(group);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Group deleted");
} }
} }
} }
private async void EntryContextMenuItem_Click(object sender, RoutedEventArgs args) private async void EntryContextMenuItem_Click(object sender, RoutedEventArgs args)
{ {
if (args.Source is StyledElement s && s.DataContext is PassStoreEntryPassword pwd) if (args.Source is StyledElement s && s.DataContext is PassStoreEntry ent)
{ {
PassStoreEntryPassword? pwd = UnlockedRepositoryViewModel.FollowLinkIfNeeded(ent);
if (pwd == null) return;
if (s.Name == "entryCtxMenuCopyUsername") if (s.Name == "entryCtxMenuCopyUsername")
{ {
Clipboard!.SetTextAsync(pwd.Username.Value); await Clipboard!.SetTextAsync(pwd.Username.Value);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Username copied to clipboard"); this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Username copied to clipboard");
} }
else if (s.Name == "entryCtxMenuCopyPassword") else if (s.Name == "entryCtxMenuCopyPassword")
{ {
Clipboard!.SetTextAsync(pwd.Password.Value); await Clipboard!.SetTextAsync(pwd.Password.Value);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Password copied to clipboard"); this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Password copied to clipboard");
} }
else if (s.Name == "entryCtxMenuEdit") else if (s.Name == "entryCtxMenuEdit")
{ {
if (DataContext is RepositoryWindowViewModel vm && vm.CurrentPage is UnlockedRepositoryViewModel pageVm) if (DataContext is RepositoryWindowViewModel vm && vm.CurrentPage is UnlockedRepositoryViewModel pageVm)
{ {
EntryEditWindow dialog = new(); EditEntry(vm, pageVm, ent);
dialog.SetEntry(pwd); }
vm.StopLockTimer(); }
await dialog.ShowDialog(this); else if (s.Name == "entryCtxMenuRemoveFromGroup")
vm.StartLockTimer(); {
if (dialog.EditedEntry != null) if (DataContext is RepositoryWindowViewModel vm && vm.CurrentPage is UnlockedRepositoryViewModel pageVm)
{
pageVm.RemoveEntryFromGroup(ent);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Removed from group");
}
}
else if (s.Name == "entryCtxMenuAddToFavourites")
{
if (DataContext is RepositoryWindowViewModel vm && vm.CurrentPage is UnlockedRepositoryViewModel pageVm)
{
var favouritesGroup = pageVm.PasswordGroups.FirstOrDefault(g => g.GroupType == FileFormatConstants.GROUP_TYPE_FAVOURITES);
if (favouritesGroup != null)
{ {
pageVm.UpdateEntry(dialog.EditedEntry); if (pageVm.AddEntryToGroup(ent, favouritesGroup))
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Entry updated"); this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Added to Favourites");
else
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Already in Favourites");
} }
} }
} }
else if (s.Name == "entryCtxMenuRemoveFromFavourites")
{
if (DataContext is RepositoryWindowViewModel vm && vm.CurrentPage is UnlockedRepositoryViewModel pageVm)
{
pageVm.RemoveEntryFromFavourites(ent);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Removed from Favourites");
}
}
else if (s.Name == "entryCtxMenuDelete") else if (s.Name == "entryCtxMenuDelete")
{ {
if (DataContext is RepositoryWindowViewModel vm && vm.CurrentPage is UnlockedRepositoryViewModel pageVm) if (DataContext is RepositoryWindowViewModel vm && vm.CurrentPage is UnlockedRepositoryViewModel pageVm)
{ {
pageVm.DeleteEntry(pwd.Id); pageVm.DeleteEntry(ent.Id);
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Entry deleted"); this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Entry deleted");
} }
} }