Improve TUI OTP readability
This commit is contained in:
@@ -34,7 +34,9 @@ submits a command or creates extra entry lines. Losing terminal ownership
|
||||
(including terminal suspension/focus loss) immediately revokes the lease,
|
||||
cancels Git and clipboard work, removes plaintext presentation state, and
|
||||
returns to the locked screen. `NO_COLOR` or `TERM=dumb` selects the monochrome
|
||||
fallback while preserving focus through text and bold attributes.
|
||||
fallback while preserving focused selections through reverse-video attributes.
|
||||
Selected rows override nested field colors so labels, masked values, metadata,
|
||||
and OTP codes keep one high-contrast foreground across the complete selection.
|
||||
|
||||
## Pass and pass-otp coverage matrix
|
||||
|
||||
@@ -65,7 +67,7 @@ where one is listed.
|
||||
| git fetch/sync | cancellable progress view | — | `:git fetch …`, `:git sync …` |
|
||||
| git pull/push | cancellable progress view | `g p` / `g P` | `:git pull …`, `:git push …` |
|
||||
| git conflict resolution | conflict view | — | `:git resolve-local`, `:git resolve-remote` |
|
||||
| otp code | focused OTP field | `o c` | `:otp code ENTRY` |
|
||||
| otp code | responsive entry OTP panel/compact field | `o c` | `:otp code ENTRY` |
|
||||
| otp code clipboard | clipboard feedback | `o y` | `:otp code --clip ENTRY` |
|
||||
| otp insert/append | masked OTP forms | `o i` / `o a` | `:otp insert …`, `:otp append …` |
|
||||
| otp URI terminal/clipboard/QR | secret popup/clipboard/QR | `o u` / `o x` / `o q` | `:otp uri [--clip\|--qrcode] ENTRY` |
|
||||
@@ -79,6 +81,13 @@ ticks, storage completion, Git progress, and clipboard timers do not extend the
|
||||
authentication lease. Clipboard and QR payloads are storage-produced,
|
||||
zeroizing values and disappear immediately on relock.
|
||||
|
||||
Opening an entry with a TOTP field requests its read-only code without requiring
|
||||
field focus. If the viewer pane can retain both the code and field list, the TUI
|
||||
renders five-row digit-only glyphs and a storage-period-driven countdown bar.
|
||||
Shorter panes fall back to a leading inline code and remaining-seconds label.
|
||||
The displayed TOTP survives field navigation, refreshes at its exact storage
|
||||
boundary, and is removed when the entry closes or the application relocks.
|
||||
|
||||
`:quit` is a TUI convenience. Shell-completion script generation is a CLI
|
||||
build-time/integration surface; interactive Tab completion replaces it here.
|
||||
|
||||
|
||||
@@ -138,6 +138,9 @@ impl OtpDisplay {
|
||||
pub fn counter(&self) -> Option<u64> {
|
||||
self.validity.counter()
|
||||
}
|
||||
pub fn period_seconds(&self) -> Option<u64> {
|
||||
self.validity.period()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -542,19 +545,33 @@ impl App {
|
||||
return None;
|
||||
}
|
||||
let viewer = self.viewer.as_ref()?;
|
||||
let field = viewer.focused_field()?;
|
||||
let otp = field.metadata().otp()?;
|
||||
if otp.kind() != ironstorage::otp::OtpKind::Totp {
|
||||
return None;
|
||||
}
|
||||
let entry = self.selected_entry.clone()?;
|
||||
if self.otp_display.as_ref().is_some_and(|display| {
|
||||
display.entry == entry
|
||||
&& display.field == Some(field.id())
|
||||
&& display.remaining_seconds != Some(0)
|
||||
}) {
|
||||
return None;
|
||||
let field = if let Some(display) = self
|
||||
.otp_display
|
||||
.as_ref()
|
||||
.filter(|display| display.entry == entry)
|
||||
{
|
||||
match display.remaining_seconds {
|
||||
Some(0) => {
|
||||
let field_id = display.field?;
|
||||
viewer.document().fields().iter().find(|field| {
|
||||
field.id() == field_id
|
||||
&& field
|
||||
.metadata()
|
||||
.otp()
|
||||
.is_some_and(|otp| otp.kind() == ironstorage::otp::OtpKind::Totp)
|
||||
})?
|
||||
}
|
||||
Some(_) | None => return None,
|
||||
}
|
||||
} else {
|
||||
viewer.document().fields().iter().find(|field| {
|
||||
field
|
||||
.metadata()
|
||||
.otp()
|
||||
.is_some_and(|otp| otp.kind() == ironstorage::otp::OtpKind::Totp)
|
||||
})?
|
||||
};
|
||||
self.otp_pending = true;
|
||||
Some((entry, field.id()))
|
||||
}
|
||||
@@ -998,13 +1015,11 @@ impl App {
|
||||
Action::FocusNext if self.mode == Mode::Viewer => {
|
||||
if let Some(viewer) = self.viewer.as_mut() {
|
||||
viewer.focus_next();
|
||||
self.otp_display = None;
|
||||
}
|
||||
}
|
||||
Action::FocusPrevious if self.mode == Mode::Viewer => {
|
||||
if let Some(viewer) = self.viewer.as_mut() {
|
||||
viewer.focus_previous();
|
||||
self.otp_display = None;
|
||||
}
|
||||
}
|
||||
Action::FocusNext if self.mode == Mode::Editor => {
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::{
|
||||
action::{context_actions, help_actions},
|
||||
@@ -17,6 +18,8 @@ use crate::{
|
||||
|
||||
const MINIMUM_WIDTH: u16 = 40;
|
||||
const MINIMUM_HEIGHT: u16 = 8;
|
||||
const LARGE_OTP_HEIGHT: u16 = 8;
|
||||
const LARGE_OTP_ROWS: usize = 5;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ColorCapability {
|
||||
@@ -69,6 +72,9 @@ pub fn draw_with_color_capability(frame: &mut Frame, app: &App, capability: Colo
|
||||
draw_inner(frame, app);
|
||||
if capability == ColorCapability::Monochrome {
|
||||
for cell in &mut frame.buffer_mut().content {
|
||||
if cell.fg == Color::White && cell.bg == Color::Blue {
|
||||
cell.modifier.insert(Modifier::REVERSED);
|
||||
}
|
||||
cell.set_fg(Color::Reset).set_bg(Color::Reset);
|
||||
}
|
||||
}
|
||||
@@ -232,6 +238,56 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
.wrap(Wrap { trim: false }),
|
||||
panes[0],
|
||||
);
|
||||
if app.mode() == Mode::Viewer
|
||||
&& let Some(viewer) = app.viewer()
|
||||
&& large_otp_fits(app, viewer, panes[1])
|
||||
{
|
||||
let main_rows =
|
||||
Layout::vertical([Constraint::Length(LARGE_OTP_HEIGHT), Constraint::Min(3)])
|
||||
.split(panes[1]);
|
||||
render_large_otp(
|
||||
frame,
|
||||
app.otp_display().expect("large OTP was checked"),
|
||||
main_rows[0],
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(viewer_lines(viewer, None))
|
||||
.scroll((u16::try_from(viewer.scroll()).unwrap_or(u16::MAX), 0))
|
||||
.block(pane_block(
|
||||
mode_title(app.mode()),
|
||||
app.focus() == PaneFocus::Main,
|
||||
))
|
||||
.wrap(Wrap { trim: false }),
|
||||
main_rows[1],
|
||||
);
|
||||
return;
|
||||
}
|
||||
if app.mode() == Mode::Viewer
|
||||
&& let Some(viewer) = app.viewer()
|
||||
&& let Some(display) = matching_totp_display(app, viewer)
|
||||
{
|
||||
let (otp_area, details_area) = if panes[1].height >= 6 {
|
||||
let rows =
|
||||
Layout::vertical([Constraint::Length(3), Constraint::Min(3)]).split(panes[1]);
|
||||
(rows[0], Some(rows[1]))
|
||||
} else {
|
||||
(panes[1], None)
|
||||
};
|
||||
render_compact_otp(frame, display, otp_area);
|
||||
if let Some(details_area) = details_area {
|
||||
frame.render_widget(
|
||||
Paragraph::new(viewer_lines(viewer, None))
|
||||
.scroll((u16::try_from(viewer.scroll()).unwrap_or(u16::MAX), 0))
|
||||
.block(pane_block(
|
||||
mode_title(app.mode()),
|
||||
app.focus() == PaneFocus::Main,
|
||||
))
|
||||
.wrap(Wrap { trim: false }),
|
||||
details_area,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let main = match app.mode() {
|
||||
Mode::Viewer => app.viewer().map_or_else(
|
||||
|| Paragraph::new(main_text(app)),
|
||||
@@ -267,6 +323,146 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
);
|
||||
}
|
||||
|
||||
fn large_otp_fits(app: &App, viewer: &EntryViewer, area: Rect) -> bool {
|
||||
let Some(display) = matching_totp_display(app, viewer) else {
|
||||
return false;
|
||||
};
|
||||
let code = display.code().expose();
|
||||
let glyph_width = code.len().saturating_mul(4).saturating_sub(1);
|
||||
!code.is_empty()
|
||||
&& code.iter().all(u8::is_ascii_digit)
|
||||
&& usize::from(area.width.saturating_sub(2)) >= glyph_width
|
||||
&& area.height >= LARGE_OTP_HEIGHT.saturating_add(3)
|
||||
}
|
||||
|
||||
fn matching_totp_display<'a>(
|
||||
app: &'a App,
|
||||
viewer: &EntryViewer,
|
||||
) -> Option<&'a crate::app::OtpDisplay> {
|
||||
let display = app.otp_display()?;
|
||||
if app.selected_entry() != Some(display.entry())
|
||||
|| display.remaining_seconds().is_none()
|
||||
|| display.period_seconds().is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let field_id = display.field()?;
|
||||
viewer
|
||||
.document()
|
||||
.fields()
|
||||
.iter()
|
||||
.any(|field| {
|
||||
field.id() == field_id
|
||||
&& field
|
||||
.metadata()
|
||||
.otp()
|
||||
.is_some_and(|otp| otp.kind() == ironstorage::otp::OtpKind::Totp)
|
||||
})
|
||||
.then_some(display)
|
||||
}
|
||||
|
||||
fn render_large_otp(frame: &mut Frame, display: &crate::app::OtpDisplay, area: Rect) {
|
||||
let glyphs = (0..LARGE_OTP_ROWS)
|
||||
.map(|row| otp_ascii_row(display.code().expose(), row))
|
||||
.collect::<Vec<_>>();
|
||||
let remaining = display.remaining_seconds().unwrap_or_default();
|
||||
let period = display.period_seconds().unwrap_or(1);
|
||||
let inner_width = usize::from(area.width.saturating_sub(2));
|
||||
let mut lines = glyphs
|
||||
.iter()
|
||||
.map(|row| {
|
||||
Line::styled(
|
||||
row.as_str(),
|
||||
Style::default()
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
lines.push(Line::styled(
|
||||
countdown_bar(remaining, period, inner_width),
|
||||
Style::default().fg(Color::Cyan),
|
||||
));
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines)
|
||||
.alignment(Alignment::Center)
|
||||
.block(Block::bordered().title(format!("TOTP code — {remaining}s remaining"))),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_compact_otp(frame: &mut Frame, display: &crate::app::OtpDisplay, area: Rect) {
|
||||
let code = String::from_utf8_lossy(display.code().expose());
|
||||
let remaining = display.remaining_seconds().unwrap_or_default();
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(
|
||||
code.as_ref(),
|
||||
Style::default()
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(format!(" — {remaining}s remaining")),
|
||||
]))
|
||||
.alignment(Alignment::Center)
|
||||
.block(Block::bordered().title("TOTP code")),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn otp_ascii_row(code: &[u8], row: usize) -> Zeroizing<String> {
|
||||
let mut rendered = Zeroizing::new(String::with_capacity(
|
||||
code.len().saturating_mul(4).saturating_sub(1),
|
||||
));
|
||||
for (index, digit) in code.iter().copied().enumerate() {
|
||||
if index != 0 {
|
||||
rendered.push(' ');
|
||||
}
|
||||
rendered.push_str(otp_digit_glyph(digit, row));
|
||||
}
|
||||
rendered
|
||||
}
|
||||
|
||||
fn otp_digit_glyph(digit: u8, row: usize) -> &'static str {
|
||||
const GLYPHS: [[&str; LARGE_OTP_ROWS]; 10] = [
|
||||
["000", "0 0", "0 0", "0 0", "000"],
|
||||
[" 1 ", "11 ", " 1 ", " 1 ", "111"],
|
||||
["222", " 2", "222", "2 ", "222"],
|
||||
["333", " 3", "333", " 3", "333"],
|
||||
["4 4", "4 4", "444", " 4", " 4"],
|
||||
["555", "5 ", "555", " 5", "555"],
|
||||
["666", "6 ", "666", "6 6", "666"],
|
||||
["777", " 7", " 7 ", " 7 ", " 7 "],
|
||||
["888", "8 8", "888", "8 8", "888"],
|
||||
["999", "9 9", "999", " 9", "999"],
|
||||
];
|
||||
let index = usize::from(digit.saturating_sub(b'0'));
|
||||
GLYPHS
|
||||
.get(index)
|
||||
.and_then(|glyph| glyph.get(row))
|
||||
.copied()
|
||||
.unwrap_or(" ")
|
||||
}
|
||||
|
||||
fn countdown_bar(remaining: u64, period: u64, width: usize) -> String {
|
||||
let label = format!("{remaining}s");
|
||||
let bar_width = width.saturating_sub(label.len().saturating_add(1));
|
||||
let period = period.max(1);
|
||||
let remaining = remaining.min(period);
|
||||
let filled = if remaining == 0 {
|
||||
0
|
||||
} else {
|
||||
usize::try_from((u128::from(remaining) * bar_width as u128).div_ceil(u128::from(period)))
|
||||
.unwrap_or(bar_width)
|
||||
.min(bar_width)
|
||||
};
|
||||
format!(
|
||||
"{}{} {label}",
|
||||
"█".repeat(filled),
|
||||
"░".repeat(bar_width.saturating_sub(filled))
|
||||
)
|
||||
}
|
||||
|
||||
fn pane_block(title: &'static str, focused: bool) -> Block<'static> {
|
||||
let style = if focused {
|
||||
Style::default()
|
||||
@@ -281,6 +477,14 @@ fn pane_block(title: &'static str, focused: bool) -> Block<'static> {
|
||||
.border_style(style)
|
||||
}
|
||||
|
||||
fn selected_line<'a>(mut spans: Vec<Span<'a>>) -> Line<'a> {
|
||||
let style = Style::default().bg(Color::Blue).fg(Color::White);
|
||||
for span in &mut spans {
|
||||
span.style = span.style.bg(Color::Blue).fg(Color::White);
|
||||
}
|
||||
Line::from(spans).style(style)
|
||||
}
|
||||
|
||||
fn sidebar_lines(app: &App) -> Vec<Line<'static>> {
|
||||
let selected = app.sidebar().selected();
|
||||
let rows = app.sidebar().visible_window();
|
||||
@@ -408,8 +612,7 @@ fn viewer_lines<'a>(
|
||||
),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
if focused == Some(index)
|
||||
&& let Some(display) = otp_display
|
||||
if let Some(display) = otp_display
|
||||
&& display
|
||||
.field()
|
||||
.is_none_or(|display_field| display_field == field.id())
|
||||
@@ -423,19 +626,21 @@ fn viewer_lines<'a>(
|
||||
},
|
||||
|remaining| format!(", {remaining}s remaining"),
|
||||
);
|
||||
spans.push(Span::styled(
|
||||
spans.insert(
|
||||
0,
|
||||
Span::styled(
|
||||
format!("code {code}{validity} "),
|
||||
Style::default()
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
let line = Line::from(spans);
|
||||
if focused == Some(index) {
|
||||
line.style(Style::default().bg(Color::Blue).fg(Color::White))
|
||||
selected_line(spans)
|
||||
} else {
|
||||
line
|
||||
Line::from(spans)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -503,11 +708,10 @@ fn editor_lines(editor: &EntryEditor) -> Vec<Line<'_>> {
|
||||
Style::default().fg(Color::Yellow),
|
||||
));
|
||||
}
|
||||
let line = Line::from(spans);
|
||||
if selected {
|
||||
line.style(Style::default().bg(Color::Blue).fg(Color::White))
|
||||
selected_line(spans)
|
||||
} else {
|
||||
line
|
||||
Line::from(spans)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -753,6 +957,14 @@ mod tests {
|
||||
.iter()
|
||||
.all(|cell| { cell.fg == Color::Reset && cell.bg == Color::Reset })
|
||||
);
|
||||
assert!(
|
||||
terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content
|
||||
.iter()
|
||||
.any(|cell| cell.modifier.contains(Modifier::REVERSED))
|
||||
);
|
||||
|
||||
terminal
|
||||
.draw(|frame| draw_with_color_capability(frame, &app, ColorCapability::Color))
|
||||
@@ -1020,7 +1232,10 @@ mod tests {
|
||||
.expect("OTP field")
|
||||
.id();
|
||||
app.open_test_document("otp/totp", document);
|
||||
app.dispatch(crate::action::Action::FocusNext);
|
||||
assert_eq!(
|
||||
app.begin_totp_refresh(),
|
||||
Some(("otp/totp".to_owned(), otp_field))
|
||||
);
|
||||
let token = app.begin_request();
|
||||
app.apply_result(crate::app::AsyncResult {
|
||||
token,
|
||||
@@ -1028,13 +1243,17 @@ mod tests {
|
||||
entry: "otp/totp".to_owned(),
|
||||
field: Some(wrong_field),
|
||||
code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()),
|
||||
validity: ironstorage::otp::OtpCodeValidity::Timed { valid_until: 72 },
|
||||
validity: ironstorage::otp::OtpCodeValidity::Timed {
|
||||
valid_until: 72,
|
||||
period: 30,
|
||||
},
|
||||
observed_at: 60,
|
||||
clipboard: false,
|
||||
tree: None,
|
||||
}),
|
||||
});
|
||||
assert!(!render(120, 20, &app).contains("123456"));
|
||||
app.dispatch(crate::action::Action::FocusNext);
|
||||
let token = app.begin_request();
|
||||
app.apply_result(crate::app::AsyncResult {
|
||||
token,
|
||||
@@ -1042,19 +1261,38 @@ mod tests {
|
||||
entry: "otp/totp".to_owned(),
|
||||
field: Some(otp_field),
|
||||
code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()),
|
||||
validity: ironstorage::otp::OtpCodeValidity::Timed { valid_until: 72 },
|
||||
validity: ironstorage::otp::OtpCodeValidity::Timed {
|
||||
valid_until: 72,
|
||||
period: 30,
|
||||
},
|
||||
observed_at: 60,
|
||||
clipboard: false,
|
||||
tree: None,
|
||||
}),
|
||||
});
|
||||
let code = render(120, 20, &app);
|
||||
assert!(code.contains("123456"));
|
||||
assert!(code.contains("12s remaining"));
|
||||
assert!(!code.contains("JBSWY3DPEHPK3PXP"));
|
||||
app.dispatch(crate::action::Action::FocusPrevious);
|
||||
let large = render(120, 20, &app);
|
||||
assert!(large.contains("TOTP code — 12s remaining"));
|
||||
assert!(large.contains("222 333"));
|
||||
assert!(!large.contains("code 123456"));
|
||||
assert!(!large.contains("JBSWY3DPEHPK3PXP"));
|
||||
let full_bar = large.matches('█').count();
|
||||
|
||||
let compact = render(60, 16, &app);
|
||||
assert!(compact.contains("123456 — 12s remaining"));
|
||||
assert!(!compact.contains("TOTP code —"));
|
||||
|
||||
app.observe_time(66);
|
||||
let progressed = render(120, 20, &app);
|
||||
assert!(progressed.contains("TOTP code — 6s remaining"));
|
||||
let progressed_bar = progressed.matches('█').count();
|
||||
assert!(progressed_bar < full_bar);
|
||||
assert!(progressed_bar > 0);
|
||||
|
||||
app.observe_time(72);
|
||||
assert!(render(120, 20, &app).contains("0s remaining"));
|
||||
let expired = render(120, 20, &app);
|
||||
assert!(expired.contains("0s remaining"));
|
||||
assert_eq!(expired.matches('█').count(), 0);
|
||||
assert_eq!(
|
||||
app.begin_totp_refresh(),
|
||||
Some(("otp/totp".to_owned(), otp_field))
|
||||
@@ -1066,16 +1304,23 @@ mod tests {
|
||||
entry: "otp/totp".to_owned(),
|
||||
field: Some(otp_field),
|
||||
code: ironstorage::repository::SecretBytes::new(b"654321".to_vec()),
|
||||
validity: ironstorage::otp::OtpCodeValidity::Timed { valid_until: 102 },
|
||||
validity: ironstorage::otp::OtpCodeValidity::Timed {
|
||||
valid_until: 102,
|
||||
period: 30,
|
||||
},
|
||||
observed_at: 72,
|
||||
clipboard: false,
|
||||
tree: None,
|
||||
}),
|
||||
});
|
||||
let refreshed = render(120, 20, &app);
|
||||
assert!(refreshed.contains("654321"));
|
||||
assert!(refreshed.contains("30s remaining"));
|
||||
assert!(!refreshed.contains("123456"));
|
||||
assert!(refreshed.contains("TOTP code — 30s remaining"));
|
||||
assert!(refreshed.contains("666 555"));
|
||||
assert!(refreshed.contains("333 222"));
|
||||
assert!(!refreshed.contains("222 333"));
|
||||
assert!(refreshed.matches('█').count() > full_bar);
|
||||
let minimum = render(40, 8, &app);
|
||||
assert!(minimum.contains("654321 — 30s remaining"));
|
||||
|
||||
let payload = ironstorage::repository::SecretBytes::new(
|
||||
b"otpauth://totp/test?secret=NEVER-RENDER".to_vec(),
|
||||
@@ -1100,9 +1345,87 @@ mod tests {
|
||||
));
|
||||
let locked = render(120, 20, &app);
|
||||
assert!(!locked.contains("123456"));
|
||||
assert!(!locked.contains("666 555"));
|
||||
assert!(!locked.contains("NEVER-RENDER"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_viewer_spans_override_nested_colors_for_readable_contrast() {
|
||||
let mut app = App::new();
|
||||
let document = fixture_document("otp/totp");
|
||||
let otp_field = document
|
||||
.fields()
|
||||
.iter()
|
||||
.find(|field| field.metadata().otp().is_some())
|
||||
.expect("OTP field")
|
||||
.id();
|
||||
app.open_test_document("otp/totp", document);
|
||||
while app
|
||||
.viewer()
|
||||
.and_then(EntryViewer::focused_field)
|
||||
.map(|field| field.id())
|
||||
!= Some(otp_field)
|
||||
{
|
||||
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(otp_field),
|
||||
code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()),
|
||||
validity: ironstorage::otp::OtpCodeValidity::Timed {
|
||||
valid_until: 90,
|
||||
period: 30,
|
||||
},
|
||||
observed_at: 60,
|
||||
clipboard: false,
|
||||
tree: None,
|
||||
}),
|
||||
});
|
||||
|
||||
let viewer = app.viewer().expect("viewer");
|
||||
let lines = viewer_lines(viewer, app.otp_display());
|
||||
let selected = &lines[viewer.focused_index().expect("focused field")];
|
||||
assert!(
|
||||
selected
|
||||
.spans
|
||||
.iter()
|
||||
.any(|span| span.content.contains("code 123456"))
|
||||
);
|
||||
assert!(selected.spans.iter().all(|span| {
|
||||
span.style.fg == Some(Color::White) && span.style.bg == Some(Color::Blue)
|
||||
}));
|
||||
|
||||
let editor = EntryEditor::new(fixture_document("email/personal"));
|
||||
let editor_selected = &editor_lines(&editor)[0];
|
||||
assert!(editor_selected.spans.iter().all(|span| {
|
||||
span.style.fg == Some(Color::White) && span.style.bg == Some(Color::Blue)
|
||||
}));
|
||||
|
||||
let backend = TestBackend::new(60, 16);
|
||||
let mut terminal = Terminal::new(backend).expect("test terminal");
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
draw_with_color_capability(frame, &app, ColorCapability::Monochrome);
|
||||
})
|
||||
.expect("monochrome viewer draw");
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert!(
|
||||
buffer
|
||||
.content
|
||||
.iter()
|
||||
.any(|cell| cell.modifier.contains(Modifier::REVERSED))
|
||||
);
|
||||
assert!(
|
||||
buffer
|
||||
.content
|
||||
.iter()
|
||||
.all(|cell| cell.fg == Color::Reset && cell.bg == Color::Reset)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_document_preserves_the_sidebar_selection() {
|
||||
let mut app = App::new();
|
||||
|
||||
@@ -433,17 +433,18 @@ pub struct OtpCodeOutcome {
|
||||
///
|
||||
/// Frontends use this value instead of deriving TOTP periods or inferring HOTP
|
||||
/// behavior from display strings. A timed code carries its exclusive Unix-time
|
||||
/// boundary, while a counter-based code identifies the committed HOTP counter.
|
||||
/// boundary and complete period for progress presentation, while a
|
||||
/// counter-based code identifies the committed HOTP counter.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum OtpCodeValidity {
|
||||
Timed { valid_until: u64 },
|
||||
Timed { valid_until: u64, period: u64 },
|
||||
CounterBased { counter: u64 },
|
||||
}
|
||||
|
||||
impl OtpCodeValidity {
|
||||
pub fn valid_until(self) -> Option<u64> {
|
||||
match self {
|
||||
Self::Timed { valid_until } => Some(valid_until),
|
||||
Self::Timed { valid_until, .. } => Some(valid_until),
|
||||
Self::CounterBased { .. } => None,
|
||||
}
|
||||
}
|
||||
@@ -455,6 +456,13 @@ impl OtpCodeValidity {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn period(self) -> Option<u64> {
|
||||
match self {
|
||||
Self::Timed { period, .. } => Some(period),
|
||||
Self::CounterBased { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remaining_at(self, unix_seconds: u64) -> Option<u64> {
|
||||
self.valid_until()
|
||||
.map(|valid_until| valid_until.saturating_sub(unix_seconds))
|
||||
@@ -695,6 +703,7 @@ impl<'a> OtpService<'a> {
|
||||
.checked_add(1)
|
||||
.and_then(|counter| counter.checked_mul(period))
|
||||
.ok_or(OtpError::CounterOverflow)?,
|
||||
period,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -743,18 +752,21 @@ impl<'a> OtpService<'a> {
|
||||
committer: &mut impl EntryCommitter,
|
||||
) -> Result<OtpCodeOutcome, OtpError> {
|
||||
match uri.kind() {
|
||||
OtpKind::Totp => Ok(OtpCodeOutcome {
|
||||
OtpKind::Totp => {
|
||||
let period = uri.period().ok_or(OtpError::NotTotp)?;
|
||||
Ok(OtpCodeOutcome {
|
||||
code: uri.code_at(unix_seconds)?,
|
||||
validity: OtpCodeValidity::Timed {
|
||||
valid_until: {
|
||||
let period = uri.period().ok_or(OtpError::NotTotp)?;
|
||||
(unix_seconds / period)
|
||||
.checked_add(1)
|
||||
.and_then(|counter| counter.checked_mul(period))
|
||||
.ok_or(OtpError::CounterOverflow)?
|
||||
},
|
||||
period,
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
OtpKind::Hotp => {
|
||||
let (counter, incremented) = uri.incremented_hotp()?;
|
||||
let code = incremented.code_for_counter(counter)?;
|
||||
|
||||
@@ -478,7 +478,14 @@ fn automatic_code_supports_pass_diff_config_and_opens_git_only_for_hotp() -> Tes
|
||||
let totp = service.code_automatic("otp/totp", 59, None, &mut provider)?;
|
||||
assert_eq!(totp.counter(), None);
|
||||
assert_eq!(totp.valid_until(), Some(60));
|
||||
assert_eq!(totp.validity(), OtpCodeValidity::Timed { valid_until: 60 });
|
||||
assert_eq!(
|
||||
totp.validity(),
|
||||
OtpCodeValidity::Timed {
|
||||
valid_until: 60,
|
||||
period: 30,
|
||||
}
|
||||
);
|
||||
assert_eq!(totp.validity().period(), Some(30));
|
||||
assert_eq!(totp.remaining_at(59), Some(1));
|
||||
assert_eq!(totp.remaining_at(60), Some(0));
|
||||
assert_eq!(repository.read_entry(&totp_path)?, totp_before);
|
||||
@@ -494,6 +501,7 @@ fn automatic_code_supports_pass_diff_config_and_opens_git_only_for_hotp() -> Tes
|
||||
OtpCodeValidity::CounterBased { counter: 1 }
|
||||
);
|
||||
assert_eq!(hotp.remaining_at(0), None);
|
||||
assert_eq!(hotp.validity().period(), None);
|
||||
assert_eq!(service.uri("otp/hotp", &mut provider)?.counter(), Some(1));
|
||||
let git = GitRepository::open(&repository, identity)?;
|
||||
assert_eq!(git.log(None)?.len(), initial_commits + 1);
|
||||
|
||||
15
docs/otp.md
15
docs/otp.md
@@ -50,13 +50,14 @@ supports terminal, clipboard, and the shared storage-owned QR matrix renderer.
|
||||
Clipboard and QR requests never print the underlying code or URI as plaintext.
|
||||
|
||||
Every generated code carries an `OtpCodeValidity` value from `crates/storage`.
|
||||
`Timed { valid_until }` identifies the exclusive Unix-time boundary for TOTP;
|
||||
frontends call its `remaining_at` method to present a countdown and request a
|
||||
replacement at zero. `CounterBased { counter }` identifies the HOTP counter
|
||||
whose increment was committed and must be described as counter-based rather
|
||||
than time-limited. Frontends must not recover periods from OTP URIs, decrement
|
||||
an assumed interval, or infer the kind from formatted text. This same contract
|
||||
is intended for the terminal, desktop, Apple, AutoFill, and watch interfaces.
|
||||
`Timed { valid_until, period }` identifies the exclusive Unix-time boundary and
|
||||
complete display interval for TOTP; frontends call its `remaining_at` method to
|
||||
present a countdown and request a replacement at zero. The `CounterBased`
|
||||
variant identifies the HOTP counter whose increment was committed and must be
|
||||
described as counter-based rather than time-limited. Frontends must not recover
|
||||
periods from OTP URIs, decrement an assumed interval, or infer the kind from
|
||||
formatted text. This same contract is intended for the terminal, desktop,
|
||||
Apple, AutoFill, and watch interfaces.
|
||||
|
||||
The CLI preserves code-only standard output for pass-compatible pipelines and
|
||||
reports the non-secret validity description on standard error. Clipboard
|
||||
|
||||
Reference in New Issue
Block a user