diff --git a/src/KeyKeeper/Models/GroupData.cs b/src/KeyKeeper/Models/GroupData.cs new file mode 100644 index 0000000..7b964be --- /dev/null +++ b/src/KeyKeeper/Models/GroupData.cs @@ -0,0 +1,5 @@ +using System; + +namespace KeyKeeper.Models; + +public record GroupEditData(string Name, Guid IconType); diff --git a/src/KeyKeeper/PasswordStore/EntryIconType.cs b/src/KeyKeeper/PasswordStore/EntryIconType.cs index 0a1c7de..956f1c7 100644 --- a/src/KeyKeeper/PasswordStore/EntryIconType.cs +++ b/src/KeyKeeper/PasswordStore/EntryIconType.cs @@ -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; diff --git a/src/KeyKeeper/PasswordStore/FileFormatConstants.cs b/src/KeyKeeper/PasswordStore/FileFormatConstants.cs index fc0dc28..f9e9b92 100644 --- a/src/KeyKeeper/PasswordStore/FileFormatConstants.cs +++ b/src/KeyKeeper/PasswordStore/FileFormatConstants.cs @@ -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; diff --git a/src/KeyKeeper/PasswordStore/IPassStore.cs b/src/KeyKeeper/PasswordStore/IPassStore.cs index e7fbe14..fc7f1a5 100644 --- a/src/KeyKeeper/PasswordStore/IPassStore.cs +++ b/src/KeyKeeper/PasswordStore/IPassStore.cs @@ -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); } diff --git a/src/KeyKeeper/PasswordStore/IPassStoreDirectory.cs b/src/KeyKeeper/PasswordStore/IPassStoreDirectory.cs deleted file mode 100644 index 03996c6..0000000 --- a/src/KeyKeeper/PasswordStore/IPassStoreDirectory.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace KeyKeeper.PasswordStore; - -public interface IPassStoreDirectory : IEnumerable -{ - bool DeleteEntry(Guid id); - void AddEntry(PassStoreEntry entry); - void UpdateEntry(Guid id, PassStoreEntry entry); -} diff --git a/src/KeyKeeper/PasswordStore/PassStoreEntry.cs b/src/KeyKeeper/PasswordStore/PassStoreEntry.cs index a9e6a79..87f4ec4 100644 --- a/src/KeyKeeper/PasswordStore/PassStoreEntry.cs +++ b/src/KeyKeeper/PasswordStore/PassStoreEntry.cs @@ -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 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; diff --git a/src/KeyKeeper/PasswordStore/PassStoreEntryGroup.cs b/src/KeyKeeper/PasswordStore/PassStoreEntryGroup.cs index 53ebd77..c5fa68b 100644 --- a/src/KeyKeeper/PasswordStore/PassStoreEntryGroup.cs +++ b/src/KeyKeeper/PasswordStore/PassStoreEntryGroup.cs @@ -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 { public byte GroupType { get; set; } public Guid? CustomGroupSubtype { get; set; } public List 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 ChildGroups => ChildEntries.OfType(); + public PassStoreEntryGroup(Guid id, DateTime createdAt, DateTime modifiedAt, Guid iconType, string name, byte groupType, List? 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 IEnumerable.GetEnumerator() { return ChildEntries.GetEnumerator(); diff --git a/src/KeyKeeper/PasswordStore/PassStoreEntryLink.cs b/src/KeyKeeper/PasswordStore/PassStoreEntryLink.cs new file mode 100644 index 0000000..5a30a72 --- /dev/null +++ b/src/KeyKeeper/PasswordStore/PassStoreEntryLink.cs @@ -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(); + } +} diff --git a/src/KeyKeeper/PasswordStore/PassStoreEntryType.cs b/src/KeyKeeper/PasswordStore/PassStoreEntryType.cs index 8be01f8..c47620f 100644 --- a/src/KeyKeeper/PasswordStore/PassStoreEntryType.cs +++ b/src/KeyKeeper/PasswordStore/PassStoreEntryType.cs @@ -4,4 +4,5 @@ public enum PassStoreEntryType { Password, Directory, + Link, } diff --git a/src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs b/src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs index 7b8f471..2ec3e67 100644 --- a/src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs +++ b/src/KeyKeeper/PasswordStore/PassStoreFileAccessor.cs @@ -14,20 +14,22 @@ namespace KeyKeeper.PasswordStore; /// 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 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(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]; + } + /// /// Проверяет внешнюю целостность файла хранилища, то есть: /// 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; } diff --git a/src/KeyKeeper/ViewModels/EntryEditViewModel.cs b/src/KeyKeeper/ViewModels/EntryEditViewModel.cs index a0a105a..55e8264 100644 --- a/src/KeyKeeper/ViewModels/EntryEditViewModel.cs +++ b/src/KeyKeeper/ViewModels/EntryEditViewModel.cs @@ -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 }, diff --git a/src/KeyKeeper/ViewModels/UnlockedRepositoryViewModel.cs b/src/KeyKeeper/ViewModels/UnlockedRepositoryViewModel.cs index 444cdac..881cb58 100644 --- a/src/KeyKeeper/ViewModels/UnlockedRepositoryViewModel.cs +++ b/src/KeyKeeper/ViewModels/UnlockedRepositoryViewModel.cs @@ -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 _totpCodes = new(); - public IEnumerable Passwords + public IEnumerable 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 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 diff --git a/src/KeyKeeper/Views/CloseConfirmationDialog.axaml b/src/KeyKeeper/Views/CloseConfirmationDialog.axaml index 2779e45..df19cdf 100644 --- a/src/KeyKeeper/Views/CloseConfirmationDialog.axaml +++ b/src/KeyKeeper/Views/CloseConfirmationDialog.axaml @@ -16,6 +16,7 @@