Viewer: click a vault TIN to copy the full number
check / check (push) Canceled after 0s

The party page's "TIN on file" and the W-9 card's TIN copy the full,
formatted number on click (right-click: digits only). The viewer reads
it from the vault only at that moment, with the server's key file in
force, never creates a key, refuses a key inside the books dir, and
never draws more than the last four. A locked vault copies nothing and
says why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-16 08:27:13 -06:00
1 parent 9927e56431
commit a44a8873df
7 files changed
+377 -10

No files matched your search

+78 -1
View File
@@ -15,7 +15,7 @@ use ledger_store::{Attachment, JournalLine, Reconciliation, Snapshot, Statement,
use rust_decimal::Decimal;
use serde_json::Value;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use crate::nav::Route;
@@ -1470,6 +1470,58 @@ impl Books {
)
}
/// The books dir: the folder holding `books-data/` (and `vault/`).
fn books_dir(&self) -> PathBuf {
let d = self
.data_dir
.canonicalize()
.unwrap_or_else(|_| self.data_dir.clone());
d.parent().map(Path::to_path_buf).unwrap_or(d)
}
/// A party's full TIN from the encrypted vault, formatted
/// ("123-45-6789" for an SSN or ITIN, "12-3456789" for an EIN), for the
/// clipboard only: never drawn on screen.
///
/// ```text
/// INVARIANT 1: read only on demand (a click), never on load or paint.
/// INVARIANT 2: never creates a key file; a missing key is a locked
/// vault ("restore it from your password manager").
/// INVARIANT 3: the key file in force is the server's: [vault]
/// key_file, else ~/.config/wanderbooks/vault.key; one inside the
/// books dir is refused, as the server refuses it.
/// INVARIANT 4: an error never carries the digits (the vault's errors
/// are redacted); a party with no record is "not in the vault".
/// ```
pub fn tin_from_vault(&self, party: PartyId) -> Result<String, String> {
let key = self
.settings
.vault_key_file
.clone()
.unwrap_or_else(ledger_vault::default_key_file);
let books = self.books_dir();
if resolved(&key).starts_with(resolved(&books)) {
return Err(format!(
"The vault key file {} is inside the books folder: move it out and set [vault] key_file.",
key.display()
));
}
let vault = ledger_vault::Vault::open(&books.join("vault"), &key, false).map_err(
|e| match e {
ledger_vault::Error::NoKey(k) => format!(
"The vault is locked: its key file {} is missing. Restore it from your password manager.",
k.display()
),
other => other.to_string(),
},
)?;
match vault.tin(party) {
Ok(Some(t)) => Ok(t.formatted()),
Ok(None) => Err("Not in the vault on this computer.".into()),
Err(e) => Err(e.to_string()),
}
}
/// One entry's what-for, for one party (see [`PartyFlow`] INVARIANT
/// 3): its fraction of the entry's entry_flow what_for, summing to its
/// money share exactly (zero when the entry moves no money, like a
@@ -1895,3 +1947,28 @@ fn origin(events: &[usize], journal: &[JournalLine], closing: bool) -> &'static
fn cash_accounts(ledger: &Ledger, settings: &BooksSettings) -> Vec<AccountId> {
ledger_store::reconcilable::cash_accounts(ledger, &settings.cash_accounts)
}
/// `path` made absolute through its nearest existing ancestor (symlinks
/// resolved), so a path that doesn't exist yet can still be placed: the
/// server's rule for where the vault key may live.
fn resolved(path: &Path) -> PathBuf {
let abs = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir().unwrap_or_default().join(path)
};
let mut base = abs.as_path();
let mut rest = Vec::new();
while !base.exists() {
match (base.file_name(), base.parent()) {
(Some(name), Some(up)) => {
rest.push(name.to_os_string());
base = up;
}
_ => return abs,
}
}
let mut out = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
out.extend(rest.into_iter().rev());
out
}
+26 -6
View File
@@ -157,6 +157,9 @@ pub fn party_icon(p: &Party) -> &'static str {
}
}
/// What a TIN kept in the vault says on hover.
const TIN_COPY_HOVER: &str = "Click to copy the full number (right-click: digits only). It's read from the encrypted vault and never shown; the books keep only the last four.";
/// The last four of a TIN, masked the way a truncated TIN is printed:
/// "•••-••-1234". The books never hold more than these four digits.
pub fn masked_tin(last4: &str) -> String {
@@ -774,7 +777,7 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
});
super::columns(ui, 860.0, &[1.0, 1.0], |col, ui| {
if col == 0 {
facts_card(ui, party, &mut go);
facts_card(ui, b, party, &mut go);
} else {
let mut any = false;
if b.files_1099(party) || party.w9.is_some() {
@@ -861,7 +864,7 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
}
/// Who they are: names, website, address, notes, ids.
fn facts_card(ui: &mut Ui, party: &Party, go: &mut Option<Route>) {
fn facts_card(ui: &mut Ui, b: &Books, party: &Party, go: &mut Option<Route>) {
let p = pal(ui.ctx());
widgets::titled_card(
ui,
@@ -908,10 +911,18 @@ fn facts_card(ui: &mut Ui, party: &Party, go: &mut Option<Route>) {
ui.end_row();
k(ui, "Taxpayer ID");
match &party.tin {
Some(t) if t.vault_record.is_some() => {
widgets::secret_copyable(
ui,
RichText::new(tin_on_file(t)).monospace(),
TIN_COPY_HOVER,
|| b.tin_from_vault(party.id),
);
}
Some(t) => {
ui.label(RichText::new(tin_on_file(t)).monospace())
.on_hover_text(
"Only the last four digits are in the books; the full number is kept in the encrypted TIN vault, for the CPA package.",
"Only the last four digits are in the books; the full number isn't in the vault.",
);
}
None => {
@@ -1008,10 +1019,19 @@ fn w9_card(ui: &mut Ui, b: &Books, party: &Party) {
ui.label(w.fatca_code.as_deref().unwrap_or("none"));
ui.end_row();
k(ui, "TIN");
ui.label(RichText::new(masked_tin(&w.tin_last4)).monospace())
.on_hover_text(
"Only the last four digits are in the books; the full number is kept in the vault.",
if w.vault_record.is_some() {
widgets::secret_copyable(
ui,
RichText::new(masked_tin(&w.tin_last4)).monospace(),
TIN_COPY_HOVER,
|| b.tin_from_vault(party.id),
);
} else {
ui.label(RichText::new(masked_tin(&w.tin_last4)).monospace())
.on_hover_text(
"Only the last four digits are in the books; the full number isn't in the vault (the form is kept elsewhere).",
);
}
ui.end_row();
k(ui, "Kept at");
ui.label(&w.kept_at);
+84
View File
@@ -308,6 +308,90 @@ pub fn copyable(ui: &mut Ui, text: RichText, value: &str) -> Response {
.inner
}
/// A secret that copies itself when clicked but is never drawn: `text` is
/// its masked form; `fetch` reads the real value, only on a click (or the
/// right-click menu's "Copy digits only"). A fetch that fails shows why,
/// beside it, for a few seconds.
pub fn secret_copyable(
ui: &mut Ui,
text: RichText,
hover: &str,
fetch: impl Fn() -> Result<String, String>,
) -> Response {
let id = ui.id().with(("secret-copy", text.text()));
let err_id = id.with("error");
let p = pal(ui.ctx());
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let r = ui.add(egui::Label::new(text).sense(Sense::click()));
let recent = recently_copied(ui, id);
let (slot, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
if r.hovered() || recent {
ui.painter().text(
slot.center(),
egui::Align2::CENTER_CENTER,
if recent {
egui_phosphor::regular::CHECK
} else {
egui_phosphor::regular::COPY
},
egui::FontId::proportional(12.0),
if recent { p.positive } else { p.muted },
);
}
if r.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
let copy = |ui: &mut Ui, digits_only: bool| match fetch() {
Ok(v) => {
let v: String = if digits_only {
v.chars().filter(char::is_ascii_digit).collect()
} else {
v
};
ui.ctx().data_mut(|d| d.remove::<(f64, String)>(err_id));
copy_now(ui, id, &v);
}
Err(e) => {
let now = ui.input(|i| i.time);
ui.ctx().data_mut(|d| d.insert_temp(err_id, (now, e)));
}
};
let r = r.on_hover_text(if recent { "Copied" } else { hover });
if r.clicked() {
copy(ui, false);
}
r.context_menu(|ui| {
if ui
.button(format!("{} Copy", egui_phosphor::regular::COPY))
.clicked()
{
copy(ui, false);
ui.close();
}
if ui
.button(format!("{} Copy digits only", egui_phosphor::regular::COPY))
.clicked()
{
copy(ui, true);
ui.close();
}
});
let failed: Option<(f64, String)> = ui.ctx().data(|d| d.get_temp(err_id));
let now = ui.input(|i| i.time);
if let Some((at, e)) = failed.filter(|(at, _)| now - at < 6.0) {
ui.ctx()
.request_repaint_after(std::time::Duration::from_secs_f64(6.0 - (now - at)));
ui.add(
egui::Label::new(RichText::new(e).font(theme::regular(12.0)).color(p.warning))
.wrap(),
);
}
r
})
.inner
}
/// A "Copy …" item for a right-click menu.
pub fn copy_item(ui: &mut Ui, label: &str, value: &str) {
if ui