From 0101cc707c78fad508c5cb5e751bf016a2d3c75c Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 18 Oct 2026 11:53:29 -0500 Subject: [PATCH] feat(network): a clear auth-failed node banner with a fix-it action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the node rejects the wallet's RPC login (bad rpcuser/rpcpassword), the status bar showed developer jargon ("Auth failed — check rpcuser/rpcpassword") and the only action was Reconnect, which just repeats the failure. Now a dedicated AuthFailed banner reason surfaces "Wallet can't sign in to the node" with a plain-language detail and a real fix: Restart node for the embedded daemon (regenerates its cookie creds) or Open config folder for an external one (to fix DRAGONX.conf). Driven by a new rpc_auth_failed_ flag (set on the auth path, cleared on each connect attempt), with evaluator unit tests. Co-Authored-By: Claude Opus 4.8 --- src/app.cpp | 16 +++++++++++++--- src/app.h | 1 + src/app_network.cpp | 3 +++ src/ui/node_status_banner.h | 25 ++++++++++++++++++++++--- src/util/i18n.cpp | 5 ++++- tests/test_phase4.cpp | 23 +++++++++++++++++++++++ 6 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/app.cpp b/src/app.cpp index 05cc0e5..e19d08a 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -2662,6 +2662,7 @@ void App::renderNodeStatusBanner() const NodeBusyText busy = nodeBusyText(); in.node_busy = busy.busy; in.node_busy_detail = busy.compact; // one line; the overlay and the quit prompt explain more + in.auth_failed = rpc_auth_failed_; const ui::NodeBannerState banner = ui::evaluateNodeStatusBanner(in); if (!banner.show) return; @@ -2698,6 +2699,8 @@ void App::renderNodeStatusBanner() title = TR("node_banner_no_peers_title"); icon = ICON_MD_CLOUD_OFF; break; case ui::NodeBannerReason::NodeBusy: title = busy.title.c_str(); icon = ICON_MD_HOURGLASS_EMPTY; break; + case ui::NodeBannerReason::AuthFailed: + title = TR("node_banner_auth_title"); icon = ICON_MD_LOCK; break; case ui::NodeBannerReason::FullNodeOffline: default: title = TR("node_banner_offline_title"); icon = ICON_MD_CLOUD_OFF; break; @@ -2706,13 +2709,15 @@ void App::renderNodeStatusBanner() const char* actionLabel = nullptr; if (banner.action == ui::NodeBannerAction::Reconnect) actionLabel = TR("node_banner_reconnect"); else if (banner.action == ui::NodeBannerAction::RestartNode) actionLabel = TR("node_banner_restart"); + else if (banner.action == ui::NodeBannerAction::OpenConfigFolder) actionLabel = TR("node_banner_open_config"); // The NoPeers case deliberately carries no passthrough detail (connection_status is just // "Connected" there, which reads as a contradiction under the "no peers" headline). Show a // translated, actionable hint instead; every other reason keeps its live status/error text. - const std::string detailText = (banner.reason == ui::NodeBannerReason::NoPeers) - ? std::string(TR("node_banner_no_peers_detail")) - : banner.detail; + std::string detailText; + if (banner.reason == ui::NodeBannerReason::NoPeers) detailText = TR("node_banner_no_peers_detail"); + else if (banner.reason == ui::NodeBannerReason::AuthFailed) detailText = TR("node_banner_auth_detail"); + else detailText = banner.detail; const float padX = ui::Layout::spacingLg(); @@ -2801,6 +2806,11 @@ void App::renderNodeStatusBanner() if (m::TactileButton(actionLabel, ImVec2(btnW, btnH))) { if (banner.action == ui::NodeBannerAction::RestartNode) restartDaemon(); else if (banner.action == ui::NodeBannerAction::Reconnect) tryConnect(); + else if (banner.action == ui::NodeBannerAction::OpenConfigFolder) { + const std::string conf = rpc::Connection::getDefaultConfPath(); + util::Platform::openFolder(std::filesystem::path(conf).parent_path().string(), + /*createIfMissing=*/false); + } } } diff --git a/src/app.h b/src/app.h index 34ee04f..031d9fc 100644 --- a/src/app.h +++ b/src/app.h @@ -1559,6 +1559,7 @@ private: // Connection std::string connection_status_ = "Disconnected"; + bool rpc_auth_failed_ = false; // last connect hit an auth failure (bad rpcuser/rpcpassword); drives the banner bool connection_in_progress_ = false; bool remote_rpc_plaintext_warning_shown_ = false; // Startup daemon-launch diagnostics: bound the "RPC port busy, no config" wait before warning, diff --git a/src/app_network.cpp b/src/app_network.cpp index 81c40c9..e0c4930 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -406,6 +406,7 @@ void App::tryConnect() ++connect_attempt; connection_in_progress_ = true; + rpc_auth_failed_ = false; // re-arm: a fresh connect attempt clears any prior auth failure connection_status_ = TR("sb_loading_config"); // Auto-detect configuration (file I/O — fast, safe on main thread) @@ -608,6 +609,7 @@ void App::tryConnect() } else { state_.connected = false; connection_status_ = TR("sb_auth_failed"); + rpc_auth_failed_ = true; VERBOSE_LOGF("[connect #%d] .cookie auth also failed\n", attempt); ui::Notifications::instance().error( "RPC authentication failed (HTTP 401). " @@ -621,6 +623,7 @@ void App::tryConnect() state_.connected = false; std::string confPath = rpc::Connection::getDefaultConfPath(); connection_status_ = TR("sb_auth_failed"); + rpc_auth_failed_ = true; VERBOSE_LOGF("[connect #%d] HTTP 401 — rpcuser/rpcpassword in %s don't match the daemon. " "Edit the file or restart the daemon to regenerate credentials.\n", attempt, confPath.c_str()); diff --git a/src/ui/node_status_banner.h b/src/ui/node_status_banner.h index ac23261..926aa70 100644 --- a/src/ui/node_status_banner.h +++ b/src/ui/node_status_banner.h @@ -19,9 +19,10 @@ enum class NodeBannerSeverity { // What the banner's action button does. App maps this to the concrete call. enum class NodeBannerAction { - None, // no button — nothing the user can usefully do from here - Reconnect, // full node: re-run the RPC connect state machine (App::tryConnect) - RestartNode, // full node: the embedded daemon crashed & auto-restart gave up (App::restartDaemon) + None, // no button — nothing the user can usefully do from here + Reconnect, // full node: re-run the RPC connect state machine (App::tryConnect) + RestartNode, // full node: the embedded daemon crashed & auto-restart gave up (App::restartDaemon) + OpenConfigFolder,// external node: open the folder holding DRAGONX.conf so the user can fix the login }; // Why the banner is up. App maps this to a translated headline; `detail` carries the live, @@ -33,6 +34,7 @@ enum class NodeBannerReason { LiteOpenFailed, // lite build: the wallet failed to open NoPeers, // connected to the daemon but no peer-derived tip (0 peers) — stalled, stale NodeBusy, // the node is up but busy (rebuilding witnesses / catching up / not answering) + AuthFailed, // the node rejected the wallet's RPC login (bad rpcuser/rpcpassword in the conf) }; struct NodeBannerState { @@ -71,6 +73,8 @@ struct NodeBannerInputs { bool node_busy = false; std::string node_busy_detail; + bool auth_failed = false; // the node rejected the wallet's RPC login (bad rpcuser/rpcpassword) + std::string connection_status; // human-readable status line (already translated) std::string daemon_last_error; // DaemonController::lastError() (may be empty) std::string lite_open_error; // lite: last wallet-open failure reason @@ -146,6 +150,21 @@ inline NodeBannerState evaluateNodeStatusBanner(const NodeBannerInputs& in) { return s; } + // The node rejected the wallet's RPC login — retrying won't help (the creds are wrong). Restarting + // the embedded node regenerates its cookie/creds; for an external node the user fixes DRAGONX.conf, + // so offer to open its folder. App renders a translated, actionable detail (connection_status here + // is the jargon "Auth failed…" line). + if (in.auth_failed) { + s.show = true; + s.severity = NodeBannerSeverity::Error; + s.reason = NodeBannerReason::AuthFailed; + s.action = (in.using_embedded_daemon && in.has_daemon_controller) + ? NodeBannerAction::RestartNode + : NodeBannerAction::OpenConfigFolder; + s.detail.clear(); + return s; + } + s.show = true; s.severity = NodeBannerSeverity::Warning; s.reason = NodeBannerReason::FullNodeOffline; diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 8021013..53eb565 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -1553,7 +1553,7 @@ void I18n::loadBuiltinEnglish() strings_["sb_no_conf"] = "No DRAGONX.conf found"; strings_["sb_starting_daemon"] = "Starting dragonxd..."; strings_["sb_connecting_daemon"] = "Connecting to dragonxd..."; - strings_["sb_auth_failed"] = "Auth failed — check rpcuser/rpcpassword"; + strings_["sb_auth_failed"] = "Couldn't sign in to the node"; strings_["sb_waiting_daemon"] = "Waiting for dragonxd to start..."; strings_["sb_waiting_daemon_err"] = "Waiting for dragonxd — %s"; strings_["sb_connecting_external"] = "Connecting to external daemon..."; @@ -1571,6 +1571,9 @@ void I18n::loadBuiltinEnglish() strings_["node_banner_lite_open_failed"] = "Couldn't open your wallet"; strings_["node_banner_reconnect"] = "Reconnect"; strings_["node_banner_restart"] = "Restart node"; + strings_["node_banner_open_config"] = "Open config folder"; + strings_["node_banner_auth_title"] = "Wallet can't sign in to the node"; + strings_["node_banner_auth_detail"] = "The node rejected the wallet's saved login. Restart the node, or open the config folder to fix the login in DRAGONX.conf."; // Refresh-staleness badge (W6-2) on the Total Balance card. strings_["data_stale_prefix"] = "Updated"; strings_["data_stale_tooltip"] = diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 28dfaad..36568fe 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -3559,6 +3559,29 @@ void testNodeStatusBanner() EXPECT_TRUE(s.action == NodeBannerAction::RestartNode); EXPECT_TRUE(s.detail.empty()); } + // Auth failure (bad rpcuser/rpcpassword): embedded → Restart (regenerates creds), external → Open + // config folder. A hard error, detail cleared (App renders a translated actionable hint). + { + NodeBannerInputs in; in.auth_failed = true; + in.using_embedded_daemon = true; in.has_daemon_controller = true; + NodeBannerState s = evaluateNodeStatusBanner(in); + EXPECT_TRUE(s.reason == NodeBannerReason::AuthFailed); + EXPECT_TRUE(s.severity == NodeBannerSeverity::Error); + EXPECT_TRUE(s.action == NodeBannerAction::RestartNode); + EXPECT_TRUE(s.detail.empty()); + } + { + NodeBannerInputs in; in.auth_failed = true; // external node + NodeBannerState s = evaluateNodeStatusBanner(in); + EXPECT_TRUE(s.reason == NodeBannerReason::AuthFailed); + EXPECT_TRUE(s.action == NodeBannerAction::OpenConfigFolder); + } + // A connected wallet never shows the auth banner (the connected branch returns earlier). + { + NodeBannerInputs in; in.connected = true; in.tip_known = true; in.blocks = 100; + in.auth_failed = true; + EXPECT_TRUE(!evaluateNodeStatusBanner(in).show); + } // Connected, tip unknown, but no chain info yet (blocks==0) → no banner (avoid startup flicker). { NodeBannerInputs in; in.connected = true; in.tip_known = false; in.blocks = 0;