diff --git a/README.md b/README.md index 4497051..f99e4b3 100644 --- a/README.md +++ b/README.md @@ -409,6 +409,15 @@ When choosing **Add Entry**, you can now select from: - **Key/Value** - **Managed Account** +### Adding a Password Entry + +After selecting **Password**, SeedPass asks you to pick a mode: + +1. **Quick** – prompts only for a label, username, URL, desired length, and whether to include special characters. Default values are used for notes, tags, and policy settings. +2. **Advanced** – walks through the full set of prompts for notes, tags, custom fields, and detailed password policy options. + +Both modes generate the password, display it (or copy it to the clipboard in Secret Mode), and save the entry to your encrypted vault. + ### Adding a 2FA Entry 1. From the main menu choose **Add Entry** and select **2FA (TOTP)**. diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index 4ed74b0..1015c89 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -316,6 +316,15 @@ When choosing **Add Entry**, you can now select from: - **Key/Value** - **Managed Account** +### Adding a Password Entry + +After selecting **Password**, SeedPass asks you to choose a mode: + +1. **Quick** – enter only a label, username, URL, desired length, and whether to include special characters. All other fields use defaults. +2. **Advanced** – continue through prompts for notes, tags, custom fields, and detailed password policy settings. + +Both modes generate the password, display it (or copy it to the clipboard in Secret Mode), and save the entry to your encrypted vault. + ### Adding a 2FA Entry 1. From the main menu choose **Add Entry** and select **2FA (TOTP)**. diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index 963b284..b3a3244 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -1464,6 +1464,72 @@ class PasswordManager: parent_fingerprint=parent_fp, child_fingerprint=child_fp, ) + + def prompt_length() -> int | None: + length_input = input( + f"Enter desired password length (default {DEFAULT_PASSWORD_LENGTH}): " + ).strip() + length = DEFAULT_PASSWORD_LENGTH + if length_input: + if not length_input.isdigit(): + print( + colored("Error: Password length must be a number.", "red") + ) + return None + length = int(length_input) + if not (MIN_PASSWORD_LENGTH <= length <= MAX_PASSWORD_LENGTH): + print( + colored( + f"Error: Password length must be between {MIN_PASSWORD_LENGTH} and {MAX_PASSWORD_LENGTH}.", + "red", + ) + ) + return None + return length + + def finalize_entry(index: int, label: str, length: int) -> None: + # Mark database as dirty for background sync + self.is_dirty = True + self.last_update = time.time() + + # Generate the password using the assigned index + entry = self.entry_manager.retrieve_entry(index) + password = self._generate_password_for_entry(entry, index, length) + + # Provide user feedback + print( + colored( + f"\n[+] Password generated and indexed with ID {index}.\n", + "green", + ) + ) + if self.secret_mode_enabled: + copy_to_clipboard(password, self.clipboard_clear_delay) + print( + colored( + f"[+] Password copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(colored(f"Password for {label}: {password}\n", "yellow")) + + # Automatically push the updated encrypted index to Nostr so the + # latest changes are backed up remotely. + try: + self.start_background_vault_sync() + logging.info( + "Encrypted index posted to Nostr after entry addition." + ) + except Exception as nostr_error: + logging.error( + f"Failed to post updated index to Nostr: {nostr_error}", + exc_info=True, + ) + pause() + + mode = input("Choose mode: [Q]uick or [A]dvanced? ").strip().lower() + website_name = input("Enter the label or website name: ").strip() if not website_name: print(colored("Error: Label cannot be empty.", "red")) @@ -1471,6 +1537,29 @@ class PasswordManager: username = input("Enter the username (optional): ").strip() url = input("Enter the URL (optional): ").strip() + + if mode.startswith("q"): + length = prompt_length() + if length is None: + return + include_special_input = ( + input("Include special characters? (Y/n): ").strip().lower() + ) + include_special_chars: bool | None = None + if include_special_input: + include_special_chars = include_special_input != "n" + + index = self.entry_manager.add_entry( + website_name, + length, + username, + url, + include_special_chars=include_special_chars, + ) + + finalize_entry(index, website_name, length) + return + notes = input("Enter notes (optional): ").strip() tags_input = input("Enter tags (comma-separated, optional): ").strip() tags = ( @@ -1491,23 +1580,9 @@ class PasswordManager: {"label": label, "value": value, "is_hidden": hidden} ) - length_input = input( - f"Enter desired password length (default {DEFAULT_PASSWORD_LENGTH}): " - ).strip() - length = DEFAULT_PASSWORD_LENGTH - if length_input: - if not length_input.isdigit(): - print(colored("Error: Password length must be a number.", "red")) - return - length = int(length_input) - if not (MIN_PASSWORD_LENGTH <= length <= MAX_PASSWORD_LENGTH): - print( - colored( - f"Error: Password length must be between {MIN_PASSWORD_LENGTH} and {MAX_PASSWORD_LENGTH}.", - "red", - ) - ) - return + length = prompt_length() + if length is None: + return include_special_input = ( input("Include special characters? (Y/n): ").strip().lower() @@ -1563,7 +1638,6 @@ class PasswordManager: return min_special = int(min_special_input) if min_special_input else None - # Add the entry to the index and get the assigned index index = self.entry_manager.add_entry( website_name, length, @@ -1583,43 +1657,7 @@ class PasswordManager: min_special=min_special, ) - # Mark database as dirty for background sync - self.is_dirty = True - self.last_update = time.time() - - # Generate the password using the assigned index - entry = self.entry_manager.retrieve_entry(index) - password = self._generate_password_for_entry(entry, index, length) - - # Provide user feedback - print( - colored( - f"\n[+] Password generated and indexed with ID {index}.\n", - "green", - ) - ) - if self.secret_mode_enabled: - copy_to_clipboard(password, self.clipboard_clear_delay) - print( - colored( - f"[+] Password copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", - "green", - ) - ) - else: - print(colored(f"Password for {website_name}: {password}\n", "yellow")) - - # Automatically push the updated encrypted index to Nostr so the - # latest changes are backed up remotely. - try: - self.start_background_vault_sync() - logging.info("Encrypted index posted to Nostr after entry addition.") - except Exception as nostr_error: - logging.error( - f"Failed to post updated index to Nostr: {nostr_error}", - exc_info=True, - ) - pause() + finalize_entry(index, website_name, length) except Exception as e: logging.error(f"Error during password generation: {e}", exc_info=True) diff --git a/src/tests/test_manager_add_password.py b/src/tests/test_manager_add_password.py index 7e9f8f1..0b09a8a 100644 --- a/src/tests/test_manager_add_password.py +++ b/src/tests/test_manager_add_password.py @@ -45,6 +45,7 @@ def test_handle_add_password(monkeypatch, dummy_nostr_client, capsys): inputs = iter( [ + "a", # advanced mode "Example", # label "", # username "", # url @@ -114,6 +115,7 @@ def test_handle_add_password_secret_mode(monkeypatch, dummy_nostr_client, capsys inputs = iter( [ + "a", # advanced mode "Example", # label "", # username "", # url @@ -147,3 +149,62 @@ def test_handle_add_password_secret_mode(monkeypatch, dummy_nostr_client, capsys assert f"pw-0-{DEFAULT_PASSWORD_LENGTH}" not in out assert "copied to clipboard" in out assert called == [(f"pw-0-{DEFAULT_PASSWORD_LENGTH}", 5)] + + +def test_handle_add_password_quick_mode(monkeypatch, dummy_nostr_client, capsys): + client, _relay = dummy_nostr_client + 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.password_generator = FakePasswordGenerator() + pm.parent_seed = TEST_SEED + pm.nostr_client = client + pm.fingerprint_dir = tmp_path + pm.secret_mode_enabled = False + pm.is_dirty = False + + inputs = iter( + [ + "q", # quick mode + "Example", # label + "", # username + "", # url + "", # length (default) + "", # include special default + ] + ) + monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) + monkeypatch.setattr("seedpass.core.manager.pause", lambda *a, **k: None) + monkeypatch.setattr(pm, "start_background_vault_sync", lambda *a, **k: None) + + pm.handle_add_password() + out = capsys.readouterr().out + + entries = entry_mgr.list_entries(verbose=False) + assert entries == [(0, "Example", "", "", False)] + + entry = entry_mgr.retrieve_entry(0) + assert entry == { + "label": "Example", + "length": DEFAULT_PASSWORD_LENGTH, + "username": "", + "url": "", + "archived": False, + "type": "password", + "kind": "password", + "notes": "", + "custom_fields": [], + "tags": [], + } + + assert f"pw-0-{DEFAULT_PASSWORD_LENGTH}" in out diff --git a/src/tests/test_manager_workflow.py b/src/tests/test_manager_workflow.py index b2a7a54..bac9743 100644 --- a/src/tests/test_manager_workflow.py +++ b/src/tests/test_manager_workflow.py @@ -50,6 +50,7 @@ def test_manager_workflow(monkeypatch): inputs = iter( [ + "a", # advanced mode "example.com", "", # username "", # url