Skip to content

Commit f5c6a1f

Browse files
Merge pull request #34 from Spacecraft-Software/tui-form-scroll
feat(vault-tui): scrollable add/edit form + full identity field set
2 parents 10bd3e7 + 0cd504e commit f5c6a1f

5 files changed

Lines changed: 180 additions & 39 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ range may break in any release.
1010

1111
### Added
1212

13+
- **TUI form scrolling + full identity editing.** The add/edit form overlay now
14+
**scrolls** (a viewport that keeps the focused row visible; the keybind footer
15+
stays fixed, with a `` cue when there's more). This retires the curated
16+
10-field identity limit: the TUI identity form now edits the **full 18-field
17+
set**, including the SSN/passport/license secrets (masked while unfocused, like
18+
the card number/CVV; redacted in any `Debug`). Pure `vault-tui` — the
19+
`IdentityWrite` backend already carried all the fields.
20+
1321
- **`card-cardholder` field selector.** `vault get <card> --field card-cardholder`
1422
now returns the cardholder name, and the TUI detail pane shows a `Holder` line
1523
— which also lets the TUI card **edit** form prefill the cardholder (it

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,10 @@ The TUI can also create and edit cards and identities: press `a` and cycle the
143143
**Type** row with `Space` (`login → secure note → card → identity`), or `e` on a
144144
selected item. The card's number and CVV mask while unfocused; on edit they start
145145
blank (blank = leave unchanged), and the brand/expiry prefill from the detail
146-
pane. The identity form edits a curated subset (title, first/last name, email,
147-
phone, address, city, state, postal, country); the long-tail fields and the
148-
SSN/passport/license secrets remain CLI-only.
146+
pane. The identity form edits the **full field set** — including the
147+
SSN/passport/license secrets (masked, like the card number) — and the form
148+
**scrolls** when the field list is taller than the overlay (the keybind footer
149+
stays put).
149150

150151
### PIN unlock
151152

crates/vault-tui/src/app.rs

Lines changed: 132 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,14 @@ const F_CITY: usize = 17;
470470
const F_STATE: usize = 18;
471471
const F_POSTAL: usize = 19;
472472
const F_COUNTRY: usize = 20;
473+
const F_MIDDLE: usize = 21;
474+
const F_IDUSER: usize = 22;
475+
const F_COMPANY: usize = 23;
476+
const F_SSN: usize = 24;
477+
const F_PASSPORT: usize = 25;
478+
const F_LICENSE: usize = 26;
479+
const F_ADDR2: usize = 27;
480+
const F_ADDR3: usize = 28;
473481

474482
/// Which mutation the form drives.
475483
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -579,21 +587,33 @@ pub struct FormSubmit {
579587
pub identity: IdentityFields,
580588
}
581589

582-
/// The curated identity fields the TUI form edits (all non-secret).
583-
#[derive(Clone, Debug, Default)]
590+
/// The identity fields the TUI form edits (the full set). `ssn`,
591+
/// `passport_number`, and `license_number` are sensitive — masked in the form
592+
/// and redacted in `Debug`.
593+
#[derive(Clone, Default)]
584594
pub struct IdentityFields {
585595
/// Title (`Mr`, `Ms`, …).
586596
pub title: Option<String>,
587597
/// First name.
588598
pub first_name: Option<String>,
599+
/// Middle name.
600+
pub middle_name: Option<String>,
589601
/// Last name.
590602
pub last_name: Option<String>,
603+
/// Identity username.
604+
pub username: Option<String>,
605+
/// Company.
606+
pub company: Option<String>,
591607
/// Email.
592608
pub email: Option<String>,
593609
/// Phone.
594610
pub phone: Option<String>,
595611
/// Address line 1.
596612
pub address1: Option<String>,
613+
/// Address line 2.
614+
pub address2: Option<String>,
615+
/// Address line 3.
616+
pub address3: Option<String>,
597617
/// City.
598618
pub city: Option<String>,
599619
/// State / province.
@@ -602,29 +622,69 @@ pub struct IdentityFields {
602622
pub postal_code: Option<String>,
603623
/// Country.
604624
pub country: Option<String>,
625+
/// SSN / national id (sensitive).
626+
pub ssn: Option<String>,
627+
/// Passport number (sensitive).
628+
pub passport_number: Option<String>,
629+
/// License number (sensitive).
630+
pub license_number: Option<String>,
605631
}
606632

607633
impl IdentityFields {
608-
/// Whether any curated identity field is set (drives the edit "no changes"
609-
/// gate without listing each field there).
634+
/// Whether any identity field is set (drives the edit "no changes" gate
635+
/// without listing each field there).
610636
fn any_set(&self) -> bool {
611637
[
612638
&self.title,
613639
&self.first_name,
640+
&self.middle_name,
614641
&self.last_name,
642+
&self.username,
643+
&self.company,
615644
&self.email,
616645
&self.phone,
617646
&self.address1,
647+
&self.address2,
648+
&self.address3,
618649
&self.city,
619650
&self.state,
620651
&self.postal_code,
621652
&self.country,
653+
&self.ssn,
654+
&self.passport_number,
655+
&self.license_number,
622656
]
623657
.iter()
624658
.any(|o| o.is_some())
625659
}
626660
}
627661

662+
impl fmt::Debug for IdentityFields {
663+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
664+
let redact = |o: &Option<String>| o.as_ref().map(|_| "<redacted>");
665+
f.debug_struct("IdentityFields")
666+
.field("title", &self.title)
667+
.field("first_name", &self.first_name)
668+
.field("middle_name", &self.middle_name)
669+
.field("last_name", &self.last_name)
670+
.field("username", &self.username)
671+
.field("company", &self.company)
672+
.field("email", &self.email)
673+
.field("phone", &self.phone)
674+
.field("address1", &self.address1)
675+
.field("address2", &self.address2)
676+
.field("address3", &self.address3)
677+
.field("city", &self.city)
678+
.field("state", &self.state)
679+
.field("postal_code", &self.postal_code)
680+
.field("country", &self.country)
681+
.field("ssn", &redact(&self.ssn))
682+
.field("passport_number", &redact(&self.passport_number))
683+
.field("license_number", &redact(&self.license_number))
684+
.finish()
685+
}
686+
}
687+
628688
impl fmt::Debug for FormSubmit {
629689
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
630690
let redact = |o: &Option<String>| o.as_ref().map(|_| "<redacted>");
@@ -656,15 +716,20 @@ impl FormState {
656716
[
657717
"Name", "User", "Pass", "URI", "Folder", "Notes", // shared / login
658718
"Holder", "Brand", "Number", "Expiry", "CVV", // card
659-
"Title", "First", "Last", "Email", "Phone", "Address", "City", "State", "Postal",
660-
"Country", // identity (curated)
719+
"Title", "First", "Last", "Email", "Phone", "Addr1", "City", "State", "Postal",
720+
"Country", // identity
721+
"Middle", "IdUser", "Company", "SSN", "Passport", "License", "Addr2",
722+
"Addr3", // identity (long-tail + secrets)
661723
]
662724
.into_iter()
663725
.map(|label| FormField {
664726
label,
665727
value: TextInput::default(),
666728
initial: String::new(),
667-
secret: matches!(label, "Pass" | "Number" | "CVV"),
729+
secret: matches!(
730+
label,
731+
"Pass" | "Number" | "CVV" | "SSN" | "Passport" | "License"
732+
),
668733
})
669734
.collect()
670735
}
@@ -750,12 +815,12 @@ impl FormState {
750815
F_FOLDER,
751816
F_NOTES,
752817
],
753-
// Identity exposes a curated subset (the long-tail fields + the
754-
// SSN/passport/license secrets are CLI-only — they'd overflow the
755-
// non-scrolling overlay).
818+
// Identity exposes its full field set (the form scrolls). SSN /
819+
// passport / license are secret rows (masked while unfocused).
756820
4 => vec![
757-
F_NAME, F_TITLE, F_FIRST, F_LAST, F_EMAIL, F_PHONE, F_ADDRESS, F_CITY, F_STATE,
758-
F_POSTAL, F_COUNTRY, F_FOLDER, F_NOTES,
821+
F_NAME, F_TITLE, F_FIRST, F_MIDDLE, F_LAST, F_IDUSER, F_COMPANY, F_EMAIL, F_PHONE,
822+
F_ADDRESS, F_ADDR2, F_ADDR3, F_CITY, F_STATE, F_POSTAL, F_COUNTRY, F_SSN,
823+
F_PASSPORT, F_LICENSE, F_FOLDER, F_NOTES,
759824
],
760825
// Secure notes (and anything else) edit only the metadata fields
761826
// every cipher type carries.
@@ -892,14 +957,22 @@ impl FormState {
892957
let identity = IdentityFields {
893958
title: take(F_TITLE),
894959
first_name: take(F_FIRST),
960+
middle_name: take(F_MIDDLE),
895961
last_name: take(F_LAST),
962+
username: take(F_IDUSER),
963+
company: take(F_COMPANY),
896964
email: take(F_EMAIL),
897965
phone: take(F_PHONE),
898966
address1: take(F_ADDRESS),
967+
address2: take(F_ADDR2),
968+
address3: take(F_ADDR3),
899969
city: take(F_CITY),
900970
state: take(F_STATE),
901971
postal_code: take(F_POSTAL),
902972
country: take(F_COUNTRY),
973+
ssn: take(F_SSN),
974+
passport_number: take(F_PASSPORT),
975+
license_number: take(F_LICENSE),
903976
};
904977
match self.kind {
905978
FormKind::Add => {
@@ -969,6 +1042,15 @@ fn parse_expiry(raw: &str) -> Result<(String, String), ()> {
9691042
Ok((month.to_string(), year))
9701043
}
9711044

1045+
/// Vertical scroll offset (in rows) that keeps the `focused` row visible in a
1046+
/// viewport `height` rows tall: 0 until focus passes the bottom, then just
1047+
/// enough to pin it to the last visible line. Pure, so the renderer stays
1048+
/// stateless. `height == 0` falls back to no scroll.
1049+
#[must_use]
1050+
pub const fn scroll_offset(focused: usize, height: usize) -> usize {
1051+
focused.saturating_sub(height.saturating_sub(1))
1052+
}
1053+
9721054
/// Top-level TUI state.
9731055
#[derive(Clone, Debug)]
9741056
#[expect(
@@ -2581,6 +2663,19 @@ mod tests {
25812663
assert!(parse_expiry("ab/2030").is_err(), "non-numeric month");
25822664
}
25832665

2666+
#[test]
2667+
fn scroll_offset_keeps_focus_visible() {
2668+
// Within the viewport: no scroll.
2669+
assert_eq!(scroll_offset(0, 10), 0);
2670+
assert_eq!(scroll_offset(9, 10), 0, "last visible row, still no scroll");
2671+
// Past the bottom: scroll just enough to pin focus to the last line.
2672+
assert_eq!(scroll_offset(10, 10), 1);
2673+
assert_eq!(scroll_offset(21, 10), 12);
2674+
// Degenerate viewport heights don't panic.
2675+
assert_eq!(scroll_offset(5, 0), 5);
2676+
assert_eq!(scroll_offset(5, 1), 5);
2677+
}
2678+
25842679
/// A type-4 list entry for the identity form tests.
25852680
fn identity_entry() -> ListEntry {
25862681
ListEntry {
@@ -2593,7 +2688,7 @@ mod tests {
25932688
}
25942689

25952690
#[test]
2596-
fn add_identity_form_carries_curated_fields() {
2691+
fn add_identity_form_carries_full_field_set_and_redacts_secrets() {
25972692
let mut app = App::browsing(status(), vec![entry("a", None)]);
25982693
app.open_add_form();
25992694
app.form_toggle_type(); // login → secure note
@@ -2605,32 +2700,44 @@ mod tests {
26052700
assert_eq!(
26062701
labels,
26072702
[
2608-
"Type", "Name", "Title", "First", "Last", "Email", "Phone", "Address", "City",
2609-
"State", "Postal", "Country", "Folder", "Notes"
2703+
"Type", "Name", "Title", "First", "Middle", "Last", "IdUser", "Company", "Email",
2704+
"Phone", "Addr1", "Addr2", "Addr3", "City", "State", "Postal", "Country", "SSN",
2705+
"Passport", "License", "Folder", "Notes"
26102706
]
26112707
);
26122708
let type_into = |app: &mut App, s: &str| {
26132709
for c in s.chars() {
26142710
app.form_push(c);
26152711
}
26162712
};
2617-
app.form_focus_next(); // → Name
2713+
// Fill a spread of rows by walking to each (Type row is index 0).
2714+
app.form_focus_next(); // 1 Name
26182715
type_into(&mut app, "Jane Doe");
2619-
app.form_focus_next(); // → Title (blank)
2620-
app.form_focus_next(); // → First
2716+
for _ in 1..3 {
2717+
app.form_focus_next(); // → 3 First
2718+
}
26212719
type_into(&mut app, "Jane");
2622-
app.form_focus_next(); // → Last
2623-
type_into(&mut app, "Doe");
2624-
app.form_focus_next(); // → Email
2720+
for _ in 3..8 {
2721+
app.form_focus_next(); // → 8 Email
2722+
}
26252723
type_into(&mut app, "jane@example.org");
2724+
for _ in 8..17 {
2725+
app.form_focus_next(); // → 17 SSN (a secret row)
2726+
}
2727+
type_into(&mut app, "123-45-6789");
26262728

26272729
let data = app.form_submit_data().expect("valid identity add");
26282730
assert_eq!(data.cipher_type, 4);
26292731
assert_eq!(data.name.as_deref(), Some("Jane Doe"));
26302732
assert_eq!(data.identity.first_name.as_deref(), Some("Jane"));
2631-
assert_eq!(data.identity.last_name.as_deref(), Some("Doe"));
26322733
assert_eq!(data.identity.email.as_deref(), Some("jane@example.org"));
2734+
assert_eq!(data.identity.ssn.as_deref(), Some("123-45-6789"));
26332735
assert_eq!(data.identity.title, None, "blank field rides as unset");
2736+
2737+
// The SSN must never appear in a Debug rendering.
2738+
let rendered = format!("{data:?}");
2739+
assert!(rendered.contains("<redacted>"));
2740+
assert!(!rendered.contains("123-45"), "ssn leaked: {rendered}");
26342741
}
26352742

26362743
#[test]
@@ -2660,9 +2767,9 @@ mod tests {
26602767
assert_eq!(value_of("Phone"), "+1 555 0100");
26612768
assert_eq!(value_of("First"), "", "composite name not prefilled");
26622769

2663-
// Edit rows: Name Title First Last Email Phone Address City State ...
2664-
// Walk to City (index 7) and type a value.
2665-
for _ in 0..7 {
2770+
// Edit rows (no Type row): Name Title First Middle Last IdUser Company
2771+
// Email Phone Addr1 Addr2 Addr3 City … — City is index 12.
2772+
for _ in 0..12 {
26662773
app.form_focus_next();
26672774
}
26682775
for c in "Amber".chars() {

crates/vault-tui/src/main.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -489,15 +489,22 @@ async fn submit_form(state: &mut App, socket: &Path) {
489489
let identity = (cipher_type == 4).then(|| IdentityWrite {
490490
title: id_fields.title,
491491
first_name: id_fields.first_name,
492+
middle_name: id_fields.middle_name,
492493
last_name: id_fields.last_name,
494+
username: id_fields.username,
495+
company: id_fields.company,
493496
email: id_fields.email,
494497
phone: id_fields.phone,
495498
address1: id_fields.address1,
499+
address2: id_fields.address2,
500+
address3: id_fields.address3,
496501
city: id_fields.city,
497502
state: id_fields.state,
498503
postal_code: id_fields.postal_code,
499504
country: id_fields.country,
500-
..IdentityWrite::default()
505+
ssn: id_fields.ssn.map(String::into_bytes),
506+
passport_number: id_fields.passport_number.map(String::into_bytes),
507+
license_number: id_fields.license_number.map(String::into_bytes),
501508
});
502509
let req = match kind {
503510
FormKind::Add => Request::Add {

0 commit comments

Comments
 (0)