mirror of
https://github.com/KeyKeeperApp/KeyKeeper.git
synced 2026-05-19 14:56:34 +03:00
merge branch 'feature/password-groups'
This commit is contained in:
5
src/KeyKeeper/Models/GroupData.cs
Normal file
5
src/KeyKeeper/Models/GroupData.cs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace KeyKeeper.Models;
|
||||||
|
|
||||||
|
public record GroupEditData(string Name, Guid IconType);
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
58
src/KeyKeeper/PasswordStore/PassStoreEntryLink.cs
Normal file
58
src/KeyKeeper/PasswordStore/PassStoreEntryLink.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,4 +4,5 @@ public enum PassStoreEntryType
|
|||||||
{
|
{
|
||||||
Password,
|
Password,
|
||||||
Directory,
|
Directory,
|
||||||
|
Link,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
38
src/KeyKeeper/Views/ConfirmationDialog.axaml
Normal file
38
src/KeyKeeper/Views/ConfirmationDialog.axaml
Normal 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>
|
||||||
40
src/KeyKeeper/Views/ConfirmationDialog.axaml.cs
Normal file
40
src/KeyKeeper/Views/ConfirmationDialog.axaml.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/KeyKeeper/Views/Converters/PasswordEntryConverter.cs
Normal file
49
src/KeyKeeper/Views/Converters/PasswordEntryConverter.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/KeyKeeper/Views/CreateGroupDialog.axaml
Normal file
61
src/KeyKeeper/Views/CreateGroupDialog.axaml
Normal 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>
|
||||||
89
src/KeyKeeper/Views/CreateGroupDialog.axaml.cs
Normal file
89
src/KeyKeeper/Views/CreateGroupDialog.axaml.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
CopyPassword(ent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
Clipboard!.SetTextAsync(pwd.Password.Value);
|
||||||
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Password copied to clipboard");
|
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);
|
|
||||||
vm.StartLockTimer();
|
|
||||||
if (dialog.EditedEntry != null)
|
|
||||||
{
|
|
||||||
pageVm.UpdateEntry(dialog.EditedEntry);
|
|
||||||
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Entry updated");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (s.Name == "entryCtxMenuRemoveFromGroup")
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
if (pageVm.AddEntryToGroup(ent, favouritesGroup))
|
||||||
|
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user