Implement OTP clipboard and QR TUI
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user