feat(settings): redesign the Import Key dialog

Rework the full-node Import Key dialog into a safer, Material-consistent, more
capable flow:

- Security: the key is masked by default (Password) with a reveal (eye) toggle,
  a DialogWarningHeader "only import a key you own" note, and the buffer is
  wiped with sodium_memzero on close and on successful import.
- Progress: import triggers a blocking full-chain rescan; the dialog now shows
  "Importing & rescanning — this can take several minutes" with LoadingDots and
  disables Import while it runs (no double-submit). Offline shows a "connect a
  running node" note and disables Import up front.
- Viewing keys: the classifier gains isViewingKey / isRecognizedImportKey;
  importPrivateKey auto-detects a shielded viewing key (zxviews…) and routes to
  z_importviewingkey (watch-only) vs z_importkey / importprivkey. The live type
  indicator shows Transparent / Shielded spending / Shielded viewing (watch-only).
- Result: on success the imported address is captured from the RPC result and
  shown via AddressCopyField.
- Material restyle (OverlayDialogSpec/BlurFloat + TactileButton + Esc) and full
  i18n (12 new keys x 8 languages; CJK subset rebuilt).

Verified: rendered on dark + light skins; a seeded zxviews… key shows the masked
field + "Shielded viewing key (watch-only)" indicator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 12:58:36 -05:00
parent 8293f02e36
commit 46f3001360
15 changed files with 274 additions and 88 deletions

View File

@@ -66,6 +66,7 @@
#include "ui/material/type.h"
#include "ui/material/typography.h"
#include "ui/material/draw_helpers.h"
#include "ui/widgets/copy_field.h"
#include "ui/notifications.h"
#include "util/i18n.h"
#include "util/platform.h"
@@ -2755,106 +2756,151 @@ void App::renderLiteUnlockPrompt()
void App::renderImportKeyDialog()
{
auto dlg = ui::schema::UI().drawElement("inline-dialogs", "import-key");
auto dlgF = [&](const char* key, float fb) -> float {
auto it = dlg.extraFloats.find(key);
return it != dlg.extraFloats.end() ? it->second : fb;
};
int btnFont = (int)dlgF("button-font", 1);
float btnW = dlgF("button-width", 120.0f);
if (!ui::material::BeginOverlayDialog("Import Private Key", &show_import_key_, dlgF("width", 500.0f), 0.94f)) {
return;
namespace m = ui::material;
float dp = ui::Layout::dpiScale();
m::OverlayDialogSpec ov;
ov.title = TR("import_key_title");
ov.p_open = &show_import_key_;
ov.style = m::OverlayStyle::BlurFloat;
ov.cardWidth = 520.0f;
ov.idSuffix = "importkey";
if (!m::BeginOverlayDialog(ov)) return;
// Fresh dialog on each open (the key buffer was already wiped on the last close).
if (ImGui::IsWindowAppearing()) {
import_success_ = false;
import_status_.clear();
import_result_address_.clear();
import_key_reveal_ = false;
}
ImGui::TextWrapped("Enter a private key to import. The wallet will rescan the blockchain for transactions.");
// Esc dismisses (this overlay has no built-in Esc handling).
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) show_import_key_ = false;
// Handle-with-care note.
m::DialogWarningHeader(TR("import_key_warn"));
ImGui::Spacing();
ImGui::Text("Private Key:");
ImGui::SetNextItemWidth(-1);
ImGui::InputText("##importkey", import_key_input_, sizeof(import_key_input_));
// Paste & Clear buttons
if (ui::material::StyledButton(TR("paste"), ImVec2(0, 0), ui::material::resolveButtonFont(btnFont))) {
// A running node is required — say so up front instead of failing only after Import.
if (!state_.connected) {
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("import_key_need_node"));
ImGui::Spacing();
}
// Masked key field with a reveal (eye) toggle + Paste/Clear.
ImGui::TextUnformatted(TR("import_key_field"));
float eyeW = ImGui::GetFrameHeight();
ImGui::SetNextItemWidth(-(eyeW + ui::Layout::spacingSm()));
ImGuiInputTextFlags flags = import_key_reveal_ ? 0 : ImGuiInputTextFlags_Password;
ImGui::InputText("##importkey", import_key_input_, sizeof(import_key_input_), flags);
ImGui::SameLine(0, ui::Layout::spacingSm());
if (m::TactileButton(import_key_reveal_ ? ICON_MD_VISIBILITY_OFF : ICON_MD_VISIBILITY,
ImVec2(eyeW, eyeW), m::Type().iconSmall()))
import_key_reveal_ = !import_key_reveal_;
if (ImGui::IsItemHovered()) m::Tooltip("%s", TR("import_key_reveal_tip"));
if (m::TactileButton(TR("paste"))) {
const char* clipboard = ImGui::GetClipboardText();
if (clipboard) {
snprintf(import_key_input_, sizeof(import_key_input_), "%s", clipboard);
// Trim whitespace
std::string trimmed(import_key_input_);
while (!trimmed.empty() && (trimmed.front() == ' ' || trimmed.front() == '\t' ||
trimmed.front() == '\n' || trimmed.front() == '\r'))
trimmed.erase(trimmed.begin());
while (!trimmed.empty() && (trimmed.back() == ' ' || trimmed.back() == '\t' ||
trimmed.back() == '\n' || trimmed.back() == '\r'))
trimmed.pop_back();
std::string trimmed(clipboard);
while (!trimmed.empty() && (trimmed.front()==' '||trimmed.front()=='\t'||trimmed.front()=='\n'||trimmed.front()=='\r')) trimmed.erase(trimmed.begin());
while (!trimmed.empty() && (trimmed.back()==' '||trimmed.back()=='\t'||trimmed.back()=='\n'||trimmed.back()=='\r')) trimmed.pop_back();
snprintf(import_key_input_, sizeof(import_key_input_), "%s", trimmed.c_str());
}
}
ImGui::SameLine();
if (ui::material::StyledButton(TR("clear"), ImVec2(0, 0), ui::material::resolveButtonFont(btnFont))) {
memset(import_key_input_, 0, sizeof(import_key_input_));
}
// Trimmed view for validation (importPrivateKey trims again before the RPC). The indicator and the
// Import-button guard below both derive from the SAME shared classifier, so they can't disagree.
if (m::TactileButton(TR("clear"))) sodium_memzero(import_key_input_, sizeof(import_key_input_));
// Trimmed view + classification. The indicator and the Import guard share isRecognizedImportKey.
std::string keyTrim(import_key_input_);
while (!keyTrim.empty() && (keyTrim.front()==' '||keyTrim.front()=='\t'||keyTrim.front()=='\n'||keyTrim.front()=='\r')) keyTrim.erase(keyTrim.begin());
while (!keyTrim.empty() && (keyTrim.back()==' '||keyTrim.back()=='\t'||keyTrim.back()=='\n'||keyTrim.back()=='\r')) keyTrim.pop_back();
bool keyRecognized = services::WalletSecurityController::isRecognizedPrivateKey(keyTrim);
bool keyIsZ = services::WalletSecurityController::classifyPrivateKey(keyTrim)
== services::WalletSecurityController::KeyKind::Shielded;
bool recognized = services::WalletSecurityController::isRecognizedImportKey(keyTrim);
bool viewing = services::WalletSecurityController::isViewingKey(keyTrim);
bool shielded = services::WalletSecurityController::classifyPrivateKey(keyTrim)
== services::WalletSecurityController::KeyKind::Shielded;
// Key validation indicator
if (!keyTrim.empty()) {
if (keyRecognized) {
ImGui::PushFont(ui::material::Type().iconSmall());
ImGui::TextColored(ui::material::SuccessVec4(), ICON_MD_CHECK_CIRCLE);
if (recognized) {
const char* label = viewing ? TR("import_key_type_zview")
: shielded ? TR("import_key_type_zspend")
: TR("import_key_type_tkey");
ImGui::PushFont(m::Type().iconSmall());
ImGui::TextColored(m::SuccessVec4(), ICON_MD_CHECK_CIRCLE);
ImGui::PopFont();
ImGui::SameLine(0, 4.0f);
ImGui::TextColored(ui::material::SuccessVec4(), "%s",
keyIsZ ? "Shielded spending key" : "Transparent private key");
ImGui::SameLine(0, 4.0f * dp);
ImGui::TextColored(m::SuccessVec4(), "%s", label);
} else {
ImGui::PushFont(ui::material::Type().iconSmall());
ImGui::PushFont(m::Type().iconSmall());
ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.0f, 1.0f), ICON_MD_HELP);
ImGui::PopFont();
ImGui::SameLine(0, 4.0f);
ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.0f, 1.0f), "Unrecognized key format");
ImGui::SameLine(0, 4.0f * dp);
ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.0f, 1.0f), "%s", TR("import_key_type_unknown"));
}
}
ImGui::Spacing();
if (!import_status_.empty()) {
if (import_success_) {
ImGui::TextColored(ui::material::SuccessVec4(), "%s", import_status_.c_str());
} else {
ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%s", import_status_.c_str());
// Progress / result / error.
if (import_in_progress_) {
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::Primary()), "%s%s",
TR("import_key_rescanning"), m::LoadingDots());
} else if (import_success_) {
ImGui::PushFont(m::Type().iconSmall());
ImGui::TextColored(m::SuccessVec4(), ICON_MD_CHECK_CIRCLE);
ImGui::PopFont();
ImGui::SameLine(0, 4.0f * dp);
ImGui::TextColored(m::SuccessVec4(), "%s", TR("import_key_done"));
if (!import_result_address_.empty()) {
ImGui::Spacing();
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("import_key_address"));
ui::widgets::AddressCopyField("##importedaddr", import_result_address_);
}
} else if (!import_status_.empty()) {
ImGui::PushTextWrapPos(0.0f);
ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%s", import_status_.c_str());
ImGui::PopTextWrapPos();
}
ImGui::Spacing();
ImGui::Separator();
ImGui::BeginDisabled(!keyRecognized); // only a recognized Z/T key can be imported
if (ui::material::StyledButton("Import", ImVec2(btnW, 0), ui::material::resolveButtonFont(btnFont))) {
importPrivateKey(std::string(import_key_input_), [this](bool success, const std::string& msg) {
import_success_ = success;
import_status_ = msg;
if (success) {
memset(import_key_input_, 0, sizeof(import_key_input_));
ImGui::Spacing();
float btnW = 130.0f * dp;
bool canImport = recognized && state_.connected && !import_in_progress_;
ImGui::BeginDisabled(!canImport);
if (m::TactileButton(TR("import_key_import"), ImVec2(btnW, 0))) {
import_in_progress_ = true;
import_success_ = false;
import_status_.clear();
import_result_address_.clear();
importPrivateKey(std::string(import_key_input_),
[this](bool ok, const std::string& err, const std::string& addr) {
import_in_progress_ = false;
import_success_ = ok;
if (ok) {
import_result_address_ = addr;
import_status_.clear();
sodium_memzero(import_key_input_, sizeof(import_key_input_)); // wipe on success
} else {
import_status_ = err;
}
});
}
ImGui::EndDisabled();
ImGui::SameLine();
if (ui::material::StyledButton("Close", ImVec2(btnW, 0), ui::material::resolveButtonFont(btnFont))) {
show_import_key_ = false;
if (m::TactileButton(TR("close"), ImVec2(btnW, 0))) show_import_key_ = false;
// Wipe the key the moment the dialog closes (Close button OR outside-click, which clears
// show_import_key_ mid-frame). in_progress is left to the callback to clear.
if (!show_import_key_) {
sodium_memzero(import_key_input_, sizeof(import_key_input_));
import_status_.clear();
memset(import_key_input_, 0, sizeof(import_key_input_));
import_result_address_.clear();
import_success_ = false;
import_key_reveal_ = false;
}
ui::material::EndOverlayDialog();
m::EndOverlayDialog();
}
void App::renderExportKeyDialog()