diff --git a/src/seedpass/tui_v3/app.py b/src/seedpass/tui_v3/app.py index 9c51d8b1..3cbe107f 100644 --- a/src/seedpass/tui_v3/app.py +++ b/src/seedpass/tui_v3/app.py @@ -136,6 +136,9 @@ class SeedPassTuiV3(App[None]): selected_entry_id = reactive[int | None](None) session_locked = reactive(True) + # Internal state for sensitive actions + _pending_sensitive_confirm: tuple[str, int, float] | None = None + def __init__( self, fingerprint: str | None = None, @@ -245,14 +248,126 @@ class SeedPassTuiV3(App[None]): return self.push_screen(MaximizedInspectorScreen()) - def action_reveal_selected(self) -> None: - self.notify("Reveal Coming Soon in V3") + def action_reveal_selected(self, confirm: bool = False) -> None: + """Handle reveal shortcut (v).""" + if self.session_locked: + self.notify("Vault is locked", severity="error") + return + if self.selected_entry_id is None: + return + + # Check confirmation + if not confirm: + confirm = self._consume_confirm("reveal_selected", self.selected_entry_id) + + self._show_sensitive_view(include_qr=False, confirm=confirm) - def action_show_qr(self) -> None: - self.notify("QR Coming Soon in V3") + def action_show_qr(self, mode: str = "default", confirm: bool = False) -> None: + """Handle QR shortcut (g).""" + if self.session_locked: + self.notify("Vault is locked", severity="error") + return + if self.selected_entry_id is None: + return + + if not confirm: + confirm = self._consume_confirm("show_qr", self.selected_entry_id) + + self._show_sensitive_view(include_qr=True, qr_mode=mode, confirm=confirm) def action_toggle_archive(self) -> None: self.notify("Archive toggle Coming Soon in V3") def action_copy_selected(self) -> None: self.notify("Copy Coming Soon in V3") + + 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 + now = time.time() + if p_action == action and p_eid == eid and (now - p_ts) <= 8.0: + self._pending_sensitive_confirm = None + return True + self._pending_sensitive_confirm = None + return False + + def _show_sensitive_view(self, include_qr: bool, qr_mode: str = "default", confirm: bool = False) -> None: + try: + payload = self._resolve_sensitive_payload(qr_mode=qr_mode) + title, body, qr_data, secret, kind = payload + except Exception as e: + self.notify(f"Reveal failed: {e}", severity="error") + return + + # Check if confirmation is required + requires = False + if include_qr: + requires = (kind == "nostr" and qr_mode == "private") + else: + requires = kind in {"seed", "managed_account", "ssh", "pgp"} + + if requires and not confirm: + key = "g" if include_qr else "v" + self._pending_sensitive_confirm = ("show_qr" if include_qr else "reveal_selected", self.selected_entry_id, time.time()) + # Update the board with confirmation prompt + self._update_board_sensitive(prompt=f"CONFIRMATION REQUIRED\n\nHigh-risk action for '{kind}'.\nPress '{key}' again within 8s to proceed.") + self.notify(f"Press '{key}' again to confirm") + return + + # Success - Update Board + if include_qr: + try: + qr_rendered = render_qr_ascii(qr_data) + self._update_board_sensitive(content=qr_rendered, title=title) + except Exception as e: + self.notify(f"QR Render failed: {e}", severity="error") + else: + self._update_board_sensitive(content=body, title=title) + + def _resolve_sensitive_payload(self, qr_mode="default"): + if "entry" not in self.services: raise ValueError("Service offline") + eid = self.selected_entry_id + entry = self.services["entry"].retrieve_entry(eid) + if not entry: raise ValueError("Entry not found") + + kind = str(entry.get("kind") or entry.get("type") or "").lower() + label = entry.get("label", "") + + 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) + 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) + if kind in {"seed", "managed_account"}: + parent_seed = self.services["vault"]._manager.parent_seed + if kind == "seed": + phrase = self.services["entry"].get_seed_phrase(eid, parent_seed) + 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) + + # Fallback + return ("Data Revealed", f"Label: {label}\nDetails: {entry}", None, None, kind) + + def _update_board_sensitive(self, content: str = None, title: str = None, prompt: str = None): + """Push sensitive data to the currently active board or screen.""" + data = {"content": content, "title": title, "prompt": prompt} + + # 1. Update full-screen inspector if active + if isinstance(self.screen, MaximizedInspectorScreen): + self.screen.reveal_data = data + return + + # 2. Update standard inspector board + try: + board_cont = self.query_one("#board-container") + if board_cont.children: + board = board_cont.children[0] + if hasattr(board, "reveal_data"): + board.reveal_data = data + except Exception: + pass diff --git a/src/seedpass/tui_v3/screens/inspector.py b/src/seedpass/tui_v3/screens/inspector.py index 30597fe2..bc6c2b84 100644 --- a/src/seedpass/tui_v3/screens/inspector.py +++ b/src/seedpass/tui_v3/screens/inspector.py @@ -4,12 +4,17 @@ from textual.app import ComposeResult from textual.screen import Screen from textual.widgets import Static, Footer from textual.containers import Vertical, Horizontal +from textual.reactive import reactive class MaximizedInspectorScreen(Screen): """ Full-screen detailed view for a single entry. Optimized for long documents, SSH keys, or PGP blocks. """ + reveal_data = reactive[dict[str, Any]]({}) + + def watch_reveal_data(self, old, new): + self._refresh_detail() BINDINGS = [ ("escape", "app.pop_screen", "Back to Vault"), @@ -61,10 +66,20 @@ class MaximizedInspectorScreen(Screen): def _refresh_detail(self) -> None: app = self.app eid = app.selected_entry_id + r = self.reveal_data + if eid is None: self.query_one("#max-inspector-content", Static).update("No entry selected.") return + if r.get("prompt"): + self.query_one("#max-inspector-content", Static).update(f"[b]SECURITY ALERT[/b]\n\n{r['prompt']}") + return + + if r.get("content") and "##" in r["content"]: + self.query_one("#max-inspector-content", Static).update(f"[b]SECURE QR DISPLAY[/b]\n\n{r['content']}") + return + if "entry" not in app.services: return @@ -88,15 +103,18 @@ class MaximizedInspectorScreen(Screen): f"Modified : {entry.get('modified_at', '(unknown)')}", ] - # Content (if note/doc) + # Handle revealed content content = entry.get('content', '') - content_block = f"\n[b]CONTENT / NOTES:[/b]\n{divider}{content}\n" if content else "" + if r.get("content"): + content = f"[b][cyan]{r['content']}[/b]" + + content_block = f"\n[b]CONTENT / SECURE DATA:[/b]\n{divider}{content}\n" if content else "" final_text = header + divider + "\n".join(meta_lines) + content_block self.query_one("#max-inspector-content", Static).update(final_text) def action_reveal(self) -> None: - self.app.notify("Reveal triggered (Coming Soon in v3)") + self.app.action_reveal_selected() def action_qr(self) -> None: - self.app.notify("QR triggered (Coming Soon in v3)") + self.app.action_show_qr() diff --git a/src/seedpass/tui_v3/widgets/inspector.py b/src/seedpass/tui_v3/widgets/inspector.py index c3e2c789..d9777434 100644 --- a/src/seedpass/tui_v3/widgets/inspector.py +++ b/src/seedpass/tui_v3/widgets/inspector.py @@ -8,6 +8,10 @@ from textual.reactive import reactive class BaseBoard(Static): """Base class for all specialized entry boards.""" entry_data = reactive[dict[str, Any]]({}) + reveal_data = reactive[dict[str, Any]]({}) + + def watch_reveal_data(self, old, new): + self.refresh() def _render_card(self, title: str, rows: list[str]) -> str: """Utility to render ASCII cards matching mockup style.""" @@ -35,11 +39,22 @@ class PasswordBoard(BaseBoard): """Matches 'Password Board.png' mockup.""" def render(self) -> str: d = self.entry_data + r = self.reveal_data + + # If we have a direct prompt (confirmation required), show ONLY that + if r.get("prompt"): + return self._render_card("SECURITY ALERT", r["prompt"].splitlines()) + + # If we have revealed content, show it + password_val = "[HIDDEN] (v to reveal)" + if r.get("content"): + password_val = f"[b]{r['content']}[/b]" + cred_rows = [ f"Label : {d.get('label', '')}", f"Username : {d.get('username', '')}", f"URL : {d.get('url', '')}", - "Password : [HIDDEN]", + f"Password : {password_val}", ] action_rows = [ "▣ Reveal (v) ▣ QR (g)", @@ -56,7 +71,15 @@ class TotpBoard(BaseBoard): """Matches '2FA Board.png' mockup.""" def render(self) -> str: d = self.entry_data + r = self.reveal_data app = self.app + + if r.get("prompt"): + return self._render_card("SECURITY ALERT", r["prompt"].splitlines()) + + if r.get("content") and "##" in r["content"]: + return self._render_card("TOTP QR CODE", r["content"].splitlines()) + code = "------" if app.services.get("entry") and not app.session_locked: try: @@ -70,10 +93,13 @@ class TotpBoard(BaseBoard): filled = int((remaining / 30) * bar_width) bar = "█" * filled + "░" * (bar_width - filled) + secret_val = r.get("content") if r.get("content") else "[HIDDEN] (v to reveal)" + cred_rows = [ f"Label : {d.get('label', '')}", f"Code : [b][cyan]{code}[/b] ({remaining}s)", f"Timer : {bar}", + f"Secret : {secret_val}", f"Period : {d.get('period', 30)}s", f"Digits : {d.get('digits', 6)}", ] @@ -92,12 +118,24 @@ class SeedBoard(BaseBoard): """Matches 'BIP-39 Seed Board.png' mockup.""" def render(self) -> str: d = self.entry_data + r = self.reveal_data + + if r.get("prompt"): + return self._render_card("SECURITY ALERT", r["prompt"].splitlines()) + + if r.get("content") and "##" in r["content"]: + return self._render_card("SEED QR CODE", r["content"].splitlines()) + + phrase_val = "[HIDDEN] (vv to reveal)" + if r.get("content"): + phrase_val = f"[b]{r['content']}[/b]" + cred_rows = [ f"Label : {d.get('label', '')}", f"Fingerprint: {d.get('fingerprint', 'Pending...')}", f"Index : {d.get('index', 0)}", f"Word Count : {d.get('word_count', 24)}", - "Seed Phrase: [HIDDEN] (vv to reveal)", + f"Seed Phrase: {phrase_val}", ] action_rows = [ "▣ Reveal Words (vv) ▣ Show SeedQR (gg)", @@ -114,12 +152,21 @@ class SshBoard(BaseBoard): """Matches 'SSH Board.png' mockup.""" def render(self) -> str: d = self.entry_data + r = self.reveal_data + + if r.get("prompt"): + return self._render_card("SECURITY ALERT", r["prompt"].splitlines()) + + priv_val = "[HIDDEN] (vv to reveal)" + if r.get("content"): + priv_val = f"[b]{r['content']}[/b]" + cred_rows = [ f"Label : {d.get('label', '')}", f"Key Type : {d.get('key_type', 'RSA/Ed25519')}", f"Index : {d.get('index', 0)}", "Pub Key : [PREVIEW AVAILABLE]", - "Priv Key : [HIDDEN] (vv to reveal)", + f"Priv Key : {priv_val}", ] action_rows = [ "▣ Copy Pub (c) ▣ Reveal Priv (vv)", @@ -136,11 +183,20 @@ class PgpBoard(BaseBoard): """Matches 'PGP Board.png' mockup.""" def render(self) -> str: d = self.entry_data + r = self.reveal_data + + if r.get("prompt"): + return self._render_card("SECURITY ALERT", r["prompt"].splitlines()) + + priv_val = "[HIDDEN] (vv to reveal)" + if r.get("content"): + priv_val = f"[b]{r['content']}[/b]" + cred_rows = [ f"Label : {d.get('label', '')}", f"Identity : {d.get('user_id', '')}", f"Fingerpr : {d.get('fingerprint', '')}", - "Priv Key : [HIDDEN] (vv to reveal)", + f"Priv Key : {priv_val}", ] action_rows = [ "▣ Copy Pub (c) ▣ Reveal Priv (vv)", @@ -157,10 +213,22 @@ class NostrBoard(BaseBoard): """Matches 'Nostr Board.png' mockup.""" def render(self) -> str: d = self.entry_data + r = self.reveal_data + + if r.get("prompt"): + return self._render_card("SECURITY ALERT", r["prompt"].splitlines()) + + nsec_val = "[HIDDEN] (v to reveal)" + if r.get("content") and "##" not in r["content"]: + nsec_val = f"[b]{r['content']}[/b]" + + if r.get("content") and "##" in r["content"]: + return self._render_card("NOSTR QR CODE", r["content"].splitlines()) + cred_rows = [ f"Label : {d.get('label', '')}", f"npub : {d.get('npub', '')[:20]}...", - "nsec : [HIDDEN] (v to reveal)", + f"nsec : {nsec_val}", ] action_rows = [ "▣ Reveal (v) ▣ QR (g)",