@@ -470,6 +470,14 @@ const F_CITY: usize = 17;
470470const F_STATE : usize = 18 ;
471471const F_POSTAL : usize = 19 ;
472472const 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 ) ]
584594pub 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
607633impl 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+
628688impl 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 ( ) {
0 commit comments