Merge pull request #271 from PR0M3TH3AN/codex/add--list-entries--item-to-main-menu

Add list entries feature
This commit is contained in:
thePR0M3TH3AN
2025-07-05 09:37:24 -04:00
committed by GitHub
9 changed files with 218 additions and 102 deletions

View File

@@ -701,10 +701,11 @@ def display_menu(
1. Add Entry 1. Add Entry
2. Retrieve Entry 2. Retrieve Entry
3. Search Entries 3. Search Entries
4. Modify an Existing Entry 4. List Entries
5. 2FA Codes 5. Modify an Existing Entry
6. Settings 6. 2FA Codes
7. Exit 7. Settings
8. Exit
""" """
display_fn = getattr(password_manager, "display_stats", None) display_fn = getattr(password_manager, "display_stats", None)
if callable(display_fn): if callable(display_fn):
@@ -729,7 +730,7 @@ def display_menu(
print(colored(menu, "cyan")) print(colored(menu, "cyan"))
try: try:
choice = timed_input( choice = timed_input(
"Enter your choice (1-7): ", inactivity_timeout "Enter your choice (1-8): ", inactivity_timeout
).strip() ).strip()
except TimeoutError: except TimeoutError:
print(colored("Session timed out. Vault locked.", "yellow")) print(colored("Session timed out. Vault locked.", "yellow"))
@@ -740,7 +741,7 @@ def display_menu(
if not choice: if not choice:
print( print(
colored( colored(
"No input detected. Please enter a number between 1 and 7.", "No input detected. Please enter a number between 1 and 8.",
"yellow", "yellow",
) )
) )
@@ -787,14 +788,17 @@ def display_menu(
password_manager.handle_search_entries() password_manager.handle_search_entries()
elif choice == "4": elif choice == "4":
password_manager.update_activity() password_manager.update_activity()
password_manager.handle_modify_entry() password_manager.handle_list_entries()
elif choice == "5": elif choice == "5":
password_manager.update_activity() password_manager.update_activity()
password_manager.handle_display_totp_codes() password_manager.handle_modify_entry()
elif choice == "6": elif choice == "6":
password_manager.update_activity() password_manager.update_activity()
handle_settings(password_manager) password_manager.handle_display_totp_codes()
elif choice == "7": elif choice == "7":
password_manager.update_activity()
handle_settings(password_manager)
elif choice == "8":
logging.info("Exiting the program.") logging.info("Exiting the program.")
print(colored("Exiting the program.", "green")) print(colored("Exiting the program.", "green"))
password_manager.nostr_client.close_client_pool() password_manager.nostr_client.close_client_pool()

View File

@@ -841,3 +841,39 @@ class EntryManager:
logger.error(f"Failed to list all entries: {e}", exc_info=True) logger.error(f"Failed to list all entries: {e}", exc_info=True)
print(colored(f"Error: Failed to list all entries: {e}", "red")) print(colored(f"Error: Failed to list all entries: {e}", "red"))
return return
def get_entry_summaries(
self, filter_kind: str | None = None
) -> list[tuple[int, str]]:
"""Return a list of entry index and display labels."""
try:
data = self.vault.load_index()
entries_data = data.get("entries", {})
summaries: list[tuple[int, str]] = []
for idx_str, entry in entries_data.items():
etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
if filter_kind and etype != filter_kind:
continue
if etype == EntryType.PASSWORD.value:
label = entry.get("website", "")
elif etype == EntryType.TOTP.value:
label = entry.get("label", "")
elif etype == EntryType.SSH.value:
label = "SSH Key"
elif etype == EntryType.SEED.value:
label = "Seed Phrase"
elif etype == EntryType.NOSTR.value:
label = entry.get("label", "Nostr Key")
elif etype == EntryType.PGP.value:
label = "PGP Key"
else:
label = etype
summaries.append((int(idx_str), label))
summaries.sort(key=lambda x: x[0])
return summaries
except Exception as e:
logger.error(f"Failed to get entry summaries: {e}", exc_info=True)
print(colored(f"Error: Failed to get entry summaries: {e}", "red"))
return []

View File

@@ -1752,19 +1752,22 @@ class PasswordManager:
print(colored("\n[+] Search Results:\n", "green")) print(colored("\n[+] Search Results:\n", "green"))
for match in results: for match in results:
index, website, username, url, blacklisted = match self.display_entry_details(match[0])
except Exception as e:
logging.error(f"Failed to search entries: {e}", exc_info=True)
print(colored(f"Error: Failed to search entries: {e}", "red"))
def display_entry_details(self, index: int) -> None:
"""Print detailed information for a single entry."""
entry = self.entry_manager.retrieve_entry(index) entry = self.entry_manager.retrieve_entry(index)
if not entry: if not entry:
continue return
etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
print(colored(f"Index: {index}", "cyan")) print(colored(f"Index: {index}", "cyan"))
if etype == EntryType.TOTP.value: if etype == EntryType.TOTP.value:
print(colored(f" Label: {entry.get('label', website)}", "cyan")) print(colored(f" Label: {entry.get('label', '')}", "cyan"))
print( print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan"))
colored(
f" Derivation Index: {entry.get('index', index)}", "cyan"
)
)
print( print(
colored( colored(
f" Period: {entry.get('period', 30)}s Digits: {entry.get('digits', 6)}", f" Period: {entry.get('period', 30)}s Digits: {entry.get('digits', 6)}",
@@ -1777,67 +1780,97 @@ class PasswordManager:
elif etype == EntryType.SEED.value: elif etype == EntryType.SEED.value:
print(colored(" Type: Seed Phrase", "cyan")) print(colored(" Type: Seed Phrase", "cyan"))
print(colored(f" Words: {entry.get('words', 24)}", "cyan")) print(colored(f" Words: {entry.get('words', 24)}", "cyan"))
print( print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan"))
colored(
f" Derivation Index: {entry.get('index', index)}", "cyan"
)
)
notes = entry.get("notes", "") notes = entry.get("notes", "")
if notes: if notes:
print(colored(f" Notes: {notes}", "cyan")) print(colored(f" Notes: {notes}", "cyan"))
elif etype == EntryType.SSH.value: elif etype == EntryType.SSH.value:
print(colored(" Type: SSH Key", "cyan")) print(colored(" Type: SSH Key", "cyan"))
print( print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan"))
colored(
f" Derivation Index: {entry.get('index', index)}", "cyan"
)
)
notes = entry.get("notes", "") notes = entry.get("notes", "")
if notes: if notes:
print(colored(f" Notes: {notes}", "cyan")) print(colored(f" Notes: {notes}", "cyan"))
elif etype == EntryType.PGP.value: elif etype == EntryType.PGP.value:
print(colored(" Type: PGP Key", "cyan")) print(colored(" Type: PGP Key", "cyan"))
print( print(colored(f" Key Type: {entry.get('key_type', 'ed25519')}", "cyan"))
colored(
f" Key Type: {entry.get('key_type', 'ed25519')}", "cyan"
)
)
uid = entry.get("user_id", "") uid = entry.get("user_id", "")
if uid: if uid:
print(colored(f" User ID: {uid}", "cyan")) print(colored(f" User ID: {uid}", "cyan"))
print( print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan"))
colored(
f" Derivation Index: {entry.get('index', index)}", "cyan"
)
)
notes = entry.get("notes", "") notes = entry.get("notes", "")
if notes: if notes:
print(colored(f" Notes: {notes}", "cyan")) print(colored(f" Notes: {notes}", "cyan"))
elif etype == EntryType.NOSTR.value: elif etype == EntryType.NOSTR.value:
print(colored(" Type: Nostr Key", "cyan")) print(colored(" Type: Nostr Key", "cyan"))
print(colored(f" Label: {entry.get('label', '')}", "cyan")) print(colored(f" Label: {entry.get('label', '')}", "cyan"))
print( print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan"))
colored(
f" Derivation Index: {entry.get('index', index)}", "cyan"
)
)
notes = entry.get("notes", "") notes = entry.get("notes", "")
if notes: if notes:
print(colored(f" Notes: {notes}", "cyan")) print(colored(f" Notes: {notes}", "cyan"))
else: else:
website = entry.get("website", "")
username = entry.get("username", "")
url = entry.get("url", "")
blacklisted = entry.get("blacklisted", False)
print(colored(f" Website: {website}", "cyan")) print(colored(f" Website: {website}", "cyan"))
print(colored(f" Username: {username or 'N/A'}", "cyan")) print(colored(f" Username: {username or 'N/A'}", "cyan"))
print(colored(f" URL: {url or 'N/A'}", "cyan")) print(colored(f" URL: {url or 'N/A'}", "cyan"))
print( print(colored(f" Blacklisted: {'Yes' if blacklisted else 'No'}", "cyan"))
colored(
f" Blacklisted: {'Yes' if blacklisted else 'No'}", "cyan"
)
)
print("-" * 40) print("-" * 40)
def handle_list_entries(self) -> None:
"""List entries and optionally show details."""
try:
while True:
print("\nList Entries:")
print("1. All")
print("2. Passwords")
print("3. 2FA (TOTP)")
print("4. SSH Key")
print("5. Seed Phrase")
print("6. Nostr Key Pair")
print("7. PGP")
print("8. Back")
choice = input("Select entry type: ").strip()
if choice == "1":
filter_kind = None
elif choice == "2":
filter_kind = EntryType.PASSWORD.value
elif choice == "3":
filter_kind = EntryType.TOTP.value
elif choice == "4":
filter_kind = EntryType.SSH.value
elif choice == "5":
filter_kind = EntryType.SEED.value
elif choice == "6":
filter_kind = EntryType.NOSTR.value
elif choice == "7":
filter_kind = EntryType.PGP.value
elif choice == "8":
return
else:
print(colored("Invalid choice.", "red"))
continue
summaries = self.entry_manager.get_entry_summaries(filter_kind)
if not summaries:
continue
print(colored("\n[+] Entries:\n", "green"))
for idx, label in summaries:
print(colored(f"{idx}. {label}", "cyan"))
idx_input = input(
"Enter index to view details or press Enter to return: "
).strip()
if not idx_input:
return
if not idx_input.isdigit():
print(colored("Invalid index.", "red"))
continue
self.display_entry_details(int(idx_input))
return
except Exception as e: except Exception as e:
logging.error(f"Failed to search entries: {e}", exc_info=True) logging.error(f"Failed to list entries: {e}", exc_info=True)
print(colored(f"Error: Failed to search entries: {e}", "red")) print(colored(f"Error: Failed to list entries: {e}", "red"))
def delete_entry(self) -> None: def delete_entry(self) -> None:
"""Deletes an entry from the password index.""" """Deletes an entry from the password index."""

View File

@@ -31,7 +31,7 @@ def test_auto_sync_triggers_post(monkeypatch):
called = True called = True
monkeypatch.setattr(main, "handle_post_to_nostr", fake_post) monkeypatch.setattr(main, "handle_post_to_nostr", fake_post)
monkeypatch.setattr(main, "timed_input", lambda *_: "7") monkeypatch.setattr(main, "timed_input", lambda *_: "8")
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=0.1) main.display_menu(pm, sync_interval=0.1)

View File

@@ -52,7 +52,7 @@ def _make_pm(called, locked=None):
def test_empty_and_non_numeric_choice(monkeypatch, capsys): def test_empty_and_non_numeric_choice(monkeypatch, capsys):
called = {"add": False, "retrieve": False, "modify": False} called = {"add": False, "retrieve": False, "modify": False}
pm, _ = _make_pm(called) pm, _ = _make_pm(called)
inputs = iter(["", "abc", "7"]) inputs = iter(["", "abc", "8"])
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000)
@@ -65,7 +65,7 @@ def test_empty_and_non_numeric_choice(monkeypatch, capsys):
def test_out_of_range_menu(monkeypatch, capsys): def test_out_of_range_menu(monkeypatch, capsys):
called = {"add": False, "retrieve": False, "modify": False} called = {"add": False, "retrieve": False, "modify": False}
pm, _ = _make_pm(called) pm, _ = _make_pm(called)
inputs = iter(["9", "7"]) inputs = iter(["9", "8"])
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000) main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000)
@@ -77,7 +77,7 @@ def test_out_of_range_menu(monkeypatch, capsys):
def test_invalid_add_entry_submenu(monkeypatch, capsys): def test_invalid_add_entry_submenu(monkeypatch, capsys):
called = {"add": False, "retrieve": False, "modify": False} called = {"add": False, "retrieve": False, "modify": False}
pm, _ = _make_pm(called) pm, _ = _make_pm(called)
inputs = iter(["1", "8", "7", "7"]) inputs = iter(["1", "8", "7", "8"])
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
@@ -92,7 +92,7 @@ def test_inactivity_timeout_loop(monkeypatch, capsys):
pm, locked = _make_pm(called) pm, locked = _make_pm(called)
pm.last_activity = 0 pm.last_activity = 0
monkeypatch.setattr(time, "time", lambda: 100.0) monkeypatch.setattr(time, "time", lambda: 100.0)
monkeypatch.setattr(main, "timed_input", lambda *_: "7") monkeypatch.setattr(main, "timed_input", lambda *_: "8")
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1) main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1)
out = capsys.readouterr().out out = capsys.readouterr().out

View File

@@ -36,7 +36,7 @@ def test_inactivity_triggers_lock(monkeypatch):
unlock_vault=unlock_vault, unlock_vault=unlock_vault,
) )
monkeypatch.setattr(main, "timed_input", lambda *_: "7") monkeypatch.setattr(main, "timed_input", lambda *_: "8")
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1) main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1)
@@ -72,7 +72,7 @@ def test_input_timeout_triggers_lock(monkeypatch):
unlock_vault=unlock_vault, unlock_vault=unlock_vault,
) )
responses = iter([TimeoutError(), "7"]) responses = iter([TimeoutError(), "8"])
def fake_input(*_args, **_kwargs): def fake_input(*_args, **_kwargs):
val = next(responses) val = next(responses)

View File

@@ -0,0 +1,43 @@
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
import sys
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager
from password_manager.backup import BackupManager
from password_manager.manager import PasswordManager, EncryptionMode
from password_manager.config_manager import ConfigManager
def test_handle_list_entries(monkeypatch, capsys):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_path, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.vault = vault
pm.entry_manager = entry_mgr
pm.backup_manager = backup_mgr
pm.nostr_client = SimpleNamespace()
pm.fingerprint_dir = tmp_path
entry_mgr.add_totp("Example", TEST_SEED)
entry_mgr.add_entry("example.com", 12)
inputs = iter(["1", ""]) # list all, then exit
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
pm.handle_list_entries()
out = capsys.readouterr().out
assert "Example" in out
assert "example.com" in out

View File

@@ -30,7 +30,7 @@ def _make_pm(calls):
def test_menu_totp_option(monkeypatch): def test_menu_totp_option(monkeypatch):
calls = [] calls = []
pm = _make_pm(calls) pm = _make_pm(calls)
inputs = iter(["5", "7"]) inputs = iter(["6", "8"])
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
monkeypatch.setattr(main, "handle_settings", lambda *_: None) monkeypatch.setattr(main, "handle_settings", lambda *_: None)
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
@@ -41,7 +41,7 @@ def test_menu_totp_option(monkeypatch):
def test_menu_settings_option(monkeypatch): def test_menu_settings_option(monkeypatch):
calls = [] calls = []
pm = _make_pm(calls) pm = _make_pm(calls)
inputs = iter(["6", "7"]) inputs = iter(["7", "8"])
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
monkeypatch.setattr(main, "handle_settings", lambda *_: calls.append("settings")) monkeypatch.setattr(main, "handle_settings", lambda *_: calls.append("settings"))
with pytest.raises(SystemExit): with pytest.raises(SystemExit):

View File

@@ -30,7 +30,7 @@ def _make_pm(called):
def test_menu_search_option(monkeypatch): def test_menu_search_option(monkeypatch):
called = [] called = []
pm = _make_pm(called) pm = _make_pm(called)
inputs = iter(["3", "7"]) inputs = iter(["3", "8"])
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
monkeypatch.setattr("builtins.input", lambda *_: "query") monkeypatch.setattr("builtins.input", lambda *_: "query")
with pytest.raises(SystemExit): with pytest.raises(SystemExit):