Implement the authenticated structured entry viewer
This commit is contained in:
@@ -5,12 +5,16 @@ use std::collections::BTreeSet;
|
||||
use ironstorage::{
|
||||
config::Config,
|
||||
crypto::KeyInfo,
|
||||
document::EntryDocument,
|
||||
presentation::ClipboardDisposition,
|
||||
read::{FindResults, TreeModel},
|
||||
repository::SecretBytes,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
action::Action,
|
||||
sidebar::{Sidebar, SidebarIntent},
|
||||
viewer::EntryViewer,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
@@ -56,14 +60,22 @@ pub struct StartupData {
|
||||
pub key: KeyInfo,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
#[derive(Debug)]
|
||||
pub enum AsyncPayload {
|
||||
Startup(Box<StartupData>),
|
||||
Refreshed(TreeModel),
|
||||
Filtered { query: String, results: FindResults },
|
||||
Filtered {
|
||||
query: String,
|
||||
results: FindResults,
|
||||
},
|
||||
DocumentLoaded {
|
||||
entry: String,
|
||||
document: Box<EntryDocument>,
|
||||
},
|
||||
ClipboardFinished(ClipboardDisposition),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
#[derive(Debug)]
|
||||
pub struct AsyncResult {
|
||||
pub token: RequestToken,
|
||||
pub payload: Result<AsyncPayload, String>,
|
||||
@@ -75,11 +87,12 @@ pub enum ResultDisposition {
|
||||
Stale,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
#[derive(Debug)]
|
||||
pub enum AppEffect {
|
||||
None,
|
||||
RefreshTree,
|
||||
AuthenticateEntry(String),
|
||||
CopyFocused(SecretBytes),
|
||||
ManualLock,
|
||||
}
|
||||
|
||||
@@ -93,6 +106,7 @@ pub struct App {
|
||||
default_key: Option<KeyInfo>,
|
||||
sidebar: Sidebar,
|
||||
selected_entry: Option<String>,
|
||||
viewer: Option<EntryViewer>,
|
||||
authentication_pending: Option<String>,
|
||||
remaining_lease: Option<std::time::Duration>,
|
||||
terminal_size: (u16, u16),
|
||||
@@ -120,6 +134,7 @@ impl App {
|
||||
default_key: None,
|
||||
sidebar: Sidebar::default(),
|
||||
selected_entry: None,
|
||||
viewer: None,
|
||||
authentication_pending: None,
|
||||
remaining_lease: None,
|
||||
terminal_size: (0, 0),
|
||||
@@ -159,6 +174,10 @@ impl App {
|
||||
self.selected_entry.as_deref()
|
||||
}
|
||||
|
||||
pub fn viewer(&self) -> Option<&EntryViewer> {
|
||||
self.viewer.as_ref()
|
||||
}
|
||||
|
||||
pub fn default_key(&self) -> Option<&KeyInfo> {
|
||||
self.default_key.as_ref()
|
||||
}
|
||||
@@ -239,7 +258,34 @@ impl App {
|
||||
}
|
||||
self.status = format!("Filter: {query} ({} matches)", results.matches().len());
|
||||
}
|
||||
Err(error) => self.status = error,
|
||||
Ok(AsyncPayload::DocumentLoaded { entry, document }) => {
|
||||
if self.mode != Mode::Viewer || self.selected_entry.as_deref() != Some(&entry) {
|
||||
return ResultDisposition::Stale;
|
||||
}
|
||||
let field_count = document.fields().len();
|
||||
self.viewer = Some(EntryViewer::new(*document));
|
||||
self.focus = PaneFocus::Main;
|
||||
self.status = format!("Opened {entry} ({field_count} fields)");
|
||||
}
|
||||
Ok(AsyncPayload::ClipboardFinished(disposition)) => {
|
||||
self.status = match disposition {
|
||||
ClipboardDisposition::RestoredPrevious => {
|
||||
"Clipboard restored to its previous value".to_owned()
|
||||
}
|
||||
ClipboardDisposition::Cleared => "Clipboard secret cleared".to_owned(),
|
||||
ClipboardDisposition::PreservedNewer => {
|
||||
"Clipboard changed; the newer value was preserved".to_owned()
|
||||
}
|
||||
};
|
||||
}
|
||||
Err(error) => {
|
||||
self.status = error;
|
||||
if self.mode == Mode::Viewer && self.viewer.is_none() {
|
||||
self.mode = Mode::Browser;
|
||||
self.focus = PaneFocus::Sidebar;
|
||||
self.selected_entry = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
ResultDisposition::Applied
|
||||
}
|
||||
@@ -295,12 +341,68 @@ impl App {
|
||||
Action::Filter => self.sidebar.begin_filter(),
|
||||
Action::NextMatch => self.sidebar.next_match(),
|
||||
Action::PreviousMatch => self.sidebar.previous_match(),
|
||||
Action::FocusNext if self.mode == Mode::Viewer => {
|
||||
if let Some(viewer) = self.viewer.as_mut() {
|
||||
viewer.focus_next();
|
||||
}
|
||||
}
|
||||
Action::FocusPrevious if self.mode == Mode::Viewer => {
|
||||
if let Some(viewer) = self.viewer.as_mut() {
|
||||
viewer.focus_previous();
|
||||
}
|
||||
}
|
||||
Action::FocusNext | Action::FocusPrevious => {
|
||||
self.focus = match self.focus {
|
||||
PaneFocus::Sidebar => PaneFocus::Main,
|
||||
PaneFocus::Main => PaneFocus::Sidebar,
|
||||
};
|
||||
}
|
||||
Action::Reveal => {
|
||||
if self
|
||||
.viewer
|
||||
.as_mut()
|
||||
.is_some_and(EntryViewer::reveal_focused)
|
||||
{
|
||||
self.status = "Focused sensitive field revealed".to_owned();
|
||||
}
|
||||
}
|
||||
Action::Hide => {
|
||||
if self.viewer.as_mut().is_some_and(EntryViewer::hide_revealed) {
|
||||
self.status = "Sensitive field hidden".to_owned();
|
||||
}
|
||||
}
|
||||
Action::Copy => {
|
||||
if let Some(viewer) = self.viewer.as_ref() {
|
||||
match viewer.copy_focused() {
|
||||
Ok(value) => {
|
||||
self.status = self.config.as_ref().map_or_else(
|
||||
|| "Copying focused field…".to_owned(),
|
||||
|config| {
|
||||
format!(
|
||||
"Copied focused field; cleanup in {}s",
|
||||
config.clipboard_timeout().duration().as_secs()
|
||||
)
|
||||
},
|
||||
);
|
||||
return AppEffect::CopyFocused(value);
|
||||
}
|
||||
Err(error) => self.status = error.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Action::ScrollDown => {
|
||||
if let Some(viewer) = self.viewer.as_mut() {
|
||||
viewer.scroll_down(5);
|
||||
}
|
||||
}
|
||||
Action::ScrollUp => {
|
||||
if let Some(viewer) = self.viewer.as_mut() {
|
||||
viewer.scroll_up(5);
|
||||
}
|
||||
}
|
||||
Action::CloseEntry => {
|
||||
self.transition(Transition::CloseEntry);
|
||||
}
|
||||
Action::Quit => {}
|
||||
}
|
||||
AppEffect::None
|
||||
@@ -312,13 +414,19 @@ impl App {
|
||||
}
|
||||
self.authentication_pending = None;
|
||||
self.selected_entry = Some(entry);
|
||||
self.status = "Authenticated".to_owned();
|
||||
self.transition(Transition::OpenEntry)
|
||||
self.viewer = None;
|
||||
self.status = "Authenticated; loading structured entry…".to_owned();
|
||||
let transitioned = self.transition(Transition::OpenEntry);
|
||||
if transitioned {
|
||||
self.focus = PaneFocus::Main;
|
||||
}
|
||||
transitioned
|
||||
}
|
||||
|
||||
pub fn authentication_failed(&mut self, message: String) {
|
||||
self.authentication_pending = None;
|
||||
self.selected_entry = None;
|
||||
self.viewer = None;
|
||||
self.status = message;
|
||||
if self.mode != Mode::Browser {
|
||||
self.mode = Mode::Browser;
|
||||
@@ -371,16 +479,22 @@ impl App {
|
||||
return false;
|
||||
};
|
||||
if matches!(destination, Mode::Dialog | Mode::Help | Mode::Command) {
|
||||
if let Some(viewer) = self.viewer.as_mut() {
|
||||
viewer.hide_revealed();
|
||||
}
|
||||
self.suspended_mode = Some(current);
|
||||
} else if destination == Mode::Browser && matches!(current, Mode::Viewer | Mode::Editor) {
|
||||
self.selected_entry = None;
|
||||
self.viewer = None;
|
||||
self.authentication_pending = None;
|
||||
self.remaining_lease = None;
|
||||
self.focus = PaneFocus::Sidebar;
|
||||
} else if destination == Mode::Locked {
|
||||
self.suspended_mode = None;
|
||||
self.focus = PaneFocus::Sidebar;
|
||||
self.invalidate_requests();
|
||||
self.selected_entry = None;
|
||||
self.viewer = None;
|
||||
self.status = "Locked".to_owned();
|
||||
} else if current == Mode::Locked {
|
||||
self.status = "Authentication required".to_owned();
|
||||
@@ -393,6 +507,15 @@ impl App {
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
self.pending.clear();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn open_test_document(&mut self, entry: &str, document: EntryDocument) {
|
||||
self.mode = Mode::Viewer;
|
||||
self.focus = PaneFocus::Main;
|
||||
self.selected_entry = Some(entry.to_owned());
|
||||
self.viewer = Some(EntryViewer::new(document));
|
||||
self.status = format!("Opened {entry}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -452,8 +575,14 @@ mod tests {
|
||||
token,
|
||||
payload: Err("completed".to_owned()),
|
||||
};
|
||||
assert_eq!(app.apply_result(result.clone()), ResultDisposition::Applied);
|
||||
assert_eq!(app.apply_result(result), ResultDisposition::Stale);
|
||||
assert_eq!(app.apply_result(result), ResultDisposition::Applied);
|
||||
assert_eq!(
|
||||
app.apply_result(AsyncResult {
|
||||
token,
|
||||
payload: Err("duplicate".to_owned()),
|
||||
}),
|
||||
ResultDisposition::Stale
|
||||
);
|
||||
|
||||
let stale = app.begin_request();
|
||||
assert!(app.transition(Transition::Lock));
|
||||
|
||||
Reference in New Issue
Block a user