mirror of
https://github.com/KeyKeeperApp/KeyKeeper.git
synced 2026-05-19 23:06:33 +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;
|
||||
|
||||
public static class EntryIconType
|
||||
public static class BuiltinEntryIconType
|
||||
{
|
||||
public static readonly Guid KEY = Guid.Parse("65ab3d55-1652-4f66-aac9-c3617f14e308");
|
||||
public static readonly Guid DEFAULT = KEY;
|
||||
|
||||
@@ -19,6 +19,7 @@ static class FileFormatConstants
|
||||
public const uint FILE_FIELD_END = 0x010ba81a;
|
||||
public const byte ENTRY_PASS_ID = 0x00;
|
||||
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_USERNAME_ID = 0x01;
|
||||
public const byte LOGIN_FIELD_EMAIL_ID = 0x02;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using KeyKeeper.PasswordStore.Crypto;
|
||||
|
||||
namespace KeyKeeper.PasswordStore;
|
||||
@@ -6,10 +7,15 @@ public interface IPassStore
|
||||
{
|
||||
bool Locked { get; }
|
||||
|
||||
IPassStoreDirectory GetRootDirectory();
|
||||
IPassStoreDirectory? GetGroupByType(byte groupType);
|
||||
PassStoreEntryGroup GetRootDirectory();
|
||||
PassStoreEntryGroup? GetGroupByType(byte groupType);
|
||||
public PassStoreEntry GetEntryById(Guid id);
|
||||
int GetTotalEntryCount();
|
||||
void Unlock(CompositeKey key);
|
||||
void Lock();
|
||||
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.Collections.Generic;
|
||||
using System.IO;
|
||||
using static KeyKeeper.PasswordStore.FileFormatConstants;
|
||||
|
||||
@@ -13,6 +14,7 @@ public abstract class PassStoreEntry
|
||||
public Guid IconType { get; set; }
|
||||
public string Name { get; set; }
|
||||
public PassStoreEntryType Type { get; set; }
|
||||
public List<PassStoreEntry> Backlinks = new();
|
||||
public string IconPath
|
||||
{
|
||||
get
|
||||
@@ -20,6 +22,7 @@ public abstract class PassStoreEntry
|
||||
return $"avares://KeyKeeper/Assets/builtin-entry-icon-{IconType}.svg";
|
||||
}
|
||||
}
|
||||
public virtual string DisplayName => Name;
|
||||
|
||||
public void WriteToStream(Stream str)
|
||||
{
|
||||
@@ -77,6 +80,9 @@ public abstract class PassStoreEntry
|
||||
} else if (entryType == ENTRY_PASS_ID)
|
||||
{
|
||||
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
|
||||
{
|
||||
throw PassStoreFileException.InvalidPassStoreEntry;
|
||||
|
||||
@@ -2,16 +2,27 @@ using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using static KeyKeeper.PasswordStore.FileFormatConstants;
|
||||
|
||||
namespace KeyKeeper.PasswordStore;
|
||||
|
||||
public class PassStoreEntryGroup : PassStoreEntry, IPassStoreDirectory
|
||||
public class PassStoreEntryGroup : PassStoreEntry, IEnumerable<PassStoreEntry>
|
||||
{
|
||||
public byte GroupType { get; set; }
|
||||
public Guid? CustomGroupSubtype { 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,
|
||||
Guid iconType, string name, byte groupType,
|
||||
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()
|
||||
{
|
||||
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,
|
||||
Directory,
|
||||
Link,
|
||||
}
|
||||
|
||||
@@ -14,20 +14,22 @@ namespace KeyKeeper.PasswordStore;
|
||||
/// </summary>
|
||||
public class PassStoreFileAccessor : IPassStore
|
||||
{
|
||||
private const ushort FORMAT_VERSION_MAJOR = 0;
|
||||
private const ushort FORMAT_VERSION_MINOR = 0;
|
||||
private const ushort FORMAT_VERSION_MAJOR = 1;
|
||||
private const ushort FORMAT_VERSION_MINOR = 1;
|
||||
private static readonly byte[] FORMAT_MAGIC = [0xf5, 0x3a, 0xa4, 0xb7, 0xeb, 0xd9, 0xc2, 0x12];
|
||||
|
||||
private string filename;
|
||||
private byte[]? key;
|
||||
private InnerEncryptionInfo? innerCrypto;
|
||||
private OuterEncryptionHeader? outerCryptoHdr;
|
||||
private PassStoreEntry? root;
|
||||
private PassStoreEntryGroup? root;
|
||||
private Dictionary<Guid, PassStoreEntry> allEntries;
|
||||
|
||||
public PassStoreFileAccessor(string filename, bool create, StoreCreationOptions? createOptions)
|
||||
{
|
||||
this.filename = filename;
|
||||
this.key = null;
|
||||
this.allEntries = new();
|
||||
if (!create)
|
||||
{
|
||||
CheckStoreFile();
|
||||
@@ -42,14 +44,14 @@ public class PassStoreFileAccessor : IPassStore
|
||||
get { return key == null; }
|
||||
}
|
||||
|
||||
public IPassStoreDirectory GetRootDirectory()
|
||||
public PassStoreEntryGroup GetRootDirectory()
|
||||
{
|
||||
if (Locked)
|
||||
throw new InvalidOperationException();
|
||||
return (IPassStoreDirectory)root!;
|
||||
return root!;
|
||||
}
|
||||
|
||||
public IPassStoreDirectory? GetGroupByType(byte groupType)
|
||||
public PassStoreEntryGroup? GetGroupByType(byte groupType)
|
||||
{
|
||||
if (Locked)
|
||||
throw new InvalidOperationException();
|
||||
@@ -102,7 +104,9 @@ public class PassStoreFileAccessor : IPassStore
|
||||
case FILE_FIELD_CONFIG:
|
||||
break;
|
||||
case FILE_FIELD_STORE:
|
||||
this.root = PassStoreEntry.ReadFromStream(cryptoReader);
|
||||
root = PassStoreEntry.ReadFromStream(cryptoReader) as PassStoreEntryGroup;
|
||||
AddEntriesToDict(root!);
|
||||
ResolveLinks();
|
||||
break;
|
||||
case FILE_FIELD_END:
|
||||
end = true;
|
||||
@@ -123,6 +127,8 @@ public class PassStoreFileAccessor : IPassStore
|
||||
Save();
|
||||
Array.Fill<byte>(key!, 0);
|
||||
key = null;
|
||||
root = null;
|
||||
allEntries = new();
|
||||
}
|
||||
|
||||
public void Save()
|
||||
@@ -163,6 +169,97 @@ public class PassStoreFileAccessor : IPassStore
|
||||
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>
|
||||
/// Проверяет внешнюю целостность файла хранилища, то есть:
|
||||
/// 1. совпадает сигнатура (magic number)
|
||||
@@ -213,6 +310,30 @@ public class PassStoreFileAccessor : IPassStore
|
||||
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)
|
||||
{
|
||||
using FileStream file = File.Open(filename, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||
@@ -257,9 +378,12 @@ public class PassStoreFileAccessor : IPassStore
|
||||
|
||||
cryptoWriter.Flush();
|
||||
cryptoWriter.Dispose();
|
||||
|
||||
AddEntriesToDict(root);
|
||||
ResolveLinks();
|
||||
}
|
||||
|
||||
private PassStoreEntry WriteInitialStoreTree(OuterEncryptionWriter w)
|
||||
private PassStoreEntryGroup WriteInitialStoreTree(OuterEncryptionWriter w)
|
||||
{
|
||||
PassStoreEntryGroup defaultGroup = new(
|
||||
Guid.NewGuid(),
|
||||
@@ -269,6 +393,15 @@ public class PassStoreFileAccessor : IPassStore
|
||||
"",
|
||||
GROUP_TYPE_DEFAULT
|
||||
);
|
||||
PassStoreEntryGroup favourites = new(
|
||||
Guid.NewGuid(),
|
||||
DateTime.UtcNow,
|
||||
DateTime.UtcNow,
|
||||
Guid.Empty,
|
||||
"",
|
||||
GROUP_TYPE_FAVOURITES
|
||||
);
|
||||
|
||||
PassStoreEntryGroup root = new(
|
||||
Guid.NewGuid(),
|
||||
DateTime.UtcNow,
|
||||
@@ -276,9 +409,10 @@ public class PassStoreFileAccessor : IPassStore
|
||||
Guid.Empty,
|
||||
"",
|
||||
GROUP_TYPE_ROOT,
|
||||
[defaultGroup]
|
||||
[defaultGroup, favourites]
|
||||
);
|
||||
defaultGroup.Parent = root;
|
||||
favourites.Parent = root;
|
||||
root.WriteToStream(w);
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -323,7 +323,6 @@ public class EntryEditViewModel : ViewModelBase
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Validation should have caught this, but handle gracefully
|
||||
totp = null;
|
||||
}
|
||||
}
|
||||
@@ -332,7 +331,7 @@ public class EntryEditViewModel : ViewModelBase
|
||||
id,
|
||||
created,
|
||||
DateTime.UtcNow,
|
||||
EntryIconType.DEFAULT,
|
||||
BuiltinEntryIconType.DEFAULT,
|
||||
EntryName.Trim(),
|
||||
new LoginField() { Type = LOGIN_FIELD_USERNAME_ID, Value = Username.Trim() },
|
||||
new LoginField() { Type = LOGIN_FIELD_PASSWORD_ID, Value = Password },
|
||||
|
||||
@@ -10,18 +10,43 @@ namespace KeyKeeper.ViewModels;
|
||||
public class UnlockedRepositoryViewModel : ViewModelBase
|
||||
{
|
||||
private IPassStore passStore;
|
||||
private IPassStoreDirectory currentDirectory;
|
||||
private PassStoreEntryGroup currentDirectory;
|
||||
private PassStoreEntryGroup? rootDirectory;
|
||||
private bool hasUnsavedChanges;
|
||||
private DispatcherTimer? _totpRefreshTimer;
|
||||
private Dictionary<Guid, string> _totpCodes = new();
|
||||
|
||||
public IEnumerable<PassStoreEntryPassword> Passwords
|
||||
public IEnumerable<PassStoreEntry> Passwords
|
||||
{
|
||||
get
|
||||
{
|
||||
return currentDirectory
|
||||
.Where(entry => entry is PassStoreEntryPassword)
|
||||
.Select(entry => (entry as PassStoreEntryPassword)!);
|
||||
.Where(entry => entry is PassStoreEntryPassword || entry is PassStoreEntryLink lnk && lnk.LinkTarget is 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;
|
||||
currentDirectory = directory;
|
||||
currentDirectory = group;
|
||||
rootDirectory = group.Parent;
|
||||
HasUnsavedChanges = false;
|
||||
InitializeTotpCodes();
|
||||
StartTotpRefreshTimer();
|
||||
@@ -62,24 +88,118 @@ public class UnlockedRepositoryViewModel : ViewModelBase
|
||||
{
|
||||
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;
|
||||
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)
|
||||
{
|
||||
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;
|
||||
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;
|
||||
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()
|
||||
@@ -88,10 +208,36 @@ public class UnlockedRepositoryViewModel : ViewModelBase
|
||||
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()
|
||||
{
|
||||
_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!);
|
||||
}
|
||||
@@ -102,6 +248,10 @@ public class UnlockedRepositoryViewModel : ViewModelBase
|
||||
// Calculate time until next TOTP period boundary
|
||||
int secondsUntilNextCode = CalculateSecondsUntilNextTotpRefresh();
|
||||
|
||||
if (_totpRefreshTimer != null)
|
||||
{
|
||||
_totpRefreshTimer.Stop();
|
||||
}
|
||||
_totpRefreshTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(secondsUntilNextCode)
|
||||
@@ -129,7 +279,7 @@ public class UnlockedRepositoryViewModel : ViewModelBase
|
||||
private int CalculateSecondsUntilNextTotpRefresh()
|
||||
{
|
||||
// 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)
|
||||
return 60; // Default to 60 seconds if no TOTP entries
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<StackPanel Grid.Row="1"
|
||||
Orientation="Horizontal"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Bottom"
|
||||
Spacing="8"
|
||||
Margin="0,16,0,0">
|
||||
<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>
|
||||
<converters:TotpCodeConverter x:Key="TotpCodeConverter" />
|
||||
<converters:PasswordEntryConverter x:Key="PasswordEntryConverter" />
|
||||
<converters:StringPresenceToOpacityConverter x:Key="StringPresenceToOpacityConverter" />
|
||||
</Window.Resources>
|
||||
|
||||
@@ -57,22 +58,32 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Рамочка -->
|
||||
<!-- <Border BorderBrush="Gray"
|
||||
BorderThickness="1"
|
||||
CornerRadius="5"
|
||||
Padding="20"
|
||||
Background="#F5F5F5"
|
||||
HorizontalAlignment="Left">
|
||||
<!-- Группы паролей -->
|
||||
<TreeView x:Name="GroupTree"
|
||||
ItemsSource="{Binding PasswordGroups}"
|
||||
SelectedItem="{Binding SelectedPasswordGroup}"
|
||||
Background="#FFFFFFFF"
|
||||
SelectionMode="Single"
|
||||
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 -->
|
||||
<Button Content="Save Passwords"
|
||||
Classes="accentSidebarButton"
|
||||
@@ -96,6 +107,14 @@
|
||||
Height="30"
|
||||
HorizontalAlignment="Left"
|
||||
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>
|
||||
</Border>
|
||||
|
||||
@@ -122,11 +141,11 @@
|
||||
Margin="10"
|
||||
HorizontalAlignment="Center">
|
||||
|
||||
<Svg Path="{Binding IconPath}" Width="48" Height="48"/>
|
||||
<TextBlock Text="{Binding Name}"
|
||||
<Svg Path="{Binding ., Converter={StaticResource PasswordEntryConverter}, ConverterParameter=IconPath}" Width="48" Height="48"/>
|
||||
<TextBlock Text="{Binding ., Converter={StaticResource PasswordEntryConverter}, ConverterParameter=Name}"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="Black" />
|
||||
<TextBlock Text="{Binding Username.Value}"
|
||||
<TextBlock Text="{Binding ., Converter={StaticResource PasswordEntryConverter}, ConverterParameter=Username.Value}"
|
||||
Foreground="#666"
|
||||
HorizontalAlignment="Center" />
|
||||
|
||||
@@ -138,17 +157,21 @@
|
||||
Margin="0,4,0,0">
|
||||
<TextBlock.Text>
|
||||
<MultiBinding Converter="{StaticResource TotpCodeConverter}">
|
||||
<Binding />
|
||||
<Binding Converter="{StaticResource PasswordEntryConverter}" />
|
||||
<Binding Path="DataContext" RelativeSource="{RelativeSource AncestorType=ListBox}" />
|
||||
</MultiBinding>
|
||||
</TextBlock.Text>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
<Border.ContextMenu>
|
||||
<ContextMenu>
|
||||
<ContextMenu Opened="EntryContextMenu_Opening">
|
||||
<MenuItem Name="entryCtxMenuCopyUsername" Header="Copy username" Click="EntryContextMenuItem_Click"/>
|
||||
<MenuItem Name="entryCtxMenuCopyPassword" Header="Copy password" 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"/>
|
||||
</ContextMenu>
|
||||
</Border.ContextMenu>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Input;
|
||||
using KeyKeeper.PasswordStore;
|
||||
using KeyKeeper.Models;
|
||||
using KeyKeeper.ViewModels;
|
||||
using Avalonia.VisualTree;
|
||||
using Avalonia.Controls.Presenters;
|
||||
@@ -14,6 +17,7 @@ public partial class RepositoryWindow : Window
|
||||
{
|
||||
private bool allowClose;
|
||||
private bool closeConfirmationShown;
|
||||
private PassStoreEntry? _contextMenuEntry;
|
||||
|
||||
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)
|
||||
{
|
||||
if (DataContext is RepositoryWindowViewModel vm_ && vm_.CurrentPage is UnlockedRepositoryViewModel vm)
|
||||
@@ -129,15 +164,17 @@ public partial class RepositoryWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedEntry = listBox.SelectedItem as PassStoreEntryPassword;
|
||||
if (selectedEntry == null)
|
||||
{
|
||||
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("No entry selected");
|
||||
return;
|
||||
}
|
||||
PassStoreEntry? entry = listBox.SelectedItem as PassStoreEntry;
|
||||
if (entry == null) return;
|
||||
EditEntry(vm_, vm, entry);
|
||||
}
|
||||
}
|
||||
|
||||
EntryEditWindow dialog = new();
|
||||
dialog.SetEntry(selectedEntry);
|
||||
private async void AddGroupButton_Click(object sender, RoutedEventArgs args)
|
||||
{
|
||||
if (DataContext is RepositoryWindowViewModel vm_ && vm_.CurrentPage is UnlockedRepositoryViewModel vm)
|
||||
{
|
||||
CreateGroupDialog dialog = new();
|
||||
|
||||
vm_.StopLockTimer();
|
||||
|
||||
@@ -145,10 +182,18 @@ public partial class RepositoryWindow : Window
|
||||
|
||||
vm_.StartLockTimer();
|
||||
|
||||
if (dialog.EditedEntry != null)
|
||||
if (dialog.GroupData != null)
|
||||
{
|
||||
vm.UpdateEntry(dialog.EditedEntry);
|
||||
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Entry updated");
|
||||
var group = new PassStoreEntryGroup(
|
||||
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)
|
||||
{
|
||||
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);
|
||||
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Password copied to clipboard");
|
||||
CopyPassword(ent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,49 +218,248 @@ public partial class RepositoryWindow : Window
|
||||
{
|
||||
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);
|
||||
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Password copied to clipboard");
|
||||
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);
|
||||
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)
|
||||
{
|
||||
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")
|
||||
{
|
||||
Clipboard!.SetTextAsync(pwd.Username.Value);
|
||||
await Clipboard!.SetTextAsync(pwd.Username.Value);
|
||||
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Username copied to clipboard");
|
||||
}
|
||||
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");
|
||||
}
|
||||
else if (s.Name == "entryCtxMenuEdit")
|
||||
{
|
||||
if (DataContext is RepositoryWindowViewModel vm && vm.CurrentPage is UnlockedRepositoryViewModel pageVm)
|
||||
{
|
||||
EntryEditWindow dialog = new();
|
||||
dialog.SetEntry(pwd);
|
||||
vm.StopLockTimer();
|
||||
await dialog.ShowDialog(this);
|
||||
vm.StartLockTimer();
|
||||
if (dialog.EditedEntry != null)
|
||||
EditEntry(vm, pageVm, ent);
|
||||
}
|
||||
}
|
||||
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)
|
||||
{
|
||||
pageVm.UpdateEntry(dialog.EditedEntry);
|
||||
this.FindControlRecursive<ToastNotificationHost>("NotificationHost")?.Show("Entry updated");
|
||||
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")
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user