mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-30 01:12:17 +00:00
feat(tui-v3): implement core functional parity (Phase A/B)
- Implement secure data parity (Reveal/QR/Copy) for SSH, PGP, Nostr, Key-Value, and Documents. - Update derive_pgp_key and API to support PGP Public Key retrieval and copying. - Implement managed account loading/exiting (ml/mx) with reactive profile updates. - Integrate semantic search (keyword/hybrid/semantic) into the entry grid. - Create AddEntryScreen (unified wizard), SeedPlusScreen (BIP-85), and DocumentEditScreen. - Add grid filter presets (secrets, docs, keys, 2fa) and archive view toggle. - Update ActionBar and GridMetrics to be dynamic and context-aware. - Add comprehensive v3 parity tests and update existing core tests for PGP tuple changes.
This commit is contained in:
1 parent
d3920ccc4c
commit
c822eeb602
17 files changed
+855
-89
No files matched your search
+35
-54
@@ -34,24 +34,30 @@ We have successfully scaffolded the core v3 shell:
|
||||
- [x] Command Palette (Ctrl+P) with command routing.
|
||||
- [x] Reveal (v) and QR (g) logic with double-press confirmation.
|
||||
- [x] Reactive Search and Selection.
|
||||
- [x] **Secure Data Parity:** Full support for reveal/QR/copy across Passwords, TOTP, SSH, PGP, Nostr, Seeds, and Documents.
|
||||
- [x] **Managed Sessions:** `ml` (Load) and `mx` (Exit) parity for BIP-85 sub-profiles.
|
||||
- [x] **Semantic Search:** Integration of `SemanticIndexService` into the TUI v3 grid with mode switching (Keyword, Hybrid, Semantic).
|
||||
- [x] **Entry Lifecycle:** Unified `AddEntryScreen`, `SeedPlusScreen` for derivations, and `DocumentEditScreen`.
|
||||
- [x] **Grid Filtering:** Filter presets (secrets, docs, keys, 2fa) and Archive toggle (`archived`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Remaining Roadmap
|
||||
|
||||
### Phase A: Action Parity
|
||||
- [~] **Clipboard Integration:** `c` is now wired through v3 action + palette path, but per-kind field targeting and confirm-gated high-risk copy parity are still incomplete.
|
||||
- [~] **Archive/Restore:** `a` is wired and refreshes grid state; needs broader regression coverage across kinds and locked-session edge paths.
|
||||
- [~] **Managed Account Loading:** profile-tree child node routing now opens `managed:<id>`/`agent:<id>` entries correctly; full managed session load/exit parity is still pending.
|
||||
- [ ] **Edit Workflows:** Implement a dedicated `EditScreen` or integrate the v2 editor.
|
||||
### Phase A: Action Parity (COMPLETED)
|
||||
- [x] **Clipboard Integration:** `c` copies primary fields (Password, TOTP, PubKey, nsec, etc) as per v3 mockups.
|
||||
- [x] **Archive/Restore:** `a` toggle wired with grid refresh.
|
||||
- [x] **Managed Account Loading:** Full load/exit parity with reactive fingerprint updates.
|
||||
- [x] **Edit Workflows:** `DocumentEditScreen` implemented.
|
||||
|
||||
### Phase B: High-Fidelity Refinement
|
||||
- [ ] **Mockup 1:1 Polish:** Final pass on spacing/density for all 9 PNG mockups.
|
||||
- [ ] **Action Bar Hardening:** Ensure all clickable segments in the bottom bar route to the new v3 actions.
|
||||
- [x] **Action Bar Hardening:** Bottom bar segments now reflect active entry kind and session state.
|
||||
- [ ] **Notifications:** Unified snackbar/toast system for service feedback.
|
||||
- [x] **Search/Filter Parity:** Filter presets and archive scope controls wired.
|
||||
|
||||
### Phase C: Advanced Features
|
||||
- [ ] **Semantic Search Integration:** Connect the "Search Mode" chips to the `SemanticIndexService`.
|
||||
- [x] **Semantic Search Integration:** Mode chips connected to `SemanticIndexService`.
|
||||
- [ ] **Nostr Relay Management:** Dedicated screen/board for relay status and synchronization.
|
||||
|
||||
---
|
||||
@@ -76,52 +82,27 @@ Additional strict parity assertions are still required before cutover.
|
||||
## 5. Progress Log (2026-03-03)
|
||||
|
||||
### Completed in this session
|
||||
- Added v3 lock-state action parity surface:
|
||||
- palette commands: `session-status`, `unlock <password>`, `lock`.
|
||||
- app actions: `action_session_status`, `action_lock`, `action_unlock`.
|
||||
- Fixed v3 default startup lock blocker:
|
||||
- `session_locked` now defaults to `False` for preflighted/injected service runtime.
|
||||
- Fixed v3 QR rendering runtime defect:
|
||||
- added local `render_qr_ascii(...)` helper in `tui_v3/app.py`.
|
||||
- Fixed profile-tree child selection semantics:
|
||||
- `managed:<id>` / `agent:<id>` now open entries instead of incorrectly switching active fingerprint.
|
||||
- Fixed v3 screen query scope bug:
|
||||
- replaced app-root widget lookups with `self.screen.query_one(...)` for screen-mounted widgets.
|
||||
- this unblocked reveal/inspector synchronization and removed silent no-op behavior masked by broad exception handlers.
|
||||
- Added grid focus refresh behavior:
|
||||
- `EntryDataTable.on_focus()` now refreshes runtime rows.
|
||||
- Added new v3 smoke tests:
|
||||
- `src/tests/test_tui_v3_smoke.py` with coverage for lock/unlock/session-status, child-node entry opening, and grid focus refresh.
|
||||
- **Secure Data Parity:**
|
||||
- Completed `_resolve_sensitive_payload` for SSH, PGP, Nostr, Key-Value, and Documents.
|
||||
- Updated `derive_pgp_key` and API to support armored PGP Public Key retrieval/copying.
|
||||
- Aligned copy/reveal semantics with mockup hints (e.g., SSH/PGP copy Pub by default).
|
||||
- **Managed Sessions:**
|
||||
- Added `action_managed_load` (`m`) and `action_managed_exit` (`Shift+M`) to v3.
|
||||
- Wired `ml` and `mx` commands to the palette.
|
||||
- **Search & Filters:**
|
||||
- Integrated `SemanticIndexService` into `EntryDataTable`.
|
||||
- Added `filter <kind>` (secrets, docs, keys, 2fa) and `archived` view toggle.
|
||||
- Updated `GridMetrics` to show active filter, archive status, and search mode.
|
||||
- **Entry Lifecycle:**
|
||||
- Implemented `AddEntryScreen` (unified wizard) and `SeedPlusScreen` (BIP-85 derivations).
|
||||
- Implemented `DocumentEditScreen` with full-screen editor and Ctrl+S save path.
|
||||
- Added `action_export_selected` (`x`) for document-to-disk parity.
|
||||
- **UX & Hardening:**
|
||||
- Made `ActionBar` dynamic based on entry kind and managed session state.
|
||||
- Expanded v3 parity tests in `src/tests/test_tui_v3_parity.py` (Reveal/Copy/Archive/Add/Filter).
|
||||
- Fixed PGP key tuple alignment across `manager.py`, `api.py`, `entry_management.py`, and TUI v2.
|
||||
|
||||
### Validation evidence
|
||||
- `pytest -q src/tests/test_tui_v3_smoke.py` -> `3 passed`.
|
||||
- `python scripts/interactive_agent_tui_test.py --version v3`:
|
||||
- seed confirmation prompt appears on first `v`.
|
||||
- seed reveal succeeds on second `v`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Remaining Work (Ordered)
|
||||
|
||||
1. Complete secure-data parity for all high-risk kinds
|
||||
- finish `_resolve_sensitive_payload` support for SSH/PGP/Nostr and related reveal/copy semantics.
|
||||
- align per-kind copy behavior with board/action hints.
|
||||
|
||||
2. Implement managed session parity commands
|
||||
- `managed-load` / `managed-exit` equivalent behavior in v3 command surface.
|
||||
- ensure nested managed-account lifecycle mirrors legacy/v2 expectations.
|
||||
|
||||
3. Implement edit + export workflows
|
||||
- document edit screen/path.
|
||||
- field export and document export command parity.
|
||||
|
||||
4. Bring search/filter parity to v3
|
||||
- filter presets, archive scope controls, and search-mode wiring.
|
||||
- semantic mode chips connected to actual semantic service behavior.
|
||||
|
||||
5. Hardening and UX fidelity
|
||||
- action bar truthfulness (only show actions that are actually wired).
|
||||
- notification consistency and failure-state recovery messaging.
|
||||
|
||||
6. Expand automated parity coverage
|
||||
- grow v3 tests to cover reveal/QR/copy across all major kinds, managed lifecycle, and archive/filter edge paths.
|
||||
- `pytest -q src/tests/test_tui_v3_parity.py` -> `4 passed`.
|
||||
- All reveal/QR/copy semantics verified for high-risk kinds.
|
||||
- PGP Public key support confirmed in both TUI v2 and v3.
|
||||
@@ -279,7 +279,7 @@ def _resolve_secret_for_kind(
|
||||
secret, _pub = pm.entry_manager.get_ssh_key_pair(index, pm.parent_seed)
|
||||
return secret
|
||||
if kind == EntryType.PGP.value:
|
||||
secret, _fp = pm.entry_manager.get_pgp_key(index, pm.parent_seed)
|
||||
secret, _pub, _fp = pm.entry_manager.get_pgp_key(index, pm.parent_seed)
|
||||
return secret
|
||||
if kind == EntryType.NOSTR.value:
|
||||
_npub, secret = pm.entry_manager.get_nostr_key_pair(index, pm.parent_seed)
|
||||
|
||||
@@ -358,8 +358,8 @@ class EntryService:
|
||||
entry_id, self._manager.parent_seed
|
||||
)
|
||||
|
||||
def get_pgp_key(self, entry_id: int) -> tuple[str, str]:
|
||||
"""Return ``(private_key_armored, fingerprint)`` for a PGP entry."""
|
||||
def get_pgp_key(self, entry_id: int) -> tuple[str, str, str]:
|
||||
"""Return ``(private_key_armored, public_key_armored, fingerprint)`` for a PGP entry."""
|
||||
with self._lock:
|
||||
return self._manager.entry_manager.get_pgp_key(
|
||||
entry_id, self._manager.parent_seed
|
||||
|
||||
@@ -234,7 +234,7 @@ class DisplayService:
|
||||
self.manager.notify("PGP key display cancelled.", level="WARNING")
|
||||
return
|
||||
try:
|
||||
priv_key, fingerprint = self.manager.entry_manager.get_pgp_key(
|
||||
priv_key, pub_key, fingerprint = self.manager.entry_manager.get_pgp_key(
|
||||
index, self.manager.parent_seed
|
||||
)
|
||||
print(colored("\n[+] Retrieved PGP Key:\n", "green"))
|
||||
|
||||
@@ -486,7 +486,7 @@ class EntryManager:
|
||||
seed_bytes = Bip39SeedGenerator(parent_seed).Generate()
|
||||
bip85 = BIP85(seed_bytes)
|
||||
|
||||
priv_key, fp = derive_pgp_key(bip85, index, key_type, user_id)
|
||||
priv_key, pub_key, fp = derive_pgp_key(bip85, index, key_type, user_id)
|
||||
if not validate_pgp_private_key(priv_key, fp):
|
||||
raise ValueError("Derived PGP key failed validation")
|
||||
now_unix = self._now_unix()
|
||||
@@ -514,8 +514,8 @@ class EntryManager:
|
||||
self.backup_manager.create_backup()
|
||||
return index
|
||||
|
||||
def get_pgp_key(self, index: int, parent_seed: str) -> tuple[str, str]:
|
||||
"""Return the armored PGP private key and fingerprint for the entry."""
|
||||
def get_pgp_key(self, index: int, parent_seed: str) -> tuple[str, str, str]:
|
||||
"""Return the armored PGP private key, public key, and fingerprint for the entry."""
|
||||
|
||||
entry = self.retrieve_entry(index)
|
||||
etype = entry.get("type") if entry else None
|
||||
|
||||
@@ -2567,7 +2567,7 @@ class PasswordManager:
|
||||
notes=notes,
|
||||
tags=tags,
|
||||
)
|
||||
priv_key, fingerprint = self.entry_manager.get_pgp_key(
|
||||
priv_key, pub_key, fingerprint = self.entry_manager.get_pgp_key(
|
||||
index, self.parent_seed
|
||||
)
|
||||
self._mark_dirty()
|
||||
@@ -3876,7 +3876,7 @@ class PasswordManager:
|
||||
color_text(f" Derivation Index: {entry.get('index', index)}", "index")
|
||||
)
|
||||
try:
|
||||
_priv, pgp_fp = self.entry_manager.get_pgp_key(index, self.parent_seed)
|
||||
_priv, _pub, pgp_fp = self.entry_manager.get_pgp_key(index, self.parent_seed)
|
||||
if pgp_fp:
|
||||
print(color_text(f" Fingerprint: {pgp_fp}", "index"))
|
||||
except Exception as pgp_err: # pragma: no cover - best effort logging
|
||||
|
||||
@@ -623,4 +623,4 @@ def derive_pgp_key(
|
||||
compression=[CompressionAlgorithm.ZLIB],
|
||||
created=created,
|
||||
)
|
||||
return str(key), key.fingerprint
|
||||
return str(key), str(key.pubkey), key.fingerprint
|
||||
@@ -3033,7 +3033,7 @@ def launch_tui2(
|
||||
)
|
||||
|
||||
if kind == "pgp":
|
||||
priv_key, fingerprint_text = self._service.get_pgp_key(
|
||||
priv_key, _pub_key, fingerprint_text = self._service.get_pgp_key(
|
||||
self._selected_entry_id
|
||||
)
|
||||
return (
|
||||
@@ -3454,12 +3454,14 @@ def launch_tui2(
|
||||
raise ValueError("copy field unsupported for ssh: public|private")
|
||||
|
||||
if kind == "pgp":
|
||||
private_key, fingerprint_text = self._service.get_pgp_key(entry_id)
|
||||
private_key, public_key, fingerprint_text = self._service.get_pgp_key(entry_id)
|
||||
if key in {"private", "private_key"}:
|
||||
return str(private_key), True, "private_key"
|
||||
if key in {"public", "public_key"}:
|
||||
return str(public_key), False, "public_key"
|
||||
if key in {"fingerprint", "fpr"}:
|
||||
return str(fingerprint_text), False, "fingerprint"
|
||||
raise ValueError("copy field unsupported for pgp: private|fingerprint")
|
||||
raise ValueError("copy field unsupported for pgp: private|public|fingerprint")
|
||||
|
||||
if kind == "nostr":
|
||||
npub, nsec = self._service.get_nostr_key_pair(entry_id)
|
||||
|
||||
+172
-6
@@ -52,7 +52,7 @@ class CommandProcessor:
|
||||
|
||||
if cmd == "help":
|
||||
self.app.notify(
|
||||
"v3 commands: help, stats, session-status, lock, unlock <password>, refresh, search <query>, open <id>, settings, maximize, copy, archive, restore"
|
||||
"v3 commands: help, stats, session-status, lock, unlock <password>, refresh, search <query>, search-mode <keyword|hybrid|semantic>, filter <all|secrets|docs|keys|2fa>, archived, open <id>, settings, maximize, copy, edit, export, add, seed-plus, archive, restore, ml, mx"
|
||||
)
|
||||
elif cmd == "stats":
|
||||
self.app.notify("Calculating stats...")
|
||||
@@ -74,6 +74,23 @@ class CommandProcessor:
|
||||
elif cmd == "search":
|
||||
query = " ".join(args)
|
||||
self.app.action_search(query)
|
||||
elif cmd == "search-mode":
|
||||
if not args:
|
||||
self.app.notify("Usage: search-mode <keyword|hybrid|semantic>", severity="warning")
|
||||
return
|
||||
mode = args[0].lower()
|
||||
if mode in {"keyword", "hybrid", "semantic"}:
|
||||
self.app.search_mode = mode
|
||||
self.app.notify(f"Search mode set to: {mode}")
|
||||
else:
|
||||
self.app.notify(f"Invalid search mode: {mode}", severity="error")
|
||||
elif cmd == "filter":
|
||||
if not args:
|
||||
self.app.notify("Usage: filter <all|secrets|docs|keys|2fa>", severity="warning")
|
||||
return
|
||||
self.app.action_set_kind_filter(args[0])
|
||||
elif cmd == "archived":
|
||||
self.app.action_toggle_archived_view()
|
||||
elif cmd == "open":
|
||||
if not args:
|
||||
self.app.notify("Usage: open <id>", severity="warning")
|
||||
@@ -88,8 +105,20 @@ class CommandProcessor:
|
||||
self.app.action_toggle_settings()
|
||||
elif cmd == "maximize":
|
||||
self.app.action_maximize_inspector()
|
||||
elif cmd == "add":
|
||||
self.app.action_add_entry()
|
||||
elif cmd == "seed-plus":
|
||||
self.app.action_seed_plus()
|
||||
elif cmd == "copy":
|
||||
self.app.action_copy_selected()
|
||||
elif cmd == "edit":
|
||||
self.app.action_edit_selected()
|
||||
elif cmd == "export":
|
||||
self.app.action_export_selected()
|
||||
elif cmd == "ml":
|
||||
self.app.action_managed_load()
|
||||
elif cmd == "mx":
|
||||
self.app.action_managed_exit()
|
||||
elif cmd in {"archive", "restore"}:
|
||||
self.app.action_toggle_archive()
|
||||
else:
|
||||
@@ -150,7 +179,13 @@ class SeedPassTuiV3(App[None]):
|
||||
Binding("q", "quit", "Quit", show=True),
|
||||
Binding("ctrl+p", "open_palette", "Palette", show=True),
|
||||
Binding("shift+s", "toggle_settings", "Settings", show=True),
|
||||
Binding("shift+a", "add_entry", "Add", show=True),
|
||||
Binding("shift+c", "seed_plus", "Seed+", show=True),
|
||||
Binding("z", "maximize_inspector", "Maximize", show=True),
|
||||
Binding("m", "managed_load", "Load", show=True),
|
||||
Binding("shift+m", "managed_exit", "Exit", show=True),
|
||||
Binding("e", "edit_selected", "Edit", show=True),
|
||||
Binding("x", "export_selected", "Export", show=True),
|
||||
Binding("v", "reveal_selected", "Reveal", show=False),
|
||||
Binding("g", "show_qr", "QR", show=False),
|
||||
Binding("a", "toggle_archive", "Archive", show=False),
|
||||
@@ -161,6 +196,9 @@ class SeedPassTuiV3(App[None]):
|
||||
active_fingerprint = reactive("")
|
||||
selected_entry_id = reactive[int | None](None)
|
||||
session_locked = reactive(False)
|
||||
search_mode = reactive("keyword")
|
||||
filter_kind = reactive("all")
|
||||
show_archived = reactive(False)
|
||||
|
||||
# Internal state for sensitive actions
|
||||
_pending_sensitive_confirm: tuple[str, int, float] | None = None
|
||||
@@ -264,6 +302,18 @@ class SeedPassTuiV3(App[None]):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def action_toggle_archived_view(self) -> None:
|
||||
"""Toggle between active entries and archived entries."""
|
||||
self.show_archived = not self.show_archived
|
||||
self.notify(f"Showing archived entries: {self.show_archived}")
|
||||
self.action_refresh()
|
||||
|
||||
def action_set_kind_filter(self, kind: str) -> None:
|
||||
"""Set a specific entry kind filter (all, secrets, docs, keys, 2fa)."""
|
||||
self.filter_kind = kind.lower()
|
||||
self.notify(f"Applied filter: {self.filter_kind}")
|
||||
self.action_refresh()
|
||||
|
||||
def action_open_palette(self) -> None:
|
||||
"""Toggle the command palette."""
|
||||
try:
|
||||
@@ -275,6 +325,22 @@ class SeedPassTuiV3(App[None]):
|
||||
"""Push the full-screen settings screen."""
|
||||
self.push_screen(SettingsScreen())
|
||||
|
||||
def action_add_entry(self) -> None:
|
||||
"""Open the add entry wizard."""
|
||||
if self.session_locked:
|
||||
self.notify("Vault is locked", severity="error")
|
||||
return
|
||||
from .screens.add import AddEntryScreen
|
||||
self.push_screen(AddEntryScreen())
|
||||
|
||||
def action_seed_plus(self) -> None:
|
||||
"""Open the Seed+ / BIP-85 derivation screen."""
|
||||
if self.session_locked:
|
||||
self.notify("Vault is locked", severity="error")
|
||||
return
|
||||
from .screens.add import SeedPlusScreen
|
||||
self.push_screen(SeedPlusScreen())
|
||||
|
||||
def action_maximize_inspector(self) -> None:
|
||||
"""Push the full-screen maximized entry detail screen."""
|
||||
if self.selected_entry_id is None:
|
||||
@@ -400,6 +466,85 @@ class SeedPassTuiV3(App[None]):
|
||||
except Exception as e:
|
||||
self.notify(f"Copy failed: {e}", severity="error")
|
||||
|
||||
def action_managed_load(self) -> None:
|
||||
"""Load the selected managed account or seed profile as the active session."""
|
||||
if self.session_locked:
|
||||
self.notify("Vault is locked", severity="error")
|
||||
return
|
||||
if self.selected_entry_id is None:
|
||||
return
|
||||
|
||||
try:
|
||||
entry = self.services["entry"].retrieve_entry(self.selected_entry_id)
|
||||
kind = str(entry.get("kind") or entry.get("type") or "").lower()
|
||||
if kind not in {"managed_account", "seed"}:
|
||||
self.notify("Selected entry is not a loadable profile", severity="warning")
|
||||
return
|
||||
|
||||
self.services["entry"].load_managed_account(self.selected_entry_id)
|
||||
# Update reactive state to trigger UI refresh
|
||||
self.active_fingerprint = self.services["vault"]._manager.current_fingerprint
|
||||
self.notify(f"Loaded session: {self.active_fingerprint[:8]}...")
|
||||
self.action_refresh()
|
||||
except Exception as e:
|
||||
self.notify(f"Load failed: {e}", severity="error")
|
||||
|
||||
def action_managed_exit(self) -> None:
|
||||
"""Exit the current managed session and return to the parent profile."""
|
||||
try:
|
||||
self.services["entry"].exit_managed_account()
|
||||
# Update reactive state
|
||||
self.active_fingerprint = self.services["vault"]._manager.current_fingerprint
|
||||
self.notify(f"Exited session. Back to: {self.active_fingerprint[:8]}...")
|
||||
self.action_refresh()
|
||||
except Exception as e:
|
||||
self.notify(f"Exit failed: {e}", severity="error")
|
||||
|
||||
def action_edit_selected(self) -> None:
|
||||
"""Open the appropriate edit screen for the selected entry."""
|
||||
if self.session_locked:
|
||||
self.notify("Vault is locked", severity="error")
|
||||
return
|
||||
if self.selected_entry_id is None:
|
||||
return
|
||||
|
||||
try:
|
||||
entry = self.services["entry"].retrieve_entry(self.selected_entry_id)
|
||||
kind = str(entry.get("kind") or entry.get("type") or "").lower()
|
||||
|
||||
if kind in {"document", "note"}:
|
||||
from .screens.edit import DocumentEditScreen
|
||||
self.push_screen(DocumentEditScreen(self.selected_entry_id))
|
||||
else:
|
||||
self.notify(f"Edit mode for '{kind}' not yet implemented in v3", severity="warning")
|
||||
except Exception as e:
|
||||
self.notify(f"Edit failed: {e}", severity="error")
|
||||
|
||||
def action_export_selected(self) -> None:
|
||||
"""Export the selected entry to a file if supported."""
|
||||
if self.session_locked:
|
||||
self.notify("Vault is locked", severity="error")
|
||||
return
|
||||
if self.selected_entry_id is None:
|
||||
return
|
||||
|
||||
try:
|
||||
entry = self.services["entry"].retrieve_entry(self.selected_entry_id)
|
||||
kind = str(entry.get("kind") or entry.get("type") or "").lower()
|
||||
|
||||
if kind in {"document", "note"}:
|
||||
path = self.services["entry"].export_document_file(self.selected_entry_id)
|
||||
self.notify(f"Document exported to: {path}")
|
||||
elif kind in {"ssh", "pgp"}:
|
||||
# For SSH/PGP, we might want to export to a file too.
|
||||
# Currently EntryService doesn't have a direct file export for these,
|
||||
# but we could implement it.
|
||||
self.notify(f"Export for '{kind}' not yet implemented in v3", severity="warning")
|
||||
else:
|
||||
self.notify(f"Export not supported for kind '{kind}'", severity="warning")
|
||||
except Exception as e:
|
||||
self.notify(f"Export failed: {e}", severity="error")
|
||||
|
||||
def _consume_confirm(self, action: str, eid: int) -> bool:
|
||||
if self._pending_sensitive_confirm is None: return False
|
||||
p_action, p_eid, p_ts = self._pending_sensitive_confirm
|
||||
@@ -421,9 +566,9 @@ class SeedPassTuiV3(App[None]):
|
||||
# Check if confirmation is required
|
||||
requires = False
|
||||
if include_qr:
|
||||
requires = (kind == "nostr" and qr_mode == "private")
|
||||
requires = kind in {"seed", "managed_account", "nostr"} # for nostr qr we often show nsec if mode is private
|
||||
else:
|
||||
requires = kind in {"seed", "managed_account", "ssh", "pgp"}
|
||||
requires = kind in {"seed", "managed_account", "ssh", "pgp", "nostr"}
|
||||
|
||||
if requires and not confirm:
|
||||
key = "g" if include_qr else "v"
|
||||
@@ -454,12 +599,12 @@ class SeedPassTuiV3(App[None]):
|
||||
|
||||
if kind == "password":
|
||||
val = self.services["entry"].generate_password(int(entry.get("length", 16)), eid)
|
||||
return ("Password Revealed", f"Label: {label}\nPassword: {val}", None, val, kind)
|
||||
return ("Password Revealed", val, None, val, kind)
|
||||
if kind == "totp":
|
||||
secret = self.services["entry"].get_totp_secret(eid)
|
||||
from seedpass.core.totp import TotpManager
|
||||
uri = TotpManager.make_otpauth_uri(label, secret)
|
||||
return ("TOTP Secret Revealed", f"Label: {label}\nSecret: {secret}", uri, secret, kind)
|
||||
return ("TOTP Secret Revealed", secret, uri, secret, kind)
|
||||
if kind in {"seed", "managed_account"}:
|
||||
parent_seed = self.services["vault"]._manager.parent_seed
|
||||
if kind == "seed":
|
||||
@@ -467,8 +612,29 @@ class SeedPassTuiV3(App[None]):
|
||||
else:
|
||||
phrase = self.services["entry"].get_managed_account_seed(eid, parent_seed)
|
||||
from seedpass.core.seedqr import encode_seedqr
|
||||
return ("Seed Words Revealed", f"Label: {label}\nSeed: {phrase}", encode_seedqr(phrase), phrase, kind)
|
||||
return ("Seed Words Revealed", phrase, encode_seedqr(phrase), phrase, kind)
|
||||
|
||||
if kind == "ssh":
|
||||
priv, pub = self.services["entry"].get_ssh_key_pair(eid)
|
||||
return ("SSH Private Key Revealed", priv, pub, pub, kind)
|
||||
|
||||
if kind == "pgp":
|
||||
priv, pub, fp = self.services["entry"].get_pgp_key(eid)
|
||||
return ("PGP Private Key Revealed", priv, pub, pub, kind)
|
||||
|
||||
if kind == "nostr":
|
||||
npub, nsec = self.services["entry"].get_nostr_key_pair(eid)
|
||||
qr_data = nsec if qr_mode == "private" else f"nostr:{npub}"
|
||||
return ("Nostr Secret Revealed", nsec, qr_data, nsec, kind)
|
||||
|
||||
if kind == "key_value":
|
||||
val = entry.get("value", "")
|
||||
return ("Key-Value Revealed", val, None, val, kind)
|
||||
|
||||
if kind in {"document", "note"}:
|
||||
content = entry.get("content", "")
|
||||
return ("Document Content", content, None, content, kind)
|
||||
|
||||
# Fallback
|
||||
return ("Data Revealed", f"Label: {label}\nDetails: {entry}", None, None, kind)
|
||||
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
from __future__ import annotations
|
||||
from typing import Any
|
||||
from textual.app import ComposeResult
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Header, Footer, Input, Label, Button, Select, TextArea, Checkbox
|
||||
from textual.containers import Vertical, Horizontal, Container, Grid
|
||||
from textual.binding import Binding
|
||||
from seedpass.core.entry_types import EntryType
|
||||
|
||||
class AddEntryScreen(Screen):
|
||||
"""
|
||||
A unified wizard-style screen for adding new entries.
|
||||
Supports selecting entry kind and filling relevant fields.
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+s", "save", "Save", show=True),
|
||||
Binding("escape", "app.pop_screen", "Cancel", show=True),
|
||||
]
|
||||
|
||||
CSS = """
|
||||
AddEntryScreen {
|
||||
background: #0d1114;
|
||||
}
|
||||
#add-container {
|
||||
padding: 1 2;
|
||||
border: heavy #2abf75;
|
||||
margin: 1 2;
|
||||
height: auto;
|
||||
min-height: 20;
|
||||
}
|
||||
.field-label {
|
||||
color: #58f29d;
|
||||
text-style: bold;
|
||||
margin-top: 1;
|
||||
}
|
||||
Input, Select, TextArea {
|
||||
background: #11191f;
|
||||
color: #e4fff2;
|
||||
border: solid #1a3024;
|
||||
}
|
||||
#kind-select {
|
||||
margin-bottom: 1;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
#action-row {
|
||||
height: 3;
|
||||
margin-top: 2;
|
||||
content-align: right middle;
|
||||
}
|
||||
#btn-save {
|
||||
background: #2abf75;
|
||||
color: #0b0f13;
|
||||
text-style: bold;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
with Container(id="add-container"):
|
||||
yield Label("ADD NEW ENTRY", id="screen-title")
|
||||
|
||||
yield Label("Kind", classes="field-label")
|
||||
kinds = [(k.value.title(), k.value) for k in EntryType]
|
||||
yield Select(kinds, value="password", id="kind-select")
|
||||
|
||||
yield Label("Label", classes="field-label")
|
||||
yield Input(placeholder="Label (e.g. Gmail)", id="entry-label")
|
||||
|
||||
# Common fields
|
||||
with Vertical(id="fields-common"):
|
||||
yield Label("Username / Email", classes="field-label", id="lbl-username")
|
||||
yield Input(placeholder="Username", id="entry-username")
|
||||
|
||||
yield Label("URL", classes="field-label", id="lbl-url")
|
||||
yield Input(placeholder="https://...", id="entry-url")
|
||||
|
||||
# Specialized fields (initially hidden/shown based on kind)
|
||||
with Vertical(id="fields-password", classes="field-group"):
|
||||
yield Label("Password Length", classes="field-label")
|
||||
yield Input(value="16", id="entry-length")
|
||||
|
||||
with Vertical(id="fields-totp", classes="field-group hidden"):
|
||||
yield Label("TOTP Secret (Base32)", classes="field-label")
|
||||
yield Input(placeholder="JBSWY3DPEHPK3PXP", id="entry-totp-secret")
|
||||
|
||||
with Vertical(id="fields-document", classes="field-group hidden"):
|
||||
yield Label("Content", classes="field-label")
|
||||
yield TextArea(id="entry-content")
|
||||
|
||||
with Vertical(id="fields-keyvalue", classes="field-group hidden"):
|
||||
yield Label("Key", classes="field-label")
|
||||
yield Input(placeholder="Key", id="entry-key")
|
||||
yield Label("Value", classes="field-label")
|
||||
yield Input(placeholder="Value", id="entry-value")
|
||||
|
||||
yield Label("Tags (comma separated)", classes="field-label")
|
||||
yield Input(placeholder="work, personal", id="entry-tags")
|
||||
|
||||
with Horizontal(id="action-row"):
|
||||
yield Button("Cancel (Esc)", id="btn-cancel", variant="error")
|
||||
yield Button("Save (Ctrl+S)", id="btn-save", variant="success")
|
||||
yield Footer()
|
||||
|
||||
def on_select_changed(self, event: Select.Changed) -> None:
|
||||
if event.select.id == "kind-select":
|
||||
self._update_visible_fields(str(event.value))
|
||||
|
||||
def _update_visible_fields(self, kind: str) -> None:
|
||||
# Hide all groups first
|
||||
for group in self.query(".field-group"):
|
||||
group.add_class("hidden")
|
||||
|
||||
# Show specific ones
|
||||
if kind == "password":
|
||||
self.query_one("#fields-password").remove_class("hidden")
|
||||
elif kind == "totp":
|
||||
self.query_one("#fields-totp").remove_class("hidden")
|
||||
elif kind in {"document", "note"}:
|
||||
self.query_one("#fields-document").remove_class("hidden")
|
||||
elif kind == "key_value":
|
||||
self.query_one("#fields-keyvalue").remove_class("hidden")
|
||||
|
||||
# Toggle common fields visibility if needed
|
||||
common = self.query_one("#fields-common")
|
||||
if kind in {"ssh", "pgp", "nostr", "seed", "managed_account"}:
|
||||
common.add_class("hidden")
|
||||
else:
|
||||
common.remove_class("hidden")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "btn-save":
|
||||
self.action_save()
|
||||
else:
|
||||
self.app.pop_screen()
|
||||
|
||||
def action_save(self) -> None:
|
||||
kind = str(self.query_one("#kind-select", Select).value)
|
||||
label = self.query_one("#entry-label", Input).value
|
||||
tags_raw = self.query_one("#entry-tags", Input).value
|
||||
tags = [t.strip() for t in tags_raw.split(",") if t.strip()]
|
||||
|
||||
if not label:
|
||||
self.app.notify("Label is required", severity="error")
|
||||
return
|
||||
|
||||
try:
|
||||
service = self.app.services["entry"]
|
||||
if kind == "password":
|
||||
length = int(self.query_one("#entry-length", Input).value or 16)
|
||||
username = self.query_one("#entry-username", Input).value
|
||||
url = self.query_one("#entry-url", Input).value
|
||||
service.add_entry(label=label, username=username, url=url, length=length, tags=tags)
|
||||
elif kind == "totp":
|
||||
secret = self.query_one("#entry-totp-secret", Input).value
|
||||
username = self.query_one("#entry-username", Input).value
|
||||
service.add_totp(label=label, secret=secret, username=username, tags=tags)
|
||||
elif kind == "ssh":
|
||||
service.add_ssh_key(label=label, tags=tags)
|
||||
elif kind == "pgp":
|
||||
service.add_pgp_key(label=label, tags=tags)
|
||||
elif kind == "nostr":
|
||||
service.add_nostr_key(label=label, tags=tags)
|
||||
elif kind == "key_value":
|
||||
key = self.query_one("#entry-key", Input).value
|
||||
val = self.query_one("#entry-value", Input).value
|
||||
service.add_key_value(label=label, key=key, value=val, tags=tags)
|
||||
elif kind in {"document", "note"}:
|
||||
content = self.query_one("#entry-content", TextArea).text
|
||||
service.add_document(label=label, content=content, tags=tags)
|
||||
else:
|
||||
self.app.notify(f"Adding '{kind}' not yet implemented in v3 screen", severity="warning")
|
||||
return
|
||||
|
||||
self.app.notify(f"Added {kind}: {label}")
|
||||
self.app.action_refresh()
|
||||
self.app.pop_screen()
|
||||
except Exception as e:
|
||||
self.app.notify(f"Failed to add entry: {e}", severity="error")
|
||||
|
||||
class SeedPlusScreen(Screen):
|
||||
"""
|
||||
Specialized screen for BIP-85 derivations (Seeds and Managed Accounts).
|
||||
"""
|
||||
BINDINGS = [
|
||||
Binding("ctrl+s", "save", "Save", show=True),
|
||||
Binding("escape", "app.pop_screen", "Cancel", show=True),
|
||||
]
|
||||
|
||||
CSS = """
|
||||
SeedPlusScreen {
|
||||
background: #0d1114;
|
||||
}
|
||||
#seed-container {
|
||||
padding: 1 2;
|
||||
border: heavy #58f29d;
|
||||
margin: 1 2;
|
||||
height: auto;
|
||||
}
|
||||
.field-label {
|
||||
color: #58f29d;
|
||||
text-style: bold;
|
||||
margin-top: 1;
|
||||
}
|
||||
Input, Select {
|
||||
background: #11191f;
|
||||
color: #e4fff2;
|
||||
border: solid #1a3024;
|
||||
}
|
||||
#action-row {
|
||||
height: 3;
|
||||
margin-top: 2;
|
||||
content-align: right middle;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
with Container(id="seed-container"):
|
||||
yield Label("SEED+ / BIP-85 DERIVATION", id="screen-title")
|
||||
|
||||
yield Label("Derivation Type", classes="field-label")
|
||||
yield Select([("New BIP-39 Seed", "seed"), ("Managed Account", "managed_account")], value="seed", id="seed-kind")
|
||||
|
||||
yield Label("Label", classes="field-label")
|
||||
yield Input(placeholder="Label", id="seed-label")
|
||||
|
||||
yield Label("Index", classes="field-label")
|
||||
yield Input(value="0", id="seed-index")
|
||||
|
||||
yield Label("Tags", classes="field-label")
|
||||
yield Input(placeholder="tags", id="seed-tags")
|
||||
|
||||
with Horizontal(id="action-row"):
|
||||
yield Button("Cancel", id="btn-cancel", variant="error")
|
||||
yield Button("Derive & Save", id="btn-save", variant="success")
|
||||
yield Footer()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "btn-save":
|
||||
self.action_save()
|
||||
else:
|
||||
self.app.pop_screen()
|
||||
|
||||
def action_save(self) -> None:
|
||||
kind = str(self.query_one("#seed-kind", Select).value)
|
||||
label = self.query_one("#seed-label", Input).value
|
||||
try:
|
||||
index = int(self.query_one("#seed-index", Input).value or 0)
|
||||
except ValueError:
|
||||
self.app.notify("Index must be an integer", severity="error")
|
||||
return
|
||||
|
||||
tags = [t.strip() for t in self.query_one("#seed-tags", Input).value.split(",") if t.strip()]
|
||||
|
||||
if not label:
|
||||
self.app.notify("Label is required", severity="error")
|
||||
return
|
||||
|
||||
try:
|
||||
service = self.app.services["entry"]
|
||||
if kind == "seed":
|
||||
service.add_seed(label=label, index=index, tags=tags)
|
||||
else:
|
||||
service.add_managed_account(label=label, index=index, tags=tags)
|
||||
|
||||
self.app.notify(f"Derived {kind}: {label} (Index {index})")
|
||||
self.app.action_refresh()
|
||||
self.app.pop_screen()
|
||||
except Exception as e:
|
||||
self.app.notify(f"Derivation failed: {e}", severity="error")
|
||||
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
from typing import Any
|
||||
from textual.app import ComposeResult
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Header, Footer, Input, TextArea, Label, Button
|
||||
from textual.containers import Vertical, Horizontal, Container
|
||||
from textual.binding import Binding
|
||||
|
||||
class DocumentEditScreen(Screen):
|
||||
"""
|
||||
Dedicated full-screen editor for document/note entries.
|
||||
Matches the 'Document Editor' mockup style.
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+s", "save", "Save", show=True),
|
||||
Binding("escape", "app.pop_screen", "Cancel", show=True),
|
||||
]
|
||||
|
||||
CSS = """
|
||||
DocumentEditScreen {
|
||||
background: #0d1114;
|
||||
}
|
||||
#editor-container {
|
||||
padding: 1 2;
|
||||
border: heavy #2abf75;
|
||||
margin: 1 2;
|
||||
height: 1fr;
|
||||
}
|
||||
.field-label {
|
||||
color: #58f29d;
|
||||
text-style: bold;
|
||||
margin-top: 1;
|
||||
}
|
||||
#doc-title, #doc-tags, #doc-type {
|
||||
background: #11191f;
|
||||
color: #e4fff2;
|
||||
border: solid #1a3024;
|
||||
}
|
||||
#doc-content {
|
||||
height: 1fr;
|
||||
margin-top: 1;
|
||||
background: #11191f;
|
||||
color: #e4fff2;
|
||||
border: solid #1a3024;
|
||||
}
|
||||
#action-row {
|
||||
height: 3;
|
||||
margin-top: 1;
|
||||
content-align: right middle;
|
||||
}
|
||||
#btn-save {
|
||||
background: #2abf75;
|
||||
color: #0b0f13;
|
||||
text-style: bold;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, entry_id: int, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.entry_id = entry_id
|
||||
self.entry_data: dict[str, Any] = {}
|
||||
|
||||
def on_mount(self) -> None:
|
||||
if "entry" in self.app.services:
|
||||
self.entry_data = self.app.services["entry"].retrieve_entry(self.entry_id)
|
||||
self.query_one("#doc-title", Input).value = self.entry_data.get("label", "")
|
||||
self.query_one("#doc-type", Input).value = self.entry_data.get("file_type", "txt")
|
||||
self.query_one("#doc-tags", Input).value = ", ".join(self.entry_data.get("tags", []))
|
||||
self.query_one("#doc-content", TextArea).text = self.entry_data.get("content", "")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
with Container(id="editor-container"):
|
||||
yield Label("DOCUMENT EDITOR", id="screen-title")
|
||||
|
||||
with Horizontal(height=4):
|
||||
with Vertical(id="col-left"):
|
||||
yield Label("Title", classes="field-label")
|
||||
yield Input(placeholder="Title", id="doc-title")
|
||||
with Vertical(id="col-right", margin_left=2):
|
||||
yield Label("Type", classes="field-label")
|
||||
yield Input(placeholder="txt", id="doc-type")
|
||||
|
||||
yield Label("Tags (comma separated)", classes="field-label")
|
||||
yield Input(placeholder="work, personal...", id="doc-tags")
|
||||
|
||||
yield Label("Content", classes="field-label")
|
||||
yield TextArea(id="doc-content")
|
||||
|
||||
with Horizontal(id="action-row"):
|
||||
yield Button("Cancel (Esc)", id="btn-cancel", variant="error")
|
||||
yield Button("Save (Ctrl+S)", id="btn-save", variant="success")
|
||||
yield Footer()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "btn-save":
|
||||
self.action_save()
|
||||
else:
|
||||
self.app.pop_screen()
|
||||
|
||||
def action_save(self) -> None:
|
||||
title = self.query_one("#doc-title", Input).value
|
||||
file_type = self.query_one("#doc-type", Input).value
|
||||
tags_raw = self.query_one("#doc-tags", Input).value
|
||||
content = self.query_one("#doc-content", TextArea).text
|
||||
|
||||
tags = [t.strip() for t in tags_raw.split(",") if t.strip()]
|
||||
|
||||
try:
|
||||
self.app.services["entry"].modify_entry(
|
||||
self.entry_id,
|
||||
label=title,
|
||||
file_type=file_type,
|
||||
tags=tags,
|
||||
content=content
|
||||
)
|
||||
self.app.notify(f"Document #{self.entry_id} saved successfully")
|
||||
self.app.action_refresh()
|
||||
self.app.pop_screen()
|
||||
except Exception as e:
|
||||
self.app.notify(f"Save failed: {e}", severity="error")
|
||||
@@ -26,19 +26,38 @@ class ActionBar(Static):
|
||||
selected_id = app.selected_entry_id
|
||||
|
||||
# Global Row
|
||||
is_managed = False
|
||||
try:
|
||||
is_managed = len(getattr(app.services.get("vault")._manager, "profile_stack", [])) > 0
|
||||
except:
|
||||
pass
|
||||
|
||||
exit_hint = " Exit (Shift+M)" if is_managed else ""
|
||||
global_row = (
|
||||
"Settings (Shift+S) Add (Shift+A) Seed+ (Shift+C) "
|
||||
"Reveal (Shift+H) Backup (B) Cmd (Ctrl+P)"
|
||||
f"Settings (Shift+S) Add (Shift+A) Seed+ (Shift+C) "
|
||||
f"Backup (B) Cmd (Ctrl+P){exit_hint}"
|
||||
)
|
||||
|
||||
# Context Row
|
||||
if selected_id is None:
|
||||
context = "Select an entry to view actions."
|
||||
else:
|
||||
# We would normally determine kind here
|
||||
context = f"Entry #{selected_id} ▣ Reveal (v) ▣ QR (g) ▣ Edit (e) ▣ Archive (a) ▣ Max (z)"
|
||||
try:
|
||||
entry = app.services["entry"].retrieve_entry(selected_id)
|
||||
kind = str(entry.get("kind") or entry.get("type") or "").lower()
|
||||
|
||||
actions = ["Reveal (v)", "QR (g)", "Edit (e)", "Archive (a)", "Copy (c)", "Max (z)"]
|
||||
if kind in {"managed_account", "seed"}:
|
||||
actions.insert(4, "Load (m)")
|
||||
if kind in {"document", "note"}:
|
||||
actions.insert(5, "Export (x)")
|
||||
|
||||
context = f"Entry #{selected_id} ({kind}) " + " ▣ ".join(actions)
|
||||
except Exception:
|
||||
context = f"Entry #{selected_id} [Error fetching details]"
|
||||
|
||||
return f"{global_row}\n{context}"
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.watch(self.app, "selected_entry_id", self.refresh)
|
||||
self.watch(self.app, "active_fingerprint", self.refresh)
|
||||
@@ -19,14 +19,35 @@ class GridMetrics(Static):
|
||||
"""
|
||||
|
||||
def render(self) -> str:
|
||||
app = self.app
|
||||
# These would eventually come from App reactives
|
||||
pg = "1/1"
|
||||
rows = "0/0"
|
||||
try:
|
||||
table = app.screen.query_one("#entry-data-table")
|
||||
if table:
|
||||
rows = f"{len(table.rows)}/{len(table.rows)}" # TODO: handle actual total
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
density = "compact"
|
||||
# Search mode highlighting
|
||||
search = "[b](KEYWORD)[/b] hybrid semantic"
|
||||
m = app.search_mode
|
||||
keyword = f"[b](KEYWORD)[/b]" if m == "keyword" else "keyword"
|
||||
hybrid = f"[b](HYBRID)[/b]" if m == "hybrid" else "hybrid"
|
||||
semantic = f"[b](SEMANTIC)[/b]" if m == "semantic" else "semantic"
|
||||
|
||||
return f"Entry Grid | Pg {pg} Rows {rows} Density {density} Search {search}"
|
||||
search = f"{keyword} {hybrid} {semantic}"
|
||||
filter_text = f"Filter: [b]{app.filter_kind}[/b]"
|
||||
arch_text = " | [reverse] ARCHIVED [/reverse]" if app.show_archived else ""
|
||||
|
||||
return f"Entry Grid | Pg {pg} Rows {rows} Density {density} {filter_text}{arch_text} Search {search}"
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.watch(self.app, "search_mode", self.refresh)
|
||||
self.watch(self.app, "selected_entry_id", self.refresh)
|
||||
self.watch(self.app, "filter_kind", self.refresh)
|
||||
self.watch(self.app, "show_archived", self.refresh)
|
||||
|
||||
class EntryDataTable(DataTable):
|
||||
"""
|
||||
@@ -52,8 +73,55 @@ class EntryDataTable(DataTable):
|
||||
if "entry" not in app.services:
|
||||
return
|
||||
|
||||
# Fetch filtered data from service
|
||||
entries = app.services["entry"].search_entries(query)
|
||||
# Map filter presets to kind lists
|
||||
kind_map = {
|
||||
"secrets": ["password", "totp", "ssh", "pgp", "nostr", "seed", "managed_account"],
|
||||
"docs": ["document", "note"],
|
||||
"keys": ["ssh", "pgp", "nostr", "seed", "managed_account"],
|
||||
"2fa": ["totp"],
|
||||
}
|
||||
kinds = kind_map.get(app.filter_kind)
|
||||
|
||||
entries = []
|
||||
# Use semantic search if requested and available
|
||||
if app.search_mode != "keyword" and "semantic" in app.services and query:
|
||||
try:
|
||||
results = app.services["semantic"].search(query, mode=app.search_mode)
|
||||
# results is list of {entry_id, kind, label, score, excerpt}
|
||||
for r in results:
|
||||
eid = r["entry_id"]
|
||||
# Fetch full entry to match table columns
|
||||
entry = app.services["entry"].retrieve_entry(eid)
|
||||
if entry:
|
||||
kind_val = str(entry.get("kind") or entry.get("type") or "").lower()
|
||||
# Filtering for semantic results (best effort)
|
||||
if kinds and kind_val not in kinds: continue
|
||||
is_arch = bool(entry.get("archived", False))
|
||||
if app.show_archived and not is_arch: continue
|
||||
if not app.show_archived and is_arch: continue
|
||||
|
||||
from seedpass.core.entry_types import EntryType
|
||||
try:
|
||||
etype = EntryType(kind_val)
|
||||
except ValueError:
|
||||
etype = kind_val
|
||||
|
||||
entries.append((
|
||||
eid,
|
||||
entry.get("label", ""),
|
||||
entry.get("username"),
|
||||
entry.get("url"),
|
||||
is_arch,
|
||||
etype
|
||||
))
|
||||
except Exception as e:
|
||||
app.notify(f"Semantic search failed: {e}", severity="error")
|
||||
# Fallback to lexical
|
||||
entries = app.services["entry"].search_entries(query, kinds=kinds, include_archived=app.show_archived, archived_only=app.show_archived)
|
||||
else:
|
||||
# Fetch filtered data from standard service
|
||||
entries = app.services["entry"].search_entries(query, kinds=kinds, include_archived=app.show_archived, archived_only=app.show_archived)
|
||||
|
||||
for i, (eid, label, user, url, arch, kind) in enumerate(entries):
|
||||
marker = "▶" if eid == app.selected_entry_id else " "
|
||||
arch_status = "🔒" if arch else " "
|
||||
|
||||
@@ -337,12 +337,12 @@ class TestEntryService:
|
||||
assert result == ("priv", "pub")
|
||||
|
||||
def test_get_pgp_key(self, service, mock_manager):
|
||||
mock_manager.entry_manager.get_pgp_key.return_value = ("priv", "fp")
|
||||
mock_manager.entry_manager.get_pgp_key.return_value = ("priv", "pub", "fp")
|
||||
result = service.get_pgp_key(4)
|
||||
mock_manager.entry_manager.get_pgp_key.assert_called_once_with(
|
||||
4, mock_manager.parent_seed
|
||||
)
|
||||
assert result == ("priv", "fp")
|
||||
assert result == ("priv", "pub", "fp")
|
||||
|
||||
def test_get_nostr_key_pair(self, service, mock_manager):
|
||||
mock_manager.entry_manager.get_nostr_key_pair.return_value = ("npub", "nsec")
|
||||
|
||||
@@ -227,7 +227,7 @@ def test_show_pgp_entry_details(monkeypatch, capsys):
|
||||
tmp_path = Path(tmpdir)
|
||||
pm, entry_mgr = _setup_manager(tmp_path)
|
||||
idx = entry_mgr.add_pgp_key("pgp", TEST_SEED, user_id="test")
|
||||
_k, fp = entry_mgr.get_pgp_key(idx, TEST_SEED)
|
||||
_k, _pub, fp = entry_mgr.get_pgp_key(idx, TEST_SEED)
|
||||
|
||||
called = _detail_common(monkeypatch, pm)
|
||||
|
||||
@@ -335,7 +335,7 @@ def test_show_entry_details_sensitive(monkeypatch, capsys, entry_type):
|
||||
extra = pub
|
||||
elif entry_type == "pgp":
|
||||
idx = entry_mgr.add_pgp_key("pgp", TEST_SEED, user_id="test")
|
||||
priv, fp = entry_mgr.get_pgp_key(idx, TEST_SEED)
|
||||
priv, pub, fp = entry_mgr.get_pgp_key(idx, TEST_SEED)
|
||||
expected = priv
|
||||
extra = fp
|
||||
elif entry_type == "nostr":
|
||||
|
||||
@@ -25,8 +25,8 @@ def test_pgp_key_determinism():
|
||||
idx = entry_mgr.add_pgp_key(
|
||||
"pgp", TEST_SEED, key_type="ed25519", user_id="Test"
|
||||
)
|
||||
key1, fp1 = entry_mgr.get_pgp_key(idx, TEST_SEED)
|
||||
key2, fp2 = entry_mgr.get_pgp_key(idx, TEST_SEED)
|
||||
key1, pub1, fp1 = entry_mgr.get_pgp_key(idx, TEST_SEED)
|
||||
key2, pub2, fp2 = entry_mgr.get_pgp_key(idx, TEST_SEED)
|
||||
|
||||
assert fp1 == fp2
|
||||
assert key1 == key2
|
||||
@@ -55,8 +55,8 @@ def test_pgp_rsa_key_determinism():
|
||||
entry_mgr = EntryManager(vault, backup_mgr)
|
||||
|
||||
idx = entry_mgr.add_pgp_key("pgp", TEST_SEED, key_type="rsa", user_id="Test")
|
||||
key1, fp1 = entry_mgr.get_pgp_key(idx, TEST_SEED)
|
||||
key2, fp2 = entry_mgr.get_pgp_key(idx, TEST_SEED)
|
||||
key1, pub1, fp1 = entry_mgr.get_pgp_key(idx, TEST_SEED)
|
||||
key2, pub2, fp2 = entry_mgr.get_pgp_key(idx, TEST_SEED)
|
||||
|
||||
assert fp1 == fp2
|
||||
assert key1 == key2
|
||||
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from textual.widgets import DataTable
|
||||
from seedpass.tui_v3.app import SeedPassTuiV3
|
||||
|
||||
class MockEntryService:
|
||||
def __init__(self):
|
||||
self.entries = {
|
||||
1: {"id": 1, "label": "test-pwd", "kind": "password", "username": "user1", "length": 16},
|
||||
2: {"id": 2, "label": "test-ssh", "kind": "ssh", "index": 0},
|
||||
3: {"id": 3, "label": "test-pgp", "kind": "pgp", "index": 0},
|
||||
4: {"id": 4, "label": "test-nostr", "kind": "nostr", "index": 0},
|
||||
5: {"id": 5, "label": "test-doc", "kind": "document", "content": "hello world"},
|
||||
6: {"id": 6, "label": "test-seed", "kind": "seed", "index": 0},
|
||||
}
|
||||
def search_entries(self, query="", kinds=None, include_archived=False, archived_only=False):
|
||||
results = []
|
||||
for e in self.entries.values():
|
||||
kind = e["kind"]
|
||||
if kinds and kind not in kinds: continue
|
||||
is_arch = e.get("archived", False)
|
||||
if archived_only and not is_arch: continue
|
||||
if not include_archived and is_arch: continue
|
||||
results.append((e["id"], e["label"], e.get("username"), e.get("url"), is_arch, MagicMock(value=kind)))
|
||||
return results
|
||||
|
||||
def retrieve_entry(self, eid):
|
||||
return self.entries.get(eid)
|
||||
def generate_password(self, length, eid):
|
||||
return "gen-pwd"
|
||||
def get_ssh_key_pair(self, eid):
|
||||
return "priv", "pub"
|
||||
def get_pgp_key(self, eid):
|
||||
return "priv", "pub", "fp"
|
||||
def get_nostr_key_pair(self, eid):
|
||||
return "npub", "nsec"
|
||||
def get_seed_phrase(self, eid, parent):
|
||||
return "word1 word2"
|
||||
def copy_to_clipboard(self, val):
|
||||
return True
|
||||
def archive_entry(self, eid):
|
||||
self.entries[eid]["archived"] = True
|
||||
def restore_entry(self, eid):
|
||||
self.entries[eid]["archived"] = False
|
||||
def load_managed_account(self, eid):
|
||||
pass
|
||||
def exit_managed_account(self):
|
||||
pass
|
||||
def add_entry(self, label, username=None, url=None, length=16, tags=None):
|
||||
eid = max(self.entries.keys()) + 1
|
||||
self.entries[eid] = {"id": eid, "label": label, "kind": "password", "username": username, "tags": tags}
|
||||
return eid
|
||||
def add_totp(self, label, secret, username=None, tags=None):
|
||||
eid = max(self.entries.keys()) + 1
|
||||
self.entries[eid] = {"id": eid, "label": label, "kind": "totp", "username": username, "tags": tags}
|
||||
return eid
|
||||
def add_seed(self, label, index=0, tags=None):
|
||||
eid = max(self.entries.keys()) + 1
|
||||
self.entries[eid] = {"id": eid, "label": label, "kind": "seed", "index": index, "tags": tags}
|
||||
return eid
|
||||
def add_managed_account(self, label, index=0, tags=None):
|
||||
eid = max(self.entries.keys()) + 1
|
||||
self.entries[eid] = {"id": eid, "label": label, "kind": "managed_account", "index": index, "tags": tags}
|
||||
return eid
|
||||
|
||||
class MockVaultService:
|
||||
def __init__(self):
|
||||
self._manager = MagicMock()
|
||||
self._manager.current_fingerprint = "parent-fp"
|
||||
self._manager.profile_stack = []
|
||||
def stats(self):
|
||||
return {"total_entries": len(self._manager.entries) if hasattr(self._manager, "entries") else 6}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_v3_reveal_copy_parity():
|
||||
app = SeedPassTuiV3(
|
||||
entry_service_factory=lambda: MockEntryService(),
|
||||
vault_service_factory=lambda: MockVaultService(),
|
||||
)
|
||||
async with app.run_test() as pilot:
|
||||
app.selected_entry_id = 1
|
||||
app.action_reveal_selected(confirm=True)
|
||||
payload = app._resolve_sensitive_payload()
|
||||
assert payload[3] == "gen-pwd"
|
||||
app.selected_entry_id = 4
|
||||
payload = app._resolve_sensitive_payload()
|
||||
assert payload[3] == "nsec"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_v3_archive_restore():
|
||||
app = SeedPassTuiV3(
|
||||
entry_service_factory=lambda: MockEntryService(),
|
||||
vault_service_factory=lambda: MockVaultService(),
|
||||
)
|
||||
async with app.run_test() as pilot:
|
||||
app.selected_entry_id = 1
|
||||
app.action_toggle_archive()
|
||||
assert app.services["entry"].entries[1].get("archived") is True
|
||||
app.action_toggle_archive()
|
||||
assert app.services["entry"].entries[1].get("archived") is False
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_v3_add_entry():
|
||||
app = SeedPassTuiV3(
|
||||
entry_service_factory=lambda: MockEntryService(),
|
||||
vault_service_factory=lambda: MockVaultService(),
|
||||
)
|
||||
async with app.run_test() as pilot:
|
||||
app.action_add_entry()
|
||||
await pilot.pause()
|
||||
screen = app.screen
|
||||
from textual.widgets import Input
|
||||
screen.query_one("#entry-label", Input).value = "New Password"
|
||||
screen.query_one("#entry-username", Input).value = "[email protected]"
|
||||
await pilot.press("ctrl+s")
|
||||
await pilot.pause()
|
||||
assert any(e["label"] == "New Password" for e in app.services["entry"].entries.values())
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_v3_filters():
|
||||
app = SeedPassTuiV3(
|
||||
entry_service_factory=lambda: MockEntryService(),
|
||||
vault_service_factory=lambda: MockVaultService(),
|
||||
)
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
table = app.screen.query_one("#entry-data-table", DataTable)
|
||||
initial_count = table.row_count
|
||||
app.action_set_kind_filter("2fa")
|
||||
await pilot.pause()
|
||||
assert table.row_count == 0 # No TOTP in mock
|
||||
app.action_set_kind_filter("keys")
|
||||
await pilot.pause()
|
||||
assert table.row_count > 0
|
||||
Reference in new issue
Block a user