Implement OTP clipboard and QR TUI

This commit is contained in:
Hermes Agent
2026-08-10 11:11:53 +00:00
parent 1850846696
commit 71bbff934e
8 changed files with 1021 additions and 28 deletions

View File

@@ -77,6 +77,43 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
return;
}
if let Some(matrix) = app.qr_popup() {
let padded_width = matrix.width() + 8;
let needed_width =
u16::try_from(padded_width.saturating_mul(2).saturating_add(2)).unwrap_or(u16::MAX);
let needed_height = u16::try_from(
padded_width
.next_multiple_of(2)
.saturating_div(2)
.saturating_add(2),
)
.unwrap_or(u16::MAX);
let text = if area.width < needed_width || area.height < needed_height {
format!(
"Terminal too small for OTP QR (need {needed_width}×{needed_height}); resize or Esc to close."
)
} else {
let rendered = matrix.render_terminal();
String::from_utf8_lossy(rendered.expose()).into_owned()
};
frame.render_widget(
Paragraph::new(text)
.block(Block::bordered().title("OTP QR — Esc closes"))
.wrap(Wrap { trim: false }),
area,
);
return;
}
if let Some(uri) = app.uri_popup() {
frame.render_widget(
Paragraph::new(String::from_utf8_lossy(uri.expose()).into_owned())
.block(Block::bordered().title("OTP URI — Esc closes"))
.wrap(Wrap { trim: false }),
area,
);
return;
}
if app.mode() == Mode::Help {
if let Some(help) = app.command_help() {
frame.render_widget(
@@ -87,27 +124,32 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
);
return;
}
let lines = help_actions(app.help_context_mode()).map(|spec| {
let bindings = spec
.bindings
.iter()
.map(|binding| binding.display)
let entries = help_actions(app.help_context_mode())
.map(|spec| {
let bindings = spec
.bindings
.iter()
.map(|binding| binding.display)
.collect::<Vec<_>>()
.join(", ");
format!(
"{bindings:>10} {:<20} :{:<16} {}",
spec.label,
spec.command,
spec.help()
)
})
.collect::<Vec<_>>();
let lines = if area.width >= 120 {
entries
.chunks(2)
.map(|chunk| Line::raw(chunk.join(" ")))
.collect::<Vec<_>>()
.join(", ");
let mut spans = vec![
Span::styled(format!("{bindings:>14}"), Style::default().fg(Color::Cyan)),
Span::raw(format!(" {:<24} :{:<18}", spec.label, spec.command)),
];
if !spec.help().is_empty() {
spans.push(Span::styled(
spec.help(),
Style::default().fg(Color::Yellow),
));
}
Line::from(spans)
});
} else {
entries.into_iter().map(Line::raw).collect::<Vec<_>>()
};
frame.render_widget(
Paragraph::new(lines.collect::<Vec<_>>())
Paragraph::new(lines)
.block(Block::bordered().title(format!(
"Contextual help — {}",
mode_title(app.help_context_mode())
@@ -157,7 +199,7 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
Mode::Viewer => app.viewer().map_or_else(
|| Paragraph::new(main_text(app)),
|viewer| {
Paragraph::new(viewer_lines(viewer))
Paragraph::new(viewer_lines(viewer, app.otp_display()))
.scroll((u16::try_from(viewer.scroll()).unwrap_or(u16::MAX), 0))
},
),
@@ -258,13 +300,19 @@ fn main_text(app: &App) -> String {
Mode::Dialog if app.discard_confirmation() => {
"Discard all unsaved edits? Press y to discard, n or Esc to keep editing.".to_owned()
}
Mode::Dialog if app.hotp_confirmation() => {
"Generate this HOTP code? This advances and commits its counter. Press y to continue, n or Esc to cancel.".to_owned()
}
Mode::Dialog => "Complete or cancel the active dialog.".to_owned(),
Mode::Command => "Enter a command on the bottom line.".to_owned(),
Mode::Help | Mode::Locked => String::new(),
}
}
fn viewer_lines(viewer: &EntryViewer) -> Vec<Line<'_>> {
fn viewer_lines<'a>(
viewer: &'a EntryViewer,
otp_display: Option<&'a crate::app::OtpDisplay>,
) -> Vec<Line<'a>> {
let focused = viewer.focused_index();
if viewer.document().fields().is_empty() {
return vec![Line::from("This entry is empty.")];
@@ -323,6 +371,28 @@ fn viewer_lines(viewer: &EntryViewer) -> Vec<Line<'_>> {
),
Style::default().fg(Color::DarkGray),
));
if focused == Some(index)
&& let Some(display) = otp_display
&& display
.field()
.is_none_or(|display_field| display_field == field.id())
{
let code = String::from_utf8_lossy(display.code().expose());
let validity = display.remaining_seconds().map_or_else(
|| {
display
.counter()
.map_or(String::new(), |counter| format!(", counter {counter}"))
},
|remaining| format!(", {remaining}s remaining"),
);
spans.push(Span::styled(
format!(" code {code}{validity}"),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
));
}
}
let line = Line::from(spans);
if focused == Some(index) {
@@ -856,6 +926,77 @@ mod tests {
assert!(!output.contains("JBSWY3DPEHPK3PXP"));
}
#[test]
fn otp_code_qr_resize_and_lock_lifecycle_are_secret_safe() {
let mut app = App::new();
let document = fixture_document("otp/totp");
let wrong_field = document.fields()[0].id();
let otp_field = document
.fields()
.iter()
.find(|field| field.metadata().otp().is_some())
.expect("OTP field")
.id();
app.open_test_document("otp/totp", document);
app.dispatch(crate::action::Action::FocusNext);
let token = app.begin_request();
app.apply_result(crate::app::AsyncResult {
token,
payload: Ok(crate::app::AsyncPayload::OtpCodeFinished {
entry: "otp/totp".to_owned(),
field: Some(wrong_field),
code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()),
remaining_seconds: Some(12),
counter: None,
clipboard: false,
tree: None,
}),
});
assert!(!render(120, 20, &app).contains("123456"));
let token = app.begin_request();
app.apply_result(crate::app::AsyncResult {
token,
payload: Ok(crate::app::AsyncPayload::OtpCodeFinished {
entry: "otp/totp".to_owned(),
field: Some(otp_field),
code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()),
remaining_seconds: Some(12),
counter: None,
clipboard: false,
tree: None,
}),
});
let code = render(120, 20, &app);
assert!(code.contains("123456"));
assert!(code.contains("12s remaining"));
assert!(!code.contains("JBSWY3DPEHPK3PXP"));
let payload = ironstorage::repository::SecretBytes::new(
b"otpauth://totp/test?secret=NEVER-RENDER".to_vec(),
);
let qr = ironstorage::presentation::QrMatrix::encode(&payload).expect("QR");
let token = app.begin_request();
app.apply_result(crate::app::AsyncResult {
token,
payload: Ok(crate::app::AsyncPayload::OtpUriFinished {
entry: "otp/totp".to_owned(),
presentation: crate::app::OtpPresentationTarget::Qr,
payload,
qr: Some(qr),
}),
});
let resized = render(40, 8, &app);
assert!(resized.contains("too small for OTP QR"));
assert!(!resized.contains("NEVER-RENDER"));
assert!(matches!(
app.dispatch(crate::action::Action::Lock),
crate::app::AppEffect::ManualLock
));
let locked = render(120, 20, &app);
assert!(!locked.contains("123456"));
assert!(!locked.contains("NEVER-RENDER"));
}
#[test]
fn closing_a_document_preserves_the_sidebar_selection() {
let mut app = App::new();