Add additive KDBX importer
This commit is contained in:
@@ -60,6 +60,7 @@ pub enum UiAction {
|
||||
GenerateOtp,
|
||||
CopyOtp,
|
||||
ImportOtp,
|
||||
ImportKdbx,
|
||||
ShowOtpUri,
|
||||
CopyOtpUri,
|
||||
ShowOtpQr,
|
||||
@@ -106,6 +107,7 @@ impl UiAction {
|
||||
Self::GenerateOtp => "generate-otp",
|
||||
Self::CopyOtp => "copy-otp",
|
||||
Self::ImportOtp => "import-otp",
|
||||
Self::ImportKdbx => "import-kdbx",
|
||||
Self::ShowOtpUri => "show-otp-uri",
|
||||
Self::CopyOtpUri => "copy-otp-uri",
|
||||
Self::ShowOtpQr => "show-otp-qr",
|
||||
@@ -302,6 +304,12 @@ pub const ACTIONS: &[ActionSpec] = &[
|
||||
spec(UiAction::ShowOtpQr, MenuGroup::Entry, "Show OTP QR", None),
|
||||
spec(UiAction::RemoveOtp, MenuGroup::Entry, "Remove OTP", None),
|
||||
spec(UiAction::GitStatus, MenuGroup::Tools, "Git Status…", None),
|
||||
spec(
|
||||
UiAction::ImportKdbx,
|
||||
MenuGroup::Tools,
|
||||
"Import KeePass Database…",
|
||||
None,
|
||||
),
|
||||
spec(UiAction::GitPull, MenuGroup::Tools, "Pull", None),
|
||||
spec(UiAction::GitPush, MenuGroup::Tools, "Push", None),
|
||||
spec(UiAction::GitSync, MenuGroup::Tools, "Synchronize", None),
|
||||
@@ -454,7 +462,7 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool {
|
||||
&& !context.switching_vault
|
||||
&& !context.modal_open
|
||||
}
|
||||
UiAction::ImportOtp => {
|
||||
UiAction::ImportOtp | UiAction::ImportKdbx => {
|
||||
context.storage_ready
|
||||
&& context.unlocked
|
||||
&& !context.dirty
|
||||
@@ -623,12 +631,20 @@ pub fn disabled_reason(action: UiAction, context: ActionContext) -> Option<&'sta
|
||||
| UiAction::CopyOtpUri
|
||||
| UiAction::ShowOtpQr
|
||||
| UiAction::RemoveOtp => "Close the current screen first",
|
||||
UiAction::ImportOtp if !context.storage_ready => "Shared configuration is unavailable",
|
||||
UiAction::ImportOtp if !context.unlocked => "Unlock the password store first",
|
||||
UiAction::ImportOtp if context.dirty => "Save or discard the current draft first",
|
||||
UiAction::ImportOtp if context.saving => "Wait for the active save",
|
||||
UiAction::ImportOtp if context.switching_vault => "Wait for vault validation",
|
||||
UiAction::ImportOtp => "Close the current screen first",
|
||||
UiAction::ImportOtp | UiAction::ImportKdbx if !context.storage_ready => {
|
||||
"Shared configuration is unavailable"
|
||||
}
|
||||
UiAction::ImportOtp | UiAction::ImportKdbx if !context.unlocked => {
|
||||
"Unlock the password store first"
|
||||
}
|
||||
UiAction::ImportOtp | UiAction::ImportKdbx if context.dirty => {
|
||||
"Save or discard the current draft first"
|
||||
}
|
||||
UiAction::ImportOtp | UiAction::ImportKdbx if context.saving => "Wait for the active save",
|
||||
UiAction::ImportOtp | UiAction::ImportKdbx if context.switching_vault => {
|
||||
"Wait for vault validation"
|
||||
}
|
||||
UiAction::ImportOtp | UiAction::ImportKdbx => "Close the current screen first",
|
||||
UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync
|
||||
if !context.storage_ready =>
|
||||
{
|
||||
@@ -695,6 +711,7 @@ pub const fn aliases(action: UiAction) -> &'static [&'static str] {
|
||||
UiAction::GenerateOtp => &["totp", "hotp", "one time password"],
|
||||
UiAction::CopyOtp => &["copy totp", "copy hotp", "otp clipboard"],
|
||||
UiAction::ImportOtp => &["add otp", "scan qr", "import otpauth"],
|
||||
UiAction::ImportKdbx => &["keepass", "kdbx", "import database"],
|
||||
UiAction::ShowOtpUri => &["show otpauth", "otp secret"],
|
||||
UiAction::CopyOtpUri => &["copy otpauth", "copy otp secret"],
|
||||
UiAction::ShowOtpQr => &["otp qr", "export otp"],
|
||||
|
||||
@@ -32,6 +32,30 @@ pub async fn pick_qr_image() -> Result<Option<SecretBytes>, String> {
|
||||
.transpose()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
pub async fn pick_kdbx_file() -> Result<Option<PathBuf>, String> {
|
||||
pick_file("Import KeePass Database", "KeePass database", &["kdbx"]).await
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
pub async fn pick_key_file() -> Result<Option<PathBuf>, String> {
|
||||
pick_file("Select KeePass Key File", "Key file", &["key", "keyx"]).await
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
async fn pick_file(
|
||||
title: &str,
|
||||
filter_name: &str,
|
||||
extensions: &[&str],
|
||||
) -> Result<Option<PathBuf>, String> {
|
||||
Ok(rfd::AsyncFileDialog::new()
|
||||
.set_title(title)
|
||||
.add_filter(filter_name, extensions)
|
||||
.pick_file()
|
||||
.await
|
||||
.map(|file| file.path().to_owned()))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub async fn pick_folder(initial: Option<PathBuf>) -> Result<Option<PathBuf>, String> {
|
||||
use ashpd::{
|
||||
@@ -96,6 +120,45 @@ pub async fn pick_qr_image() -> Result<Option<SecretBytes>, String> {
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub async fn pick_kdbx_file() -> Result<Option<PathBuf>, String> {
|
||||
pick_file("Import KeePass Database", "Import").await
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub async fn pick_key_file() -> Result<Option<PathBuf>, String> {
|
||||
pick_file("Select KeePass Key File", "Select").await
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn pick_file(title: &str, accept_label: &str) -> Result<Option<PathBuf>, String> {
|
||||
use ashpd::{
|
||||
PortalError,
|
||||
desktop::{file_chooser::SelectedFiles, request::ResponseError},
|
||||
};
|
||||
|
||||
let response = SelectedFiles::open_file()
|
||||
.title(title)
|
||||
.accept_label(accept_label)
|
||||
.modal(true)
|
||||
.multiple(false)
|
||||
.send()
|
||||
.await
|
||||
.and_then(|request| request.response());
|
||||
let selected = match response {
|
||||
Ok(selected) => selected,
|
||||
Err(ashpd::Error::Response(ResponseError::Cancelled))
|
||||
| Err(ashpd::Error::Portal(PortalError::Cancelled(_))) => return Ok(None),
|
||||
Err(error) => return Err(error.to_string()),
|
||||
};
|
||||
selected
|
||||
.uris()
|
||||
.first()
|
||||
.ok_or_else(|| "file portal returned no selection".to_owned())
|
||||
.and_then(|uri| file_uri_path(uri.as_str()))
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn file_uri_path(uri: &str) -> Result<PathBuf, String> {
|
||||
let uri = url::Url::parse(uri).map_err(|_| "folder portal returned an invalid URI")?;
|
||||
|
||||
@@ -52,6 +52,7 @@ use ironstorage::{
|
||||
GitConflict, GitConflictChoice, GitConflictResolution, GitError, GitOperationControl,
|
||||
GitProgressPhase, GitSnapshot,
|
||||
},
|
||||
kdbx::{KdbxImportMode, KdbxImportRequest},
|
||||
mutation::{MutationAction, MutationOutcome, MutationSelection},
|
||||
otp::{OtpCodeValidity, OtpKind},
|
||||
presentation::{ClipboardWait, NativeClipboardManager, QrMatrix},
|
||||
@@ -184,6 +185,20 @@ enum Message {
|
||||
generation: u64,
|
||||
completion: OtpCompletion,
|
||||
},
|
||||
KdbxSourceChanged(String),
|
||||
KdbxKeyFileChanged(String),
|
||||
KdbxPasswordChanged(Zeroizing<String>),
|
||||
PickKdbxSource,
|
||||
KdbxSourcePicked(Result<Option<PathBuf>, String>),
|
||||
PickKdbxKeyFile,
|
||||
KdbxKeyFilePicked(Result<Option<PathBuf>, String>),
|
||||
ToggleKdbxQuickAdd,
|
||||
ToggleKdbxConfirmation,
|
||||
SubmitKdbxImport,
|
||||
KdbxFinished {
|
||||
generation: u64,
|
||||
result: Box<Result<(ironstorage::kdbx::KdbxImportOutcome, TreeModel), String>>,
|
||||
},
|
||||
#[cfg(target_os = "macos")]
|
||||
PollNativeMenu,
|
||||
StartupLoaded(Box<Result<(DesktopStorage, NativeAuthenticationSession, KeyInfo), String>>),
|
||||
@@ -374,6 +389,7 @@ enum UtilityView {
|
||||
Mutation(MutationForm),
|
||||
Git(GitForm),
|
||||
Otp(OtpForm),
|
||||
Kdbx(KdbxForm),
|
||||
Help,
|
||||
}
|
||||
|
||||
@@ -402,6 +418,72 @@ struct OtpForm {
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
struct KdbxForm {
|
||||
source: String,
|
||||
key_file: String,
|
||||
password: Zeroizing<String>,
|
||||
quick_add: bool,
|
||||
confirmed: bool,
|
||||
running: bool,
|
||||
error: Option<String>,
|
||||
summary: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for KdbxForm {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("KdbxForm")
|
||||
.field("source", &self.source)
|
||||
.field("key_file", &self.key_file)
|
||||
.field("password", &"[REDACTED]")
|
||||
.field("quick_add", &self.quick_add)
|
||||
.field("confirmed", &self.confirmed)
|
||||
.field("running", &self.running)
|
||||
.field("error", &self.error)
|
||||
.field("summary", &self.summary)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for KdbxForm {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
source: String::new(),
|
||||
key_file: String::new(),
|
||||
password: Zeroizing::new(String::new()),
|
||||
quick_add: false,
|
||||
confirmed: false,
|
||||
running: false,
|
||||
error: None,
|
||||
summary: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KdbxForm {
|
||||
fn request(&self) -> Result<(KdbxImportRequest, SecretBytes), String> {
|
||||
if self.source.trim().is_empty() {
|
||||
return Err("Choose a KDBX database file.".to_owned());
|
||||
}
|
||||
if !self.confirmed {
|
||||
return Err("Confirm the additive import before continuing.".to_owned());
|
||||
}
|
||||
Ok((
|
||||
KdbxImportRequest::new(
|
||||
self.source.trim(),
|
||||
(!self.key_file.trim().is_empty()).then(|| self.key_file.trim().into()),
|
||||
if self.quick_add {
|
||||
KdbxImportMode::QuickAdd
|
||||
} else {
|
||||
KdbxImportMode::AddAndUpdate
|
||||
},
|
||||
),
|
||||
SecretBytes::new(self.password.as_bytes().to_vec()),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl OtpForm {
|
||||
fn new(entry: String) -> Self {
|
||||
Self {
|
||||
@@ -837,6 +919,7 @@ enum PendingAction {
|
||||
SearchContents(GrepRequest),
|
||||
Mutate(MutationForm),
|
||||
Git(DesktopGitRequest),
|
||||
ImportKdbx(KdbxForm),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -972,6 +1055,7 @@ impl App {
|
||||
|| matches!(utility, UtilityView::Mutation(form) if form.running)
|
||||
|| matches!(utility, UtilityView::Git(form) if form.running)
|
||||
|| matches!(utility, UtilityView::Otp(form) if form.running)
|
||||
|| matches!(utility, UtilityView::Kdbx(form) if form.running)
|
||||
}) {
|
||||
self.status = "Wait for the active workflow to finish…".to_owned();
|
||||
} else {
|
||||
@@ -1867,6 +1951,130 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::KdbxSourceChanged(source) => {
|
||||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility
|
||||
&& !form.running
|
||||
{
|
||||
form.source = source;
|
||||
form.error = None;
|
||||
form.summary = None;
|
||||
}
|
||||
}
|
||||
Message::KdbxKeyFileChanged(key_file) => {
|
||||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility
|
||||
&& !form.running
|
||||
{
|
||||
form.key_file = key_file;
|
||||
form.error = None;
|
||||
form.summary = None;
|
||||
}
|
||||
}
|
||||
Message::KdbxPasswordChanged(password) => {
|
||||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility
|
||||
&& !form.running
|
||||
{
|
||||
form.password = password;
|
||||
form.error = None;
|
||||
form.summary = None;
|
||||
}
|
||||
}
|
||||
Message::PickKdbxSource => {
|
||||
return Task::perform(folder_picker::pick_kdbx_file(), Message::KdbxSourcePicked);
|
||||
}
|
||||
Message::KdbxSourcePicked(result) => match result {
|
||||
Ok(Some(path)) => {
|
||||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility {
|
||||
form.source = path.display().to_string();
|
||||
form.error = None;
|
||||
form.summary = None;
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility {
|
||||
form.error = Some(error);
|
||||
}
|
||||
}
|
||||
},
|
||||
Message::PickKdbxKeyFile => {
|
||||
return Task::perform(folder_picker::pick_key_file(), Message::KdbxKeyFilePicked);
|
||||
}
|
||||
Message::KdbxKeyFilePicked(result) => match result {
|
||||
Ok(Some(path)) => {
|
||||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility {
|
||||
form.key_file = path.display().to_string();
|
||||
form.error = None;
|
||||
form.summary = None;
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility {
|
||||
form.error = Some(error);
|
||||
}
|
||||
}
|
||||
},
|
||||
Message::ToggleKdbxQuickAdd => {
|
||||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility
|
||||
&& !form.running
|
||||
{
|
||||
form.quick_add = !form.quick_add;
|
||||
form.summary = None;
|
||||
}
|
||||
}
|
||||
Message::ToggleKdbxConfirmation => {
|
||||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility
|
||||
&& !form.running
|
||||
{
|
||||
form.confirmed = !form.confirmed;
|
||||
}
|
||||
}
|
||||
Message::SubmitKdbxImport => {
|
||||
let Some(UtilityView::Kdbx(form)) = &self.utility else {
|
||||
return Task::none();
|
||||
};
|
||||
if form.running {
|
||||
return Task::none();
|
||||
}
|
||||
if self.handle.is_none() {
|
||||
self.after_authentication = Some(PendingAction::ImportKdbx(form.clone()));
|
||||
return self.begin_authentication();
|
||||
}
|
||||
return self.begin_kdbx_import(form.clone());
|
||||
}
|
||||
Message::KdbxFinished { generation, result } => {
|
||||
if generation != self.workflow_generation {
|
||||
return Task::none();
|
||||
}
|
||||
match *result {
|
||||
Ok((outcome, tree)) => {
|
||||
self.navigation.replace(&tree);
|
||||
self.tree_state = tree_state_from_result(Ok(self.navigation.is_empty()));
|
||||
let summary = format!(
|
||||
"KDBX import: {} added, {} updated, {} unchanged, {} skipped.",
|
||||
outcome.added(),
|
||||
outcome.updated(),
|
||||
outcome.unchanged(),
|
||||
outcome.skipped()
|
||||
);
|
||||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility {
|
||||
form.running = false;
|
||||
form.error = None;
|
||||
form.summary = Some(summary.clone());
|
||||
form.password = Zeroizing::new(String::new());
|
||||
form.confirmed = false;
|
||||
}
|
||||
self.status = summary;
|
||||
}
|
||||
Err(error) => {
|
||||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility {
|
||||
form.running = false;
|
||||
form.error = Some(error.clone());
|
||||
}
|
||||
self.status = format!("KDBX import failed: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
Message::PollNativeMenu => {
|
||||
if self.native_menu.is_none() {
|
||||
@@ -2419,6 +2627,9 @@ impl App {
|
||||
self.entry_path.trim().to_owned(),
|
||||
)));
|
||||
}
|
||||
UiAction::ImportKdbx => {
|
||||
self.utility = Some(UtilityView::Kdbx(KdbxForm::default()));
|
||||
}
|
||||
UiAction::GenerateOtp
|
||||
| UiAction::CopyOtp
|
||||
| UiAction::ShowOtpUri
|
||||
@@ -2632,9 +2843,47 @@ impl App {
|
||||
self.begin_authentication()
|
||||
}
|
||||
PendingAction::Git(request) => self.begin_git(request),
|
||||
PendingAction::ImportKdbx(form) if self.handle.is_none() => {
|
||||
self.after_authentication = Some(PendingAction::ImportKdbx(form));
|
||||
self.begin_authentication()
|
||||
}
|
||||
PendingAction::ImportKdbx(form) => self.begin_kdbx_import(form),
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_kdbx_import(&mut self, form: KdbxForm) -> Task<Message> {
|
||||
let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else {
|
||||
return Task::none();
|
||||
};
|
||||
let (request, password) = match form.request() {
|
||||
Ok(request) => request,
|
||||
Err(error) => {
|
||||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility {
|
||||
form.error = Some(error);
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
self.workflow_generation = self.workflow_generation.wrapping_add(1);
|
||||
let generation = self.workflow_generation;
|
||||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility {
|
||||
form.running = true;
|
||||
form.error = None;
|
||||
form.summary = None;
|
||||
}
|
||||
self.status = "Importing KeePass database through crates/storage…".to_owned();
|
||||
Task::perform(
|
||||
async move {
|
||||
Box::new(
|
||||
storage
|
||||
.import_kdbx_active(&handle, &request, password)
|
||||
.map_err(|error| error.to_string()),
|
||||
)
|
||||
},
|
||||
move |result| Message::KdbxFinished { generation, result },
|
||||
)
|
||||
}
|
||||
|
||||
fn update_search_form(&mut self, update: impl FnOnce(&mut SearchForm)) {
|
||||
if let Some(UtilityView::Search(form)) = &mut self.utility
|
||||
&& !form.running
|
||||
@@ -3307,6 +3556,12 @@ impl App {
|
||||
form.uri.clear();
|
||||
form.error = Some(reason.clone());
|
||||
}
|
||||
Some(UtilityView::Kdbx(form)) => {
|
||||
form.running = false;
|
||||
form.password = Zeroizing::new(String::new());
|
||||
form.confirmed = false;
|
||||
form.error = Some(reason.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.authentication = AuthenticationView::Locked;
|
||||
@@ -4444,6 +4699,66 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
|
||||
content = content.push(text(format!("OTP error: {error}")));
|
||||
}
|
||||
}
|
||||
UtilityView::Kdbx(form) => {
|
||||
content = content
|
||||
.push(text("Import KeePass Database").size(28))
|
||||
.push(text(
|
||||
"The import is additive: full mode adds new entries and updates changed entries; quick-add mode only adds entries that do not exist. Nothing is deleted.",
|
||||
))
|
||||
.push(text("KDBX database"))
|
||||
.push(
|
||||
row![
|
||||
text_input("Database.kdbx", &form.source)
|
||||
.on_input(Message::KdbxSourceChanged)
|
||||
.on_submit(Message::SubmitKdbxImport),
|
||||
if form.running {
|
||||
button("Choose…")
|
||||
} else {
|
||||
button("Choose…").on_press(Message::PickKdbxSource)
|
||||
},
|
||||
]
|
||||
.spacing(8),
|
||||
)
|
||||
.push(text("Optional KeePass key file"))
|
||||
.push(
|
||||
row![
|
||||
text_input("No key file", &form.key_file)
|
||||
.on_input(Message::KdbxKeyFileChanged)
|
||||
.on_submit(Message::SubmitKdbxImport),
|
||||
if form.running {
|
||||
button("Choose…")
|
||||
} else {
|
||||
button("Choose…").on_press(Message::PickKdbxKeyFile)
|
||||
},
|
||||
]
|
||||
.spacing(8),
|
||||
)
|
||||
.push(text("Database password"))
|
||||
.push(
|
||||
text_input("Password", &form.password)
|
||||
.secure(true)
|
||||
.on_input(|value| Message::KdbxPasswordChanged(Zeroizing::new(value)))
|
||||
.on_submit(Message::SubmitKdbxImport),
|
||||
)
|
||||
.push(search_option(
|
||||
"Quick add: only add entries not already present",
|
||||
form.quick_add,
|
||||
Message::ToggleKdbxQuickAdd,
|
||||
form.running,
|
||||
))
|
||||
.push(search_option(
|
||||
"Confirm additive import and per-entry Git commits",
|
||||
form.confirmed,
|
||||
Message::ToggleKdbxConfirmation,
|
||||
form.running,
|
||||
));
|
||||
if let Some(summary) = &form.summary {
|
||||
content = content.push(text(summary));
|
||||
}
|
||||
if let Some(error) = &form.error {
|
||||
content = content.push(text(format!("KDBX import error: {error}")));
|
||||
}
|
||||
}
|
||||
UtilityView::Help => {
|
||||
content = content
|
||||
.push(text("IronStorage Help").size(28))
|
||||
@@ -4487,7 +4802,8 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
|
||||
|| matches!(utility, UtilityView::Search(form) if form.running)
|
||||
|| matches!(utility, UtilityView::Mutation(form) if form.running)
|
||||
|| matches!(utility, UtilityView::Git(form) if form.running)
|
||||
|| matches!(utility, UtilityView::Otp(form) if form.running);
|
||||
|| matches!(utility, UtilityView::Otp(form) if form.running)
|
||||
|| matches!(utility, UtilityView::Kdbx(form) if form.running);
|
||||
let done = if busy {
|
||||
done
|
||||
} else {
|
||||
@@ -4684,6 +5000,24 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
|
||||
.push(done)
|
||||
}
|
||||
}
|
||||
UtilityView::Kdbx(form) => {
|
||||
let import = button(if form.running {
|
||||
"Importing…"
|
||||
} else if form.quick_add {
|
||||
"Quick Add"
|
||||
} else {
|
||||
"Import"
|
||||
});
|
||||
row![
|
||||
if form.running {
|
||||
import
|
||||
} else {
|
||||
import.on_press(Message::SubmitKdbxImport)
|
||||
},
|
||||
done,
|
||||
]
|
||||
.spacing(8)
|
||||
}
|
||||
UtilityView::About | UtilityView::Help => row![done],
|
||||
};
|
||||
container(
|
||||
@@ -5337,6 +5671,7 @@ fn confirmation_view(action: &PendingAction) -> Element<'_, Message> {
|
||||
form.source.path().display()
|
||||
),
|
||||
PendingAction::Git(request) => format!("Git {}", git_request_name(request)),
|
||||
PendingAction::ImportKdbx(form) => format!("Import {}", form.source),
|
||||
};
|
||||
container(
|
||||
column![
|
||||
@@ -7161,4 +7496,39 @@ mod tests {
|
||||
}))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kdbx_form_builds_a_confirmed_storage_request_and_is_cleared_on_lock() {
|
||||
let mut form = KdbxForm {
|
||||
source: "/tmp/passwords.kdbx".to_owned(),
|
||||
key_file: "/tmp/passwords.key".to_owned(),
|
||||
password: Zeroizing::new("database password".to_owned()),
|
||||
quick_add: true,
|
||||
confirmed: true,
|
||||
..KdbxForm::default()
|
||||
};
|
||||
let (request, password) = form.request().expect("confirmed request");
|
||||
assert_eq!(
|
||||
request.source(),
|
||||
std::path::Path::new("/tmp/passwords.kdbx")
|
||||
);
|
||||
assert_eq!(
|
||||
request.key_file(),
|
||||
Some(std::path::Path::new("/tmp/passwords.key"))
|
||||
);
|
||||
assert_eq!(request.mode(), KdbxImportMode::QuickAdd);
|
||||
assert_eq!(password.expose(), b"database password");
|
||||
assert!(!format!("{form:?}").contains("database password"));
|
||||
|
||||
form.running = true;
|
||||
let mut app = App::new().0;
|
||||
app.utility = Some(UtilityView::Kdbx(form));
|
||||
app.authentication_lost("locked".to_owned());
|
||||
let Some(UtilityView::Kdbx(form)) = app.utility else {
|
||||
panic!("expected KDBX form");
|
||||
};
|
||||
assert!(form.password.is_empty());
|
||||
assert!(!form.confirmed);
|
||||
assert!(!form.running);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,7 @@ fn accelerator(action: UiAction) -> Option<Accelerator> {
|
||||
| UiAction::GenerateOtp
|
||||
| UiAction::CopyOtp
|
||||
| UiAction::ImportOtp
|
||||
| UiAction::ImportKdbx
|
||||
| UiAction::ShowOtpUri
|
||||
| UiAction::CopyOtpUri
|
||||
| UiAction::ShowOtpQr
|
||||
|
||||
Reference in New Issue
Block a user