Implement the authenticated structured entry viewer
This commit is contained in:
@@ -11,6 +11,7 @@ use ratatui::{
|
||||
use crate::{
|
||||
action::available_actions,
|
||||
app::{App, Mode, PaneFocus},
|
||||
viewer::EntryViewer,
|
||||
};
|
||||
|
||||
const MINIMUM_WIDTH: u16 = 40;
|
||||
@@ -124,13 +125,23 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
.wrap(Wrap { trim: false }),
|
||||
panes[0],
|
||||
);
|
||||
frame.render_widget(
|
||||
let main = if app.mode() == Mode::Viewer {
|
||||
app.viewer().map_or_else(
|
||||
|| Paragraph::new(main_text(app)),
|
||||
|viewer| {
|
||||
Paragraph::new(viewer_lines(viewer))
|
||||
.scroll((u16::try_from(viewer.scroll()).unwrap_or(u16::MAX), 0))
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Paragraph::new(main_text(app))
|
||||
.block(pane_block(
|
||||
mode_title(app.mode()),
|
||||
app.focus() == PaneFocus::Main,
|
||||
))
|
||||
.wrap(Wrap { trim: true }),
|
||||
};
|
||||
frame.render_widget(
|
||||
main.block(pane_block(
|
||||
mode_title(app.mode()),
|
||||
app.focus() == PaneFocus::Main,
|
||||
))
|
||||
.wrap(Wrap { trim: false }),
|
||||
panes[1],
|
||||
);
|
||||
}
|
||||
@@ -208,6 +219,76 @@ fn main_text(app: &App) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn viewer_lines(viewer: &EntryViewer) -> Vec<Line<'_>> {
|
||||
let focused = viewer.focused_index();
|
||||
if viewer.document().fields().is_empty() {
|
||||
return vec![Line::from("This entry is empty.")];
|
||||
}
|
||||
|
||||
viewer
|
||||
.document()
|
||||
.fields()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, field)| {
|
||||
let metadata = field.metadata();
|
||||
let label = metadata.name().map_or_else(
|
||||
|| match metadata.kind() {
|
||||
ironstorage::document::EntryFieldKind::Note => format!("note {}", index + 1),
|
||||
ironstorage::document::EntryFieldKind::Blank => format!("blank {}", index + 1),
|
||||
kind => format!("{kind:?}").to_ascii_lowercase(),
|
||||
},
|
||||
str::to_owned,
|
||||
);
|
||||
let value = if metadata.sensitivity()
|
||||
== ironstorage::document::EntrySensitivity::Sensitive
|
||||
&& !viewer.is_revealed(field.id())
|
||||
{
|
||||
Span::styled("••••••••", Style::default().fg(Color::DarkGray))
|
||||
} else if field.value().is_empty() {
|
||||
Span::styled("(empty)", Style::default().fg(Color::DarkGray))
|
||||
} else {
|
||||
match std::str::from_utf8(field.value()) {
|
||||
Ok(value) => Span::raw(value),
|
||||
Err(_) => Span::styled("[non-UTF-8 value]", Style::default().fg(Color::Yellow)),
|
||||
}
|
||||
};
|
||||
let mut spans = vec![
|
||||
Span::styled(
|
||||
format!("{label}: "),
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
value,
|
||||
];
|
||||
if let Some(otp) = metadata.otp() {
|
||||
let timing = otp.period().map_or_else(
|
||||
|| format!("counter {}", otp.counter().unwrap_or_default()),
|
||||
|period| format!("period {period}s"),
|
||||
);
|
||||
spans.push(Span::styled(
|
||||
format!(
|
||||
" [{:?}, {}, {:?}, {}, {} digits, {timing}]",
|
||||
otp.kind(),
|
||||
otp.issuer().unwrap_or("no issuer"),
|
||||
otp.algorithm(),
|
||||
otp.account(),
|
||||
otp.digits(),
|
||||
),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
}
|
||||
let line = Line::from(spans);
|
||||
if focused == Some(index) {
|
||||
line.style(Style::default().bg(Color::Blue).fg(Color::White))
|
||||
} else {
|
||||
line
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn mode_title(mode: Mode) -> &'static str {
|
||||
match mode {
|
||||
Mode::Browser => "Browser",
|
||||
@@ -264,6 +345,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::app::Transition;
|
||||
use crate::sidebar::TestTreeNode;
|
||||
use crate::viewer::test_support::fixture_document;
|
||||
|
||||
fn render(width: u16, height: u16, app: &App) -> String {
|
||||
let backend = TestBackend::new(width, height);
|
||||
@@ -384,4 +466,77 @@ mod tests {
|
||||
let output = render(100, 20, &app);
|
||||
assert!(output.contains("locks in 30s"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewer_masks_storage_sensitive_fields_and_renders_dynamic_unicode_fields() {
|
||||
let mut app = App::new();
|
||||
app.open_test_document("unicode/咖啡", fixture_document("unicode/咖啡"));
|
||||
for width in [60, 100, 140] {
|
||||
let output = render(width, 20, &app);
|
||||
assert!(output.contains("password: ••••••••"));
|
||||
assert!(output.contains("login:"));
|
||||
assert!(output.contains('用'));
|
||||
assert!(output.contains("@example.test"));
|
||||
assert!(output.contains("notes: ••••••••"));
|
||||
assert!(!output.contains("pässwörd-猫"));
|
||||
}
|
||||
assert!(!format!("{app:?}").contains("pässwörd-猫"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_is_explicit_and_focus_change_or_lock_removes_secret_from_rendering() {
|
||||
let mut app = App::new();
|
||||
app.open_test_document("email/personal", fixture_document("email/personal"));
|
||||
app.dispatch(crate::action::Action::Reveal);
|
||||
let revealed = render(100, 20, &app);
|
||||
assert!(revealed.contains("correct horse fixture"));
|
||||
|
||||
app.dispatch(crate::action::Action::FocusNext);
|
||||
let hidden = render(100, 20, &app);
|
||||
assert!(!hidden.contains("correct horse fixture"));
|
||||
|
||||
app.dispatch(crate::action::Action::FocusPrevious);
|
||||
app.dispatch(crate::action::Action::Reveal);
|
||||
app.dispatch(crate::action::Action::Help);
|
||||
app.dispatch(crate::action::Action::Cancel);
|
||||
let after_overlay = render(100, 20, &app);
|
||||
assert!(!after_overlay.contains("correct horse fixture"));
|
||||
|
||||
app.dispatch(crate::action::Action::Reveal);
|
||||
app.forced_relock("test lock");
|
||||
let locked = render(100, 20, &app);
|
||||
assert!(!locked.contains("correct horse fixture"));
|
||||
assert!(locked.contains("locked"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn otp_metadata_is_visible_while_the_secret_uri_stays_masked() {
|
||||
let mut app = App::new();
|
||||
app.open_test_document("otp/totp", fixture_document("otp/totp"));
|
||||
app.dispatch(crate::action::Action::FocusNext);
|
||||
let output = render(120, 20, &app);
|
||||
assert!(output.contains("Totp"));
|
||||
assert!(output.contains("IronStorage"));
|
||||
assert!(output.contains("alice@example.test"));
|
||||
assert!(output.contains("period 30s"));
|
||||
assert!(!output.contains("JBSWY3DPEHPK3PXP"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_document_preserves_the_sidebar_selection() {
|
||||
let mut app = App::new();
|
||||
app.sidebar_mut().replace_test_tree(vec![TestTreeNode {
|
||||
path: "email/personal".to_owned(),
|
||||
name: "personal".to_owned(),
|
||||
directory: false,
|
||||
indicators: ironstorage::read::TreeNodeIndicators::default(),
|
||||
children: vec![],
|
||||
}]);
|
||||
let selected = app.sidebar().selected().cloned();
|
||||
app.open_test_document("email/personal", fixture_document("email/personal"));
|
||||
app.dispatch(crate::action::Action::CloseEntry);
|
||||
assert_eq!(app.mode(), Mode::Browser);
|
||||
assert_eq!(app.sidebar().selected(), selected.as_ref());
|
||||
assert!(app.viewer().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user