From a3f9f9cecd30cd95cee1a0a04c992c179dea0948 Mon Sep 17 00:00:00 2001 From: UnbreakableMJ Date: Mon, 15 Jun 2026 23:40:16 +0300 Subject: [PATCH] feat(vault-tui): card/identity detail render (+ reveal/copy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete card/identity support in the TUI. Selecting a card/identity fetches its non-sensitive fields on-select via the existing Request::Get and renders them in the detail pane; the card number/CVV stay masked and the number is fetched only on reveal (Space) — like passwords, so no card secret enters the TUI until asked. - app.rs: DetailView + App.detail cache; primary_secret_field / primary_copy_field (type-aware Space/c). - main.rs: ensure_detail (fetch on select, cached per id) in the run loop; toggle_reveal + the `c` key now use the per-type primary field. - ui.rs: render_detail branches for card (brand/exp + masked number/CVV) and identity (name/email/phone/address). No proto/agent/core change (reuses the PR #23 Field selectors). Card/identity is now end-to-end (CLI + TUI). Tests: primary_*_field units; TestBackend renders for card (masked + revealed number) and identity contact fields. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 12 ++++ README.md | 8 ++- crates/vault-tui/src/app.rs | 55 ++++++++++++++++ crates/vault-tui/src/main.rs | 76 +++++++++++++++++++--- crates/vault-tui/src/ui.rs | 122 +++++++++++++++++++++++++++++++---- 5 files changed, 249 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33cfe42..78259eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,18 @@ range may break in any release. ### Added +- **TUI card/identity detail render.** Selecting a card or identity in + `vault-tui` now shows its fields in the detail pane: card brand/expiry with a + masked number (`Space` reveals it, re-masked on navigation) and masked CVV; + identity name/email/phone/address. `c` copies the primary field per type (card + number / identity email / login password); `Space` reveals the primary secret + (card number / login password). Non-sensitive fields are fetched on select via + the existing `Request::Get`; the card number/CVV are fetched **only on reveal** + — no card secret enters the TUI until asked. No proto/agent/core change + (reuses the PR #23 `Field` selectors). This completes card/identity support + end-to-end (CLI + TUI). Tests: `primary_secret_field`/`primary_copy_field` + units + `TestBackend` renders for card (masked number, revealed) and identity. + - **Card & identity cipher types (read, CLI).** `vault-core` now models card (type 3) and identity (type 4) ciphers — full field sets, decrypted into `PlainCard`/`PlainIdentity` (sensitive fields zeroized on drop) via new diff --git a/README.md b/README.md index 7922392..e4d155a 100644 --- a/README.md +++ b/README.md @@ -91,8 +91,12 @@ vault get visa --field card-number # also: card-brand, card-expiry, card-cod vault get me --field identity-email # also: identity-name, identity-phone, identity-address ``` -Rendering these in the TUI detail pane is coming next; for now the TUI shows the -item name/type and copies login fields. +In the TUI, selecting a card or identity shows its fields in the detail pane: +card brand/expiry with the number masked (`Space` reveals it, re-masked when you +navigate away) and the CVV masked; identity name/email/phone/address. `c` copies +the primary field — a card's number, an identity's email (and a login's +password). The number/CVV are fetched only on reveal, so no card secret enters +the TUI until you ask. ### PIN unlock diff --git a/crates/vault-tui/src/app.rs b/crates/vault-tui/src/app.rs index af474ee..ec3bdab 100644 --- a/crates/vault-tui/src/app.rs +++ b/crates/vault-tui/src/app.rs @@ -341,6 +341,40 @@ pub struct FolderItem { } /// A secret currently shown in the detail pane: which item and field it +/// Cached non-sensitive display fields for the selected card/identity item, +/// fetched on selection. Sensitive fields (card number/CVV) are *not* here — +/// they stay masked and are fetched only via reveal, like passwords. +#[derive(Clone, Debug, Default)] +pub struct DetailView { + /// Id of the item these fields belong to (cache key). + pub id: String, + /// Ordered `(label, value)` pairs to show in the detail pane. + pub lines: Vec<(String, String)>, +} + +/// The field `Space` reveals for a cipher type: a login's password or a card's +/// number. Identity and secure-note items have no masked secret (`None`). +#[must_use] +pub const fn primary_secret_field(cipher_type: u8) -> Option { + match cipher_type { + 1 => Some(Field::Password), + 3 => Some(Field::CardNumber), + _ => None, + } +} + +/// The field `c` copies for a cipher type, with its toast label: a login's +/// password, a card's number, or an identity's email. +#[must_use] +pub const fn primary_copy_field(cipher_type: u8) -> Option<(Field, &'static str)> { + match cipher_type { + 1 => Some((Field::Password, "password")), + 3 => Some((Field::CardNumber, "card number")), + 4 => Some((Field::IdentityEmail, "email")), + _ => None, + } +} + /// belongs to, plus the plaintext. The value is zeroised on drop and never /// surfaced by `Debug`, so an `App` dump can't leak it. #[derive(Clone)] @@ -776,6 +810,9 @@ pub struct App { pub confirm_delete: Option<(String, String)>, /// Secret currently revealed in the detail pane, if any. pub revealed: Option, + /// Cached non-sensitive fields for the selected card/identity, `Some` while + /// such an item is selected (populated on select; `None` for logins/notes). + pub detail: Option, /// When a pending OSC52 fallback copy should be cleared from the terminal /// clipboard. The TUI owns this timer (the agent can't — it has no /// terminal); the run loop races it against input. @@ -819,6 +856,7 @@ impl App { form: None, confirm_delete: None, revealed: None, + detail: None, osc52_clear_at: None, toast: None, unlock: None, @@ -855,6 +893,7 @@ impl App { form: None, confirm_delete: None, revealed: None, + detail: None, osc52_clear_at: None, toast: None, unlock: None, @@ -1940,6 +1979,22 @@ mod tests { assert!(app.command.is_empty()); } + #[test] + fn primary_fields_by_cipher_type() { + use super::{Field, primary_copy_field, primary_secret_field}; + assert_eq!(primary_secret_field(1), Some(Field::Password)); + assert_eq!(primary_secret_field(3), Some(Field::CardNumber)); + assert_eq!(primary_secret_field(4), None); // identity: no masked secret + assert_eq!(primary_secret_field(2), None); // secure note + assert_eq!(primary_copy_field(1), Some((Field::Password, "password"))); + assert_eq!( + primary_copy_field(3), + Some((Field::CardNumber, "card number")) + ); + assert_eq!(primary_copy_field(4), Some((Field::IdentityEmail, "email"))); + assert_eq!(primary_copy_field(2), None); + } + #[test] fn about_overlay_open_and_close() { let mut app = App::browsing(status(), vec![entry("a", None)]); diff --git a/crates/vault-tui/src/main.rs b/crates/vault-tui/src/main.rs index c3a5729..a180a34 100644 --- a/crates/vault-tui/src/main.rs +++ b/crates/vault-tui/src/main.rs @@ -141,6 +141,9 @@ async fn run(socket: &Path) -> anyhow::Result<()> { }); loop { + // Refresh the card/identity detail cache for the current selection + // before drawing (cheap no-op unless the selection changed). + ensure_detail(&mut state, socket).await; terminal.draw(|f| ui::render(f, &state))?; if state.should_quit { break; @@ -372,7 +375,16 @@ async fn handle_normal_key(state: &mut App, key: KeyEvent, socket: &Path) { } KeyCode::Char('r') => *state = load_app(socket).await, KeyCode::Char(' ') => toggle_reveal(state, socket).await, - KeyCode::Char('c') => copy_field(state, socket, Field::Password, "password").await, + // `c` copies the primary field for the selected item's type: login + // password / card number / identity email. + KeyCode::Char('c') => { + if let Some((field, label)) = state + .selected_entry() + .and_then(|e| app::primary_copy_field(e.cipher_type)) + { + copy_field(state, socket, field, label).await; + } + } KeyCode::Char('u') => copy_field(state, socket, Field::Username, "username").await, KeyCode::Char('o') => copy_field(state, socket, Field::Uri, "URI").await, KeyCode::Char('t') => copy_field(state, socket, Field::Totp, "TOTP code").await, @@ -663,22 +675,23 @@ async fn toggle_reveal(state: &mut App, socket: &Path) { let Some(sel) = state.selected_entry() else { return; }; - if state.is_revealed(&sel.id, Field::Password) { + // The masked secret depends on the cipher type: login password / card + // number. Items without one (identity, secure note) can't be revealed. + let Some(field) = app::primary_secret_field(sel.cipher_type) else { + return; + }; + if state.is_revealed(&sel.id, field) { state.hide_revealed(); return; } let req = Request::Get { id: Some(sel.id.clone()), name: sel.name.clone(), - field: Some(Field::Password), + field: Some(field), }; match client::request(socket, &req).await { Ok(Response::Item(item)) => { - state.reveal(RevealedSecret::new( - sel.id, - Field::Password, - item.value.clone(), - )); + state.reveal(RevealedSecret::new(sel.id, field, item.value.clone())); } Ok(Response::Error(e)) => state.set_toast(format!("reveal failed: {e}")), Ok(other) => state.set_toast(format!("unexpected response: {other:?}")), @@ -686,6 +699,53 @@ async fn toggle_reveal(state: &mut App, socket: &Path) { } } +/// Fetch one field's value for an item, or `None` on a missing field / error. +async fn fetch_field(socket: &Path, id: &str, name: &str, field: Field) -> Option { + let req = Request::Get { + id: Some(id.to_owned()), + name: name.to_owned(), + field: Some(field), + }; + match client::request(socket, &req).await { + Ok(Response::Item(item)) => Some(item.value.clone()), + _ => None, + } +} + +/// Keep `state.detail` populated with the selected card/identity's non-sensitive +/// fields. A no-op when the selection is unchanged, a login/note, or absent — +/// run once per loop iteration before drawing. Sensitive fields (card number / +/// CVV) are never fetched here; they reveal on demand like passwords. +async fn ensure_detail(state: &mut App, socket: &Path) { + let Some(sel) = state.selected_entry().filter(|_| state.items_focused()) else { + state.detail = None; + return; + }; + if state.detail.as_ref().is_some_and(|d| d.id == sel.id) { + return; // already cached for this item + } + let specs: &[(&str, Field)] = match sel.cipher_type { + 3 => &[("Brand", Field::CardBrand), ("Exp", Field::CardExpiry)], + 4 => &[ + ("Person", Field::IdentityName), + ("Email", Field::IdentityEmail), + ("Phone", Field::IdentityPhone), + ("Address", Field::IdentityAddress), + ], + _ => { + state.detail = None; + return; + } + }; + let mut lines = Vec::new(); + for (label, field) in specs { + if let Some(value) = fetch_field(socket, &sel.id, &sel.name, *field).await { + lines.push(((*label).to_owned(), value)); + } + } + state.detail = Some(app::DetailView { id: sel.id, lines }); +} + /// Ask the agent to copy `field` of the selected item to the clipboard, with a /// timed auto-clear (the agent's configured default). The secret stays in the /// agent and never enters this process — except on the OSC52 fallback, where diff --git a/crates/vault-tui/src/ui.rs b/crates/vault-tui/src/ui.rs index 5344421..bb9419c 100644 --- a/crates/vault-tui/src/ui.rs +++ b/crates/vault-tui/src/ui.rs @@ -232,30 +232,62 @@ fn render_detail(frame: &mut Frame, app: &App, area: Rect) { ))] }, |e| { + let steel = hex(steelbore::STEEL_BLUE); let folder = e.folder.clone().unwrap_or_else(|| "(unfiled)".to_owned()); - let username = e.username.clone().unwrap_or_else(|| "—".to_owned()); let mut lines = vec![ field_line("Name", &e.name, amber), field_line("Type", type_label(e.cipher_type), info), - field_line("User", &username, info), - field_line("Folder", &folder, info), - field_line("Id", &e.id, info), ]; - // Logins carry a password; show it masked, revealed on demand. - if e.cipher_type == 1 { - if app.is_revealed(&e.id, Field::Password) { + // A masked field shown in amber when revealed, steel when hidden. + let masked = |lines: &mut Vec, label: &str, field: Field| { + if app.is_revealed(&e.id, field) { let value = app.revealed.as_ref().map_or(MASK, |r| r.value()); - lines.push(field_line("Pass", value, amber)); + lines.push(field_line(label, value, amber)); } else { - lines.push(field_line("Pass", MASK, hex(steelbore::STEEL_BLUE))); + lines.push(field_line(label, MASK, steel)); } - } + }; + // Non-sensitive fields fetched for the selected card/identity. + let extra = app.detail.as_ref().filter(|d| d.id == e.id); + let hint = match e.cipher_type { + 1 => { + let username = e.username.clone().unwrap_or_else(|| "—".to_owned()); + lines.push(field_line("User", &username, info)); + lines.push(field_line("Folder", &folder, info)); + lines.push(field_line("Id", &e.id, info)); + masked(&mut lines, "Pass", Field::Password); + "Space reveal · c/u/o copy" + } + 3 => { + if let Some(d) = extra { + for (label, value) in &d.lines { + lines.push(field_line(label, value, info)); + } + } + masked(&mut lines, "Number", Field::CardNumber); + lines.push(field_line("CVV", MASK, steel)); + lines.push(field_line("Folder", &folder, info)); + "Space reveal number · c copy number" + } + 4 => { + if let Some(d) = extra { + for (label, value) in &d.lines { + lines.push(field_line(label, value, info)); + } + } + lines.push(field_line("Folder", &folder, info)); + "c copy email" + } + _ => { + lines.push(field_line("Folder", &folder, info)); + lines.push(field_line("Id", &e.id, info)); + "—" + } + }; lines.push(Line::from("")); lines.push(Line::from(Span::styled( - "Space reveal · c/u/o copy", - Style::default() - .fg(hex(steelbore::STEEL_BLUE)) - .add_modifier(Modifier::ITALIC), + hint, + Style::default().fg(steel).add_modifier(Modifier::ITALIC), ))); lines }, @@ -849,4 +881,66 @@ mod tests { "URL missing:\n{text}" ); } + + #[test] + fn card_detail_shows_fields_and_masks_number() { + use crate::app::DetailView; + use vault_ipc::proto::Field; + let card = ListEntry { + id: "card1".into(), + name: "Visa".into(), + cipher_type: 3, + username: None, + folder: Some("Wallet".into()), + }; + let mut app = App::browsing(status(), vec![card]); + app.detail = Some(DetailView { + id: "card1".into(), + lines: vec![ + ("Brand".into(), "Visa".into()), + ("Exp".into(), "04/2030".into()), + ], + }); + let text = draw(&app); + assert!(text.contains("Brand"), "brand label missing:\n{text}"); + assert!(text.contains("04/2030"), "expiry missing:\n{text}"); + assert!(text.contains("Number"), "number label missing:\n{text}"); + assert!(text.contains("CVV"), "cvv label missing:\n{text}"); + assert!(text.contains(MASK), "number must be masked:\n{text}"); + assert!( + !text.contains("4111"), + "raw card number must not appear unless revealed:\n{text}" + ); + + // After revealing the card number, it shows. + app.reveal(RevealedSecret::new( + "card1".into(), + Field::CardNumber, + "4111111111111111".into(), + )); + assert!(draw(&app).contains("4111111111111111"), "revealed number"); + } + + #[test] + fn identity_detail_shows_contact_fields() { + use crate::app::DetailView; + let id = ListEntry { + id: "id1".into(), + name: "Me".into(), + cipher_type: 4, + username: None, + folder: None, + }; + let mut app = App::browsing(status(), vec![id]); + app.detail = Some(DetailView { + id: "id1".into(), + lines: vec![ + ("Email".into(), "alice@example.org".into()), + ("Phone".into(), "+1 555 0100".into()), + ], + }); + let text = draw(&app); + assert!(text.contains("alice@example.org"), "email missing:\n{text}"); + assert!(text.contains("+1 555 0100"), "phone missing:\n{text}"); + } }