mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-05 20:32:05 +00:00
feat(tui-v3): migrate reveal and QR logic with confirmation support
Implemented _resolve_sensitive_payload and _show_sensitive_view in v3 app. Added double-press confirmation logic for high-risk entry kinds. Updated BaseBoard and all specialized boards to reactively render revealed content or QR codes. Enabled sensitive actions within the MaximizedInspectorScreen. Unified board update logic via _update_board_sensitive.
This commit is contained in:
1 parent
0c59c20c64
commit
66a89de13f
3 files changed
+214
-13
No files matched your search
+119
-4
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)",
|
||||
|
||||
Reference in new issue
Block a user