iuna

iuna - experimental devnet protocol
git clone https://iuna.jhx.app/git/iuna.git
Log | Files | Refs | README | LICENSE

commit c246947222128b49ee3054525265cd57a2c54d14
parent 71528e94586337855d809c04f1cbb84465d962d7
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Thu, 30 Jul 2026 05:43:38 +0200

Add settings screen

Diffstat:
Msrc/adapters/http.rs | 197++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/adapters/wallet_store.rs | 22++++++++++++++++++++++
Mwww/assets/iuna-ui.js | 46+++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 252 insertions(+), 13 deletions(-)

diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -88,6 +88,12 @@ struct AuthForm { password: String, } +#[derive(Debug, Deserialize)] +struct ChangePasswordForm { + old_password: String, + new_password: String, +} + #[derive(Debug, Serialize)] struct AuthStatusResponse { configured: bool, @@ -326,6 +332,10 @@ pub async fn serve( .route("/api/auth/setup", post(api_auth_setup_form)) .route("/api/auth/login", post(api_auth_login_form)) .route("/api/auth/logout", post(api_auth_logout_form)) + .route( + "/api/auth/change-password", + post(api_auth_change_password_form), + ) .route("/api/status", get(api_status)) .route("/api/blocks", get(api_blocks)) .route("/api/config", get(api_config).post(api_config_form)) @@ -568,6 +578,24 @@ async fn api_auth_logout_form(State(state): State<HttpState>, headers: HeaderMap .into_response() } +async fn api_auth_change_password_form( + State(state): State<HttpState>, + Extension(client_key): Extension<AuthClientKey>, + Form(form): Form<ChangePasswordForm>, +) -> Response { + match change_auth_password( + &state, + &form.old_password, + &form.new_password, + &client_key.0, + ) + .await + { + Ok(cookie) => ([(header::SET_COOKIE, cookie)], action_json(Ok(()))).into_response(), + Err(error) => action_json(Err(error)).into_response(), + } +} + async fn api_status(State(state): State<HttpState>) -> Json<NodeStatus> { let mut status = state.node.lock().await.status(); status.stratum = state.stratum.clone(); @@ -1766,6 +1794,38 @@ async fn login_auth_password( create_session_cookie(state, password).await } +async fn change_auth_password( + state: &HttpState, + old_password: &str, + new_password: &str, + client_key: &str, +) -> Result<String> { + check_auth_backoff(state, client_key).await?; + validate_password(new_password)?; + let current_hash = state + .ui_config + .lock() + .await + .auth_password_hash + .clone() + .context("authentication setup is required")?; + if !verify_password(old_password, &current_hash)? { + record_auth_failure(state, client_key).await; + bail!("invalid current password"); + } + let wallet = + wallet_store::reencrypt_with_password(&state.wallet_path, old_password, new_password)?; + { + let mut config = state.ui_config.lock().await; + config.auth_password_hash = Some(hash_password(new_password)?); + config_store::save(&state.config_path, &config)?; + } + state.node.lock().await.replace_wallet(wallet); + state.auth_sessions.lock().await.clear(); + clear_auth_backoff(state, client_key).await; + create_session_cookie(state, new_password).await +} + async fn check_auth_backoff(state: &HttpState, client_key: &str) -> Result<()> { let now = now_ms(); let mut backoffs = state.auth_backoff.lock().await; @@ -2009,9 +2069,9 @@ const INDEX_HTML: &str = r#"<!doctype html> .nav-button svg.chain-icon { stroke-width: 1.35; } .nav-button span { font-size: 11px; font-weight: 800; } .nav-button:hover, .nav-button.active { background: #202328; border-color: #3b4448; color: #d5f55f; } - .mode-toggle { margin-top: auto; width: 64px; display: grid; justify-items: center; border: 1px solid #2f363c; border-radius: 8px; padding: 7px 4px; color: #9fa8ad; background: #111316; font-size: 10px; font-weight: 850; text-align: center; } - .mode-toggle:hover { border-color: #d5f55f; color: #d5f55f; } - .mode-toggle-label { line-height: 1; text-transform: uppercase; } + .settings-button { margin-top: auto; width: 64px; min-height: 54px; display: grid; place-items: center; border: 1px solid transparent; border-radius: 8px; padding: 7px 4px; color: #9fa8ad; background: transparent; text-align: center; } + .settings-button svg { width: 23px; height: 23px; stroke: currentColor; stroke-width: 1.9; fill: none; } + .settings-button:hover, .settings-button.active { background: #202328; border-color: #3b4448; color: #d5f55f; } .version-panel { width: 64px; display: grid; gap: 4px; justify-items: center; border: 1px solid transparent; border-radius: 8px; padding: 7px 4px; color: #7f888e; background: transparent; font-size: 10px; font-weight: 850; text-align: center; } .version-panel.update { border-color: #566d25; color: #d5f55f; background: #1c2516; cursor: pointer; } .version-panel.checking { color: #a8b2b8; } @@ -2106,6 +2166,12 @@ const INDEX_HTML: &str = r#"<!doctype html> .auth-form { width: min(420px, 100%); display: grid; gap: 10px; } .auth-form form { display: grid; gap: 10px; align-items: stretch; } .auth-form input { width: 100%; } + .settings-grid { width: min(760px, 100%); display: grid; gap: 12px; } + .settings-mode-row { display: flex; justify-content: space-between; gap: 14px; align-items: center; } + .settings-mode-copy { min-width: 0; display: grid; gap: 4px; } + .settings-mode-title { color: #e8edf0; font-size: 15px; font-weight: 850; } + .settings-form { display: grid; gap: 10px; align-items: stretch; } + .settings-form label, .settings-form input { width: 100%; } .wallet-grid { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, .8fr); gap: 12px; align-items: start; } .wallet-actions { display: grid; gap: 12px; } .advanced-toggle { flex-basis: 100%; width: max-content; align-self: flex-start; border-color: #3a4248; padding: 4px 7px; background: #202328; color: #9fa8ad; font-size: 12px; } @@ -2278,17 +2344,19 @@ const INDEX_HTML: &str = r#"<!doctype html> .side-nav { display: flex; width: auto; gap: 8px; } .nav-button { width: 52px; min-height: 48px; } .nav-button span { font-size: 10px; } - .mode-toggle, .version-panel { margin-top: 0; width: 48px; padding: 6px 3px; font-size: 9px; } + .settings-button, .version-panel { margin-top: 0; width: 48px; min-height: 48px; padding: 6px 3px; } + .settings-button svg { width: 21px; height: 21px; } .content { padding: 16px 12px 36px; } header, .split, .setup-grid, .wallet-grid, .mining-grid, .detail-grid, .wallet-tx-row { grid-template-columns: 1fr; } header { display: grid; } + .settings-mode-row { align-items: flex-start; } input { min-width: 0; width: 100%; } .switch input { width: auto; } .seed-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .block-card { flex-basis: 108px; } } </style> - <script defer src="/assets/iuna-ui.js?v=60"></script> + <script defer src="/assets/iuna-ui.js?v=61"></script> <script defer src="/assets/alpine.min.js"></script> </head> <body x-data="iunaApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak> @@ -2313,8 +2381,8 @@ const INDEX_HTML: &str = r#"<!doctype html> <span>Chain</span> </button> </nav> - <button class="mode-toggle" type="button" @click="toggleUiMode" :title="advancedMode() ? 'Switch to basic mode' : 'Switch to full mode'"> - <span class="mode-toggle-label" x-text="advancedMode() ? 'Full' : 'Basic'"></span> + <button class="settings-button" :class="{ active: tab === 'settings' }" type="button" @click="setTab('settings')" title="Settings" aria-label="Settings"> + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9.7 3.2 9.2 5.5a7.2 7.2 0 0 0-1.4.8L5.6 5.6 3.2 9.8l1.7 1.6a7.8 7.8 0 0 0 0 1.6l-1.7 1.6 2.4 4.2 2.2-.7a7.2 7.2 0 0 0 1.4.8l.5 2.3h4.8l.5-2.3a7.2 7.2 0 0 0 1.4-.8l2.2.7 2.4-4.2-1.7-1.6a7.8 7.8 0 0 0 0-1.6L21 9.8l-2.4-4.2-2.2.7a7.2 7.2 0 0 0-1.4-.8l-.5-2.3H9.7Z"></path><circle cx="12" cy="12.2" r="3.1"></circle></svg> </button> <button class="version-panel" type="button" :class="{ update: updateAvailable(), checking: releaseCheckState === 'checking', failed: releaseCheckState === 'failed' }" :title="versionPanelTitle()" @click="openLatestRelease"> <span class="version-dot" aria-hidden="true"></span> @@ -2734,6 +2802,37 @@ const INDEX_HTML: &str = r#"<!doctype html> </section> </div> </section> + <section x-show="tab === 'settings'"> + <div class="settings-grid"> + <div class="panel"> + <div class="panel-head"> + <h2>Settings</h2> + <span class="pill" x-text="advancedMode() ? 'Node mode' : 'Wallet mode'"></span> + </div> + <div class="settings-mode-row"> + <div class="settings-mode-copy"> + <div class="settings-mode-title">Mode</div> + <div class="muted" x-text="advancedMode() ? 'Node mode shows mining and peer controls.' : 'Wallet mode keeps the interface focused on wallet and chain views.'"></div> + </div> + <label class="toggle-switch" :class="{ active: advancedMode() }"> + <input type="checkbox" :checked="advancedMode()" @change="setUiMode($event.target.checked ? 'advanced' : 'basic')"> + <span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span> + <span class="toggle-text" x-text="advancedMode() ? 'Node' : 'Wallet'"></span> + </label> + </div> + </div> + <div class="panel"> + <h3>Change Password</h3> + <div class="setup-feedback" :class="settingsFeedback?.kind" x-show="settingsFeedback" x-transition x-text="settingsFeedback?.message"></div> + <form class="settings-form" @submit.prevent="changePassword"> + <label>Current password<input x-model="settingsOldPassword" type="password" autocomplete="current-password" required></label> + <label>New password<input x-model="settingsNewPassword" type="password" autocomplete="new-password" minlength="12" required></label> + <label>Confirm new password<input x-model="settingsPasswordConfirm" type="password" autocomplete="new-password" minlength="12" required></label> + <div class="setup-actions"><button class="primary" type="submit">Change password</button></div> + </form> + </div> + </div> + </section> </main> </div> <div class="setup-overlay" x-show="showingAuth()" x-transition.opacity role="dialog" aria-modal="true" aria-labelledby="auth-title"> @@ -2975,11 +3074,12 @@ mod tests { use super::{ AUTH_COOKIE_NAME, HttpState, PEER_STALE_AFTER_MS, TransferForm, WalletTransactionFilters, - WalletTransactionsQuery, api_auth_login_form, api_auth_setup_form, api_auth_status, - auth_client_key, dev_seed_verify_bypass_allowed, hash_password, hex_encode, pbkdf2_sha256, - persist_burn_settings_config, persist_pow_mining_config, require_auth_middleware, - required_fee_per_byte_burn, same_origin_request, validate_password, validate_transfer_form, - verify_password, wallet_transaction_rows, wallet_utxo_rows, + WalletTransactionsQuery, api_auth_change_password_form, api_auth_login_form, + api_auth_setup_form, api_auth_status, auth_client_key, dev_seed_verify_bypass_allowed, + hash_password, hex_encode, pbkdf2_sha256, persist_burn_settings_config, + persist_pow_mining_config, require_auth_middleware, required_fee_per_byte_burn, + same_origin_request, validate_password, validate_transfer_form, verify_password, + wallet_transaction_rows, wallet_utxo_rows, }; #[test] @@ -3238,6 +3338,75 @@ mod tests { } #[tokio::test] + async fn password_change_reencrypts_wallet_and_replaces_login_password() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.json"); + let wallet_path = config_path.with_file_name("wallet.json"); + let old_password = "correct horse battery staple"; + let new_password = "new correct battery staple"; + let state = auth_test_state( + config_path.clone(), + UiConfig { + auth_password_hash: Some(hash_password(old_password).unwrap()), + setup_complete: true, + ..UiConfig::default() + }, + ) + .await; + wallet_store::encrypt_existing_with_password(&wallet_path, old_password).unwrap(); + let app = auth_test_app(state); + + let login = http_request( + app.clone(), + Method::POST, + "/api/auth/login", + None, + "password=correct+horse+battery+staple", + ) + .await; + assert_eq!(login.status, StatusCode::OK); + let cookie = set_cookie_pair(&login.headers); + + let change = http_request( + app.clone(), + Method::POST, + "/api/auth/change-password", + Some(&cookie), + "old_password=correct+horse+battery+staple&new_password=new+correct+battery+staple", + ) + .await; + assert_eq!(change.status, StatusCode::OK); + assert!(change.body.contains("\"ok\":true")); + + let stored = config_store::load_or_create(&config_path).unwrap(); + let stored_hash = stored.auth_password_hash.unwrap(); + assert!(!verify_password(old_password, &stored_hash).unwrap()); + assert!(verify_password(new_password, &stored_hash).unwrap()); + assert!(wallet_store::load_with_password(&wallet_path, old_password).is_err()); + assert!(wallet_store::load_with_password(&wallet_path, new_password).is_ok()); + + let old_login = http_request( + app.clone(), + Method::POST, + "/api/auth/login", + None, + "password=correct+horse+battery+staple", + ) + .await; + assert!(old_login.body.contains("invalid password")); + + let new_login = http_request( + app, + Method::POST, + "/api/auth/login", + None, + "password=new+correct+battery+staple", + ) + .await; + assert_eq!(new_login.status, StatusCode::OK); + } + + #[tokio::test] async fn peer_management_updates_config_file() { let dir = tempfile::tempdir().unwrap(); let config_path = dir.path().join("config.json"); @@ -3736,6 +3905,10 @@ mod tests { .route("/api/auth/status", get(api_auth_status)) .route("/api/auth/setup", post(api_auth_setup_form)) .route("/api/auth/login", post(api_auth_login_form)) + .route( + "/api/auth/change-password", + post(api_auth_change_password_form), + ) .route("/api/protected", get(protected_auth_test_endpoint)) .layer(middleware::from_fn_with_state( state.clone(), diff --git a/src/adapters/wallet_store.rs b/src/adapters/wallet_store.rs @@ -184,6 +184,28 @@ pub fn encrypt_existing_with_password(path: &Path, password: &str) -> Result<()> .with_context(|| format!("failed to encrypt wallet file {}", path.display())) } +pub fn reencrypt_with_password( + path: &Path, + current_password: &str, + new_password: &str, +) -> Result<Wallet> { + let stored = read_wallet_file(path)?; + let seed = wallet_seed(&stored, Some(current_password))?; + let seed = normalize_seed_phrase(&seed).unwrap_or(seed); + let wallet = Wallet::from_seed(&seed); + if wallet.address() != stored.address { + bail!( + "wallet file has address {}, but its seed derives {}", + stored.address, + wallet.address() + ); + } + let mut file = open_wallet_file(path, WalletFileMode::Replace)?; + write_encrypted_wallet_file(&mut file, seed, wallet.address(), new_password) + .with_context(|| format!("failed to re-encrypt wallet file {}", path.display()))?; + Ok(wallet) +} + fn load(path: &Path) -> Result<Wallet> { load_encrypted_or_plaintext(path, None) } diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -30,6 +30,10 @@ window.iunaApp = function iunaApp() { authPasswordConfirm: "", loginPassword: "", authFeedback: null, + settingsOldPassword: "", + settingsNewPassword: "", + settingsPasswordConfirm: "", + settingsFeedback: null, setupWallet: { address: null, seed_phrase: null, dev_verify_bypass: false, requires_peer: false }, setupWalletMode: "create", setupSeedStep: "write", @@ -113,7 +117,9 @@ window.iunaApp = function iunaApp() { }, allowedTabs() { - return this.advancedMode() ? ["wallet", "mining", "p2p", "chain"] : ["wallet", "chain"]; + return this.advancedMode() + ? ["wallet", "mining", "p2p", "chain", "settings"] + : ["wallet", "chain", "settings"]; }, basicMode() { @@ -144,6 +150,7 @@ window.iunaApp = function iunaApp() { mining: "Mining", p2p: "P2P", chain: "Chain", + settings: "Settings", }[this.tab] || "iuna"; }, @@ -260,6 +267,39 @@ window.iunaApp = function iunaApp() { } }, + async changePassword() { + try { + this.settingsFeedback = null; + if (this.settingsNewPassword !== this.settingsPasswordConfirm) { + throw new Error("New passwords do not match"); + } + const body = new URLSearchParams({ + old_password: this.settingsOldPassword, + new_password: this.settingsNewPassword, + }); + const response = await fetch("/api/auth/change-password", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + }); + const payload = await response.json(); + if (!response.ok || !payload.ok) { + throw new Error(payload.error || `/api/auth/change-password returned ${response.status}`); + } + this.settingsOldPassword = ""; + this.settingsNewPassword = ""; + this.settingsPasswordConfirm = ""; + await this.refreshAuth(); + this.showSettingsFeedback("Password changed", "success"); + this.showFlash("Password changed", "success"); + } catch (error) { + this.showSettingsFeedback(error.message, "error"); + } + }, + async refreshConfig() { this.config = await this.fetchJson("/api/config"); }, @@ -968,6 +1008,10 @@ window.iunaApp = function iunaApp() { this.authFeedback = { message, kind }; }, + showSettingsFeedback(message, kind) { + this.settingsFeedback = { message, kind }; + }, + short(value) { if (!value) return "-"; if (value.length <= 16) return value;