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
2. Retrieve Entry
3. Search Entries
4. Modify an Existing Entry
5. 2FA Codes
6. Settings
7. Exit
4. List Entries
5. Modify an Existing Entry
6. 2FA Codes
7. Settings
8. Exit
"""
display_fn = getattr(password_manager, "display_stats", None)
if callable(display_fn):
@@ -729,7 +730,7 @@ def display_menu(
print(colored(menu, "cyan"))
try:
choice = timed_input(
"Enter your choice (1-7): ", inactivity_timeout
"Enter your choice (1-8): ", inactivity_timeout
).strip()
except TimeoutError:
print(colored("Session timed out. Vault locked.", "yellow"))
@@ -740,7 +741,7 @@ def display_menu(
if not choice:
print(
colored(
"No input detected. Please enter a number between 1 and 7.",
"No input detected. Please enter a number between 1 and 8.",
"yellow",
)
)
@@ -787,14 +788,17 @@ def display_menu(
password_manager.handle_search_entries()
elif choice == "4":
password_manager.update_activity()
password_manager.handle_modify_entry()
password_manager.handle_list_entries()
elif choice == "5":
password_manager.update_activity()
password_manager.handle_display_totp_codes()
password_manager.handle_modify_entry()
elif choice == "6":
password_manager.update_activity()
handle_settings(password_manager)
password_manager.handle_display_totp_codes()
elif choice == "7":
password_manager.update_activity()
handle_settings(password_manager)
elif choice == "8":
logging.info("Exiting the program.")
print(colored("Exiting the program.", "green"))
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)
print(colored(f"Error: Failed to list all entries: {e}", "red"))
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,93 +1752,126 @@ class PasswordManager:
print(colored("\n[+] Search Results:\n", "green"))
for match in results:
index, website, username, url, blacklisted = match
entry = self.entry_manager.retrieve_entry(index)
if not entry:
continue
etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
print(colored(f"Index: {index}", "cyan"))
if etype == EntryType.TOTP.value:
print(colored(f" Label: {entry.get('label', website)}", "cyan"))
print(
colored(
f" Derivation Index: {entry.get('index', index)}", "cyan"
)
)
print(
colored(
f" Period: {entry.get('period', 30)}s Digits: {entry.get('digits', 6)}",
"cyan",
)
)
notes = entry.get("notes", "")
if notes:
print(colored(f" Notes: {notes}", "cyan"))
elif etype == EntryType.SEED.value:
print(colored(" Type: Seed Phrase", "cyan"))
print(colored(f" Words: {entry.get('words', 24)}", "cyan"))
print(
colored(
f" Derivation Index: {entry.get('index', index)}", "cyan"
)
)
notes = entry.get("notes", "")
if notes:
print(colored(f" Notes: {notes}", "cyan"))
elif etype == EntryType.SSH.value:
print(colored(" Type: SSH Key", "cyan"))
print(
colored(
f" Derivation Index: {entry.get('index', index)}", "cyan"
)
)
notes = entry.get("notes", "")
if notes:
print(colored(f" Notes: {notes}", "cyan"))
elif etype == EntryType.PGP.value:
print(colored(" Type: PGP Key", "cyan"))
print(
colored(
f" Key Type: {entry.get('key_type', 'ed25519')}", "cyan"
)
)
uid = entry.get("user_id", "")
if uid:
print(colored(f" User ID: {uid}", "cyan"))
print(
colored(
f" Derivation Index: {entry.get('index', index)}", "cyan"
)
)
notes = entry.get("notes", "")
if notes:
print(colored(f" Notes: {notes}", "cyan"))
elif etype == EntryType.NOSTR.value:
print(colored(" Type: Nostr Key", "cyan"))
print(colored(f" Label: {entry.get('label', '')}", "cyan"))
print(
colored(
f" Derivation Index: {entry.get('index', index)}", "cyan"
)
)
notes = entry.get("notes", "")
if notes:
print(colored(f" Notes: {notes}", "cyan"))
else:
print(colored(f" Website: {website}", "cyan"))
print(colored(f" Username: {username or 'N/A'}", "cyan"))
print(colored(f" URL: {url or 'N/A'}", "cyan"))
print(
colored(
f" Blacklisted: {'Yes' if blacklisted else 'No'}", "cyan"
)
)
print("-" * 40)
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)
if not entry:
return
etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
print(colored(f"Index: {index}", "cyan"))
if etype == EntryType.TOTP.value:
print(colored(f" Label: {entry.get('label', '')}", "cyan"))
print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan"))
print(
colored(
f" Period: {entry.get('period', 30)}s Digits: {entry.get('digits', 6)}",
"cyan",
)
)
notes = entry.get("notes", "")
if notes:
print(colored(f" Notes: {notes}", "cyan"))
elif etype == EntryType.SEED.value:
print(colored(" Type: Seed Phrase", "cyan"))
print(colored(f" Words: {entry.get('words', 24)}", "cyan"))
print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan"))
notes = entry.get("notes", "")
if notes:
print(colored(f" Notes: {notes}", "cyan"))
elif etype == EntryType.SSH.value:
print(colored(" Type: SSH Key", "cyan"))
print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan"))
notes = entry.get("notes", "")
if notes:
print(colored(f" Notes: {notes}", "cyan"))
elif etype == EntryType.PGP.value:
print(colored(" Type: PGP Key", "cyan"))
print(colored(f" Key Type: {entry.get('key_type', 'ed25519')}", "cyan"))
uid = entry.get("user_id", "")
if uid:
print(colored(f" User ID: {uid}", "cyan"))
print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan"))
notes = entry.get("notes", "")
if notes:
print(colored(f" Notes: {notes}", "cyan"))
elif etype == EntryType.NOSTR.value:
print(colored(" Type: Nostr Key", "cyan"))
print(colored(f" Label: {entry.get('label', '')}", "cyan"))
print(colored(f" Derivation Index: {entry.get('index', index)}", "cyan"))
notes = entry.get("notes", "")
if notes:
print(colored(f" Notes: {notes}", "cyan"))
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" Username: {username or 'N/A'}", "cyan"))
print(colored(f" URL: {url or 'N/A'}", "cyan"))
print(colored(f" Blacklisted: {'Yes' if blacklisted else 'No'}", "cyan"))
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:
logging.error(f"Failed to list entries: {e}", exc_info=True)
print(colored(f"Error: Failed to list entries: {e}", "red"))
def delete_entry(self) -> None:
"""Deletes an entry from the password index."""
try:

View File

@@ -31,7 +31,7 @@ def test_auto_sync_triggers_post(monkeypatch):
called = True
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):
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):
called = {"add": False, "retrieve": False, "modify": False}
pm, _ = _make_pm(called)
inputs = iter(["", "abc", "7"])
inputs = iter(["", "abc", "8"])
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
with pytest.raises(SystemExit):
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):
called = {"add": False, "retrieve": False, "modify": False}
pm, _ = _make_pm(called)
inputs = iter(["9", "7"])
inputs = iter(["9", "8"])
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
with pytest.raises(SystemExit):
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):
called = {"add": False, "retrieve": False, "modify": False}
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("builtins.input", lambda *_: next(inputs))
with pytest.raises(SystemExit):
@@ -92,7 +92,7 @@ def test_inactivity_timeout_loop(monkeypatch, capsys):
pm, locked = _make_pm(called)
pm.last_activity = 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):
main.display_menu(pm, sync_interval=1000, inactivity_timeout=0.1)
out = capsys.readouterr().out

View File

@@ -36,7 +36,7 @@ def test_inactivity_triggers_lock(monkeypatch):
unlock_vault=unlock_vault,
)
monkeypatch.setattr(main, "timed_input", lambda *_: "7")
monkeypatch.setattr(main, "timed_input", lambda *_: "8")
with pytest.raises(SystemExit):
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,
)
responses = iter([TimeoutError(), "7"])
responses = iter([TimeoutError(), "8"])
def fake_input(*_args, **_kwargs):
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):
calls = []
pm = _make_pm(calls)
inputs = iter(["5", "7"])
inputs = iter(["6", "8"])
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
monkeypatch.setattr(main, "handle_settings", lambda *_: None)
with pytest.raises(SystemExit):
@@ -41,7 +41,7 @@ def test_menu_totp_option(monkeypatch):
def test_menu_settings_option(monkeypatch):
calls = []
pm = _make_pm(calls)
inputs = iter(["6", "7"])
inputs = iter(["7", "8"])
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
monkeypatch.setattr(main, "handle_settings", lambda *_: calls.append("settings"))
with pytest.raises(SystemExit):

View File

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