From 5da92d014792800e130d724400599ddfaf20bdcd Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:40:50 -0400 Subject: [PATCH] goblin: Authorize Sessions GUI (trust grant, money prompt, Trusted Sites) + 6 locales - Trust-grant modal: password-gated hold-to-confirm 'Trust ?' showing identity + truncated npub, the granted low-tier categories (human labels, caution lines for unknown/login-excluded kinds), and the fixed money-asks line. Folds login in; creates the session on login-POST success. - Money-tier per-action modal: the v1-authorize-shaped password + hold-to-confirm prompt raised over the channel for a sign or a pay-committing encrypt; declines on cancel/timeout. - Trusted Sites settings page: active sessions, what each signs silently, time left, pause/resume, and one-tap end (revocation). Row + count in Settings. - Dispatch + per-frame router for trust/money; PENDING_TRUST wired. - goblin.trust.*, goblin.money.*, goblin.trusted_sites.*, settings.trusted_sites in all six locales; drift test green. --- locales/de.yml | 39 ++ locales/en.yml | 39 ++ locales/fr.yml | 39 ++ locales/ru.yml | 39 ++ locales/tr.yml | 39 ++ locales/zh-CN.yml | 39 ++ src/gui/views/goblin/mod.rs | 733 ++++++++++++++++++++++++++++++++++++ 7 files changed, 967 insertions(+) diff --git a/locales/de.yml b/locales/de.yml index cfda8e6b..8f59d4f0 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -476,6 +476,7 @@ goblin: node_advanced: "Erweitert" slatepacks: "Slatepacks" slatepacks_value: "Manuelle Transaktion" + trusted_sites: "Vertrauenswürdige Seiten" lock_wallet: "Wallet sperren" switch_wallet: "Wallet wechseln" advanced: "Erweitert" @@ -716,6 +717,44 @@ goblin: by_author: "von %{id}" reacts_to: "reagiert auf %{id}" article_title: "Titel: %{title}" + trust: + title: "Vertrauen" + headline: "%{domain} vertrauen?" + identity: "Anmeldung als" + grant_intro: "%{domain} kann diese für dich signieren, ohne erneut zu fragen, bis du dich abmeldest oder die Sitzung beendest:" + cat_social: "Beiträge und Reaktionen" + cat_dm: "Direktnachrichten" + cat_market: "Angebote" + cat_identity: "Profil und Listen" + cat_delete: "Löschungen" + cat_http: "Uploads und HTTP-Authentifizierung" + cat_unknown: "Anderer Ereignistyp (Art %{n})" + login_excluded: "Anmeldung wird einer Seite nie gewährt." + money_line: "Alles, was Geld ausgibt oder empfängt, fragt weiterhin jedes Mal nach deinem Passwort. Kauf-, Verkaufs- und Zahlungsbestätigungen werden nie stillschweigend signiert." + duration: "Dies gilt nur für diese Sitzung. Du kannst sie jederzeit unter Einstellungen, Vertrauenswürdige Seiten beenden. Dein Schlüssel verlässt nie deine Wallet." + pass_prompt: "Gib dein Wallet-Passwort ein, um dieser Seite zu vertrauen." + confirm_hold: "Halten, um für diese Sitzung zu vertrauen" + sent: "%{domain} wird für diese Sitzung vertraut" + failed: "%{domain} konnte nicht erreicht werden. Die Sitzung wurde nicht gestartet." + notice_volume: "Eine vertrauenswürdige Seite signiert sehr viel." + money: + title: "Bestätigen" + headline: "In %{domain} bestätigen?" + identity: "Signiert als %{id}" + explain: "Dies bewegt oder bindet Werte, daher wird immer gefragt. Mit dem Genehmigen wird diese eine Aktion signiert." + pass_prompt: "Gib dein Wallet-Passwort zum Bestätigen ein." + confirm_hold: "Halten zum Bestätigen" + encrypt_desc: "Eine verschlüsselte Bestellnachricht, die eine Zahlung zusichert." + trusted_sites: + title: "Vertrauenswürdige Seiten" + intro: "Seiten, denen du in dieser Sitzung vertraut hast, können risikoarme Aktionen ohne Nachfrage signieren. Bei Geld wird immer gefragt. Beende jede Sitzung hier." + empty: "Keine vertrauenswürdigen Seiten. Vertraue einer Seite bei der Anmeldung, um ihr eine Signatur-Sitzung zu geben." + none: "nichts" + can_sign: "Kann still signieren: %{cats}" + time_left: "Endet in %{mins} Min." + paused: "Pausiert, signiert sehr viel" + resume: "Fortsetzen" + end: "Sitzung beenden" identities: title: "Identitäten" switch_hint: "Identität wechseln" diff --git a/locales/en.yml b/locales/en.yml index a6a39e6f..fabeeee5 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -476,6 +476,7 @@ goblin: node_advanced: "Advanced" slatepacks: "Slatepacks" slatepacks_value: "Manual transaction" + trusted_sites: "Trusted Sites" lock_wallet: "Lock wallet" switch_wallet: "Switch wallet" advanced: "Advanced" @@ -716,6 +717,44 @@ goblin: by_author: "by %{id}" reacts_to: "reacts to %{id}" article_title: "Title: %{title}" + trust: + title: "Trust" + headline: "Trust %{domain}?" + identity: "Signing in as" + grant_intro: "%{domain} will be able to sign these for you, without asking again, until you sign out or end the session:" + cat_social: "Posts and reactions" + cat_dm: "Direct messages" + cat_market: "Listings" + cat_identity: "Profile and lists" + cat_delete: "Deletes" + cat_http: "Uploads and HTTP auth" + cat_unknown: "Other event type (kind %{n})" + login_excluded: "Login is never granted to a site." + money_line: "Anything that spends or receives money will still ask for your password, every time. Buying, selling, and payment confirmations are never signed silently." + duration: "This lasts for this session only. You can end it any time from Settings, Trusted Sites. Your key never leaves your wallet." + pass_prompt: "Enter your wallet password to trust this site." + confirm_hold: "Hold to trust for this session" + sent: "Trusting %{domain} for this session" + failed: "Could not reach %{domain}. The session was not started." + notice_volume: "A trusted site is signing a lot." + money: + title: "Confirm" + headline: "Confirm in %{domain}?" + identity: "Signing as %{id}" + explain: "This moves or commits value, so it always asks. Approving signs this one action." + pass_prompt: "Enter your wallet password to confirm." + confirm_hold: "Hold to confirm" + encrypt_desc: "An encrypted order message that commits to a payment." + trusted_sites: + title: "Trusted Sites" + intro: "Sites you trusted this session can sign low-risk actions without asking. Money always asks. End any session here." + empty: "No trusted sites. Trust a site when you sign in to give it a signing session." + none: "nothing" + can_sign: "Can sign silently: %{cats}" + time_left: "Ends in %{mins} min" + paused: "Paused, signing a lot" + resume: "Resume" + end: "End session" identities: title: "Identities" switch_hint: "Switch identity" diff --git a/locales/fr.yml b/locales/fr.yml index 0f7f5fdb..86b378f4 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -476,6 +476,7 @@ goblin: node_advanced: "Avancé" slatepacks: "Slatepacks" slatepacks_value: "Transaction manuelle" + trusted_sites: "Sites de confiance" lock_wallet: "Verrouiller le portefeuille" switch_wallet: "Changer de portefeuille" advanced: "Avancé" @@ -716,6 +717,44 @@ goblin: by_author: "par %{id}" reacts_to: "réagit à %{id}" article_title: "Titre : %{title}" + trust: + title: "Confiance" + headline: "Faire confiance à %{domain} ?" + identity: "Connexion en tant que" + grant_intro: "%{domain} pourra les signer pour vous, sans redemander, jusqu'à ce que vous vous déconnectiez ou terminiez la session :" + cat_social: "Publications et réactions" + cat_dm: "Messages privés" + cat_market: "Annonces" + cat_identity: "Profil et listes" + cat_delete: "Suppressions" + cat_http: "Envois et authentification HTTP" + cat_unknown: "Autre type d'événement (type %{n})" + login_excluded: "La connexion n'est jamais accordée à un site." + money_line: "Tout ce qui dépense ou reçoit de l'argent demandera toujours votre mot de passe, à chaque fois. Les achats, ventes et confirmations de paiement ne sont jamais signés silencieusement." + duration: "Cela ne dure que le temps de cette session. Vous pouvez y mettre fin à tout moment depuis Réglages, Sites de confiance. Votre clé ne quitte jamais votre portefeuille." + pass_prompt: "Saisissez le mot de passe de votre portefeuille pour faire confiance à ce site." + confirm_hold: "Maintenez pour faire confiance pour cette session" + sent: "Confiance accordée à %{domain} pour cette session" + failed: "Impossible de joindre %{domain}. La session n'a pas démarré." + notice_volume: "Un site de confiance signe beaucoup." + money: + title: "Confirmer" + headline: "Confirmer dans %{domain} ?" + identity: "Signature en tant que %{id}" + explain: "Ceci déplace ou engage une valeur, donc une confirmation est toujours demandée. Approuver signe cette seule action." + pass_prompt: "Saisissez le mot de passe de votre portefeuille pour confirmer." + confirm_hold: "Maintenez pour confirmer" + encrypt_desc: "Un message de commande chiffré qui engage un paiement." + trusted_sites: + title: "Sites de confiance" + intro: "Les sites auxquels vous avez fait confiance cette session peuvent signer des actions à faible risque sans demander. L'argent demande toujours. Terminez toute session ici." + empty: "Aucun site de confiance. Faites confiance à un site lors de la connexion pour lui donner une session de signature." + none: "rien" + can_sign: "Peut signer en silence : %{cats}" + time_left: "Se termine dans %{mins} min" + paused: "En pause, signe beaucoup" + resume: "Reprendre" + end: "Terminer la session" identities: title: "Identités" switch_hint: "Changer d'identité" diff --git a/locales/ru.yml b/locales/ru.yml index 7b489909..acd9e9c4 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -476,6 +476,7 @@ goblin: node_advanced: "Дополнительно" slatepacks: "Slatepacks" slatepacks_value: "Ручная транзакция" + trusted_sites: "Доверенные сайты" lock_wallet: "Заблокировать кошелёк" switch_wallet: "Сменить кошелёк" advanced: "Дополнительно" @@ -716,6 +717,44 @@ goblin: by_author: "автор %{id}" reacts_to: "реакция на %{id}" article_title: "Заголовок: %{title}" + trust: + title: "Доверие" + headline: "Доверять %{domain}?" + identity: "Вход как" + grant_intro: "%{domain} сможет подписывать это за вас без повторных запросов, пока вы не выйдете или не завершите сессию:" + cat_social: "Публикации и реакции" + cat_dm: "Личные сообщения" + cat_market: "Объявления" + cat_identity: "Профиль и списки" + cat_delete: "Удаления" + cat_http: "Загрузки и HTTP-аутентификация" + cat_unknown: "Другой тип события (вид %{n})" + login_excluded: "Вход в систему сайту никогда не предоставляется." + money_line: "Всё, что тратит или получает деньги, каждый раз будет запрашивать ваш пароль. Покупки, продажи и подтверждения платежей никогда не подписываются молча." + duration: "Это действует только для текущей сессии. Вы можете завершить её в любой момент в Настройках, Доверенные сайты. Ваш ключ никогда не покидает кошелёк." + pass_prompt: "Введите пароль кошелька, чтобы доверять этому сайту." + confirm_hold: "Удерживайте, чтобы доверять на эту сессию" + sent: "Доверие %{domain} на эту сессию" + failed: "Не удалось связаться с %{domain}. Сессия не начата." + notice_volume: "Доверенный сайт подписывает очень много." + money: + title: "Подтвердить" + headline: "Подтвердить в %{domain}?" + identity: "Подпись как %{id}" + explain: "Это перемещает или фиксирует ценность, поэтому запрос выводится всегда. Одобрение подписывает это одно действие." + pass_prompt: "Введите пароль кошелька для подтверждения." + confirm_hold: "Удерживайте для подтверждения" + encrypt_desc: "Зашифрованное сообщение заказа, фиксирующее платёж." + trusted_sites: + title: "Доверенные сайты" + intro: "Сайты, которым вы доверились в этой сессии, могут подписывать действия с низким риском без запроса. Деньги всегда запрашивают подтверждение. Завершите любую сессию здесь." + empty: "Нет доверенных сайтов. Доверьте сайту при входе, чтобы дать ему сессию подписи." + none: "ничего" + can_sign: "Может подписывать без запроса: %{cats}" + time_left: "Завершится через %{mins} мин" + paused: "Приостановлено, подписывает много" + resume: "Возобновить" + end: "Завершить сессию" identities: title: "Личности" switch_hint: "Сменить личность" diff --git a/locales/tr.yml b/locales/tr.yml index b16e2789..18d601f1 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -476,6 +476,7 @@ goblin: node_advanced: "Gelişmiş" slatepacks: "Slatepackler" slatepacks_value: "Manuel işlem" + trusted_sites: "Güvenilen Siteler" lock_wallet: "Cüzdanı kilitle" switch_wallet: "Cüzdan değiştir" advanced: "Gelişmiş" @@ -716,6 +717,44 @@ goblin: by_author: "yazan %{id}" reacts_to: "%{id} tepki veriliyor" article_title: "Başlık: %{title}" + trust: + title: "Güven" + headline: "%{domain} sitesine güvenilsin mi?" + identity: "Giriş yapan" + grant_intro: "%{domain}, siz çıkış yapana veya oturumu sonlandırana kadar bunları tekrar sormadan sizin için imzalayabilecek:" + cat_social: "Gönderiler ve tepkiler" + cat_dm: "Doğrudan mesajlar" + cat_market: "İlanlar" + cat_identity: "Profil ve listeler" + cat_delete: "Silmeler" + cat_http: "Yüklemeler ve HTTP kimlik doğrulama" + cat_unknown: "Diğer olay türü (tür %{n})" + login_excluded: "Bir siteye asla giriş izni verilmez." + money_line: "Para harcayan veya alan her şey, her seferinde parolanızı isteyecek. Alım, satım ve ödeme onayları asla sessizce imzalanmaz." + duration: "Bu yalnızca bu oturum için geçerlidir. İstediğiniz zaman Ayarlar, Güvenilen Siteler'den sonlandırabilirsiniz. Anahtarınız cüzdanınızdan asla çıkmaz." + pass_prompt: "Bu siteye güvenmek için cüzdan parolanızı girin." + confirm_hold: "Bu oturum için güvenmek üzere basılı tutun" + sent: "%{domain} bu oturum için güveniliyor" + failed: "%{domain} sitesine ulaşılamadı. Oturum başlatılmadı." + notice_volume: "Güvenilen bir site çok fazla imzalıyor." + money: + title: "Onayla" + headline: "%{domain} içinde onaylansın mı?" + identity: "%{id} olarak imzalanıyor" + explain: "Bu, değeri taşır veya bağlar, bu yüzden her zaman sorar. Onaylamak bu tek eylemi imzalar." + pass_prompt: "Onaylamak için cüzdan parolanızı girin." + confirm_hold: "Onaylamak için basılı tutun" + encrypt_desc: "Bir ödemeyi bağlayan şifreli sipariş mesajı." + trusted_sites: + title: "Güvenilen Siteler" + intro: "Bu oturumda güvendiğiniz siteler, düşük riskli eylemleri sormadan imzalayabilir. Para her zaman sorar. Herhangi bir oturumu buradan sonlandırın." + empty: "Güvenilen site yok. Ona bir imza oturumu vermek için giriş yaparken bir siteye güvenin." + none: "hiçbir şey" + can_sign: "Sessizce imzalayabilir: %{cats}" + time_left: "%{mins} dk içinde sona erer" + paused: "Duraklatıldı, çok imzalıyor" + resume: "Devam et" + end: "Oturumu sonlandır" identities: title: "Kimlikler" switch_hint: "Kimlik değiştir" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 7c30f71f..5ea29d1c 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -476,6 +476,7 @@ goblin: node_advanced: "高级" slatepacks: "Slatepack" slatepacks_value: "手动交易" + trusted_sites: "受信任网站" lock_wallet: "锁定钱包" switch_wallet: "切换钱包" advanced: "高级" @@ -716,6 +717,44 @@ goblin: by_author: "作者 %{id}" reacts_to: "回应 %{id}" article_title: "标题:%{title}" + trust: + title: "信任" + headline: "信任 %{domain}?" + identity: "登录身份" + grant_intro: "在你退出登录或结束会话之前,%{domain} 可以为你签署以下内容,无需再次询问:" + cat_social: "帖子和互动" + cat_dm: "私信" + cat_market: "商品列表" + cat_identity: "资料和列表" + cat_delete: "删除" + cat_http: "上传和 HTTP 认证" + cat_unknown: "其他事件类型(kind %{n})" + login_excluded: "登录权限绝不会授予网站。" + money_line: "任何花费或收取资金的操作每次都会要求输入密码。买入、卖出和付款确认绝不会被静默签署。" + duration: "此权限仅在本次会话有效。你可随时在设置,受信任网站中结束。你的密钥绝不离开钱包。" + pass_prompt: "输入钱包密码以信任此网站。" + confirm_hold: "长按以在本次会话中信任" + sent: "已在本次会话中信任 %{domain}" + failed: "无法连接 %{domain}。会话未启动。" + notice_volume: "某个受信任网站签署量过大。" + money: + title: "确认" + headline: "在 %{domain} 中确认?" + identity: "以 %{id} 签署" + explain: "此操作转移或锁定价值,因此每次都会询问。批准将签署这一操作。" + pass_prompt: "输入钱包密码以确认。" + confirm_hold: "长按以确认" + encrypt_desc: "一条锁定付款的加密订单消息。" + trusted_sites: + title: "受信任网站" + intro: "本次会话中你信任的网站可在不询问的情况下签署低风险操作。资金操作始终询问。可在此结束任意会话。" + empty: "暂无受信任网站。登录时信任某个网站即可为其开启签署会话。" + none: "无" + can_sign: "可静默签署:%{cats}" + time_left: "%{mins} 分钟后结束" + paused: "已暂停,签署量过大" + resume: "恢复" + end: "结束会话" identities: title: "身份" switch_hint: "切换身份" diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 861cc3a3..279bae8c 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -149,6 +149,17 @@ pub struct GoblinWalletView { /// Quiet toast for the login outcome: text and when it appeared. Reused for /// the authorize outcome (same quiet pill, different strings). login_toast: Option<(String, std::time::Instant)>, + /// A "Trust with Goblin" (Authorize Sessions) grant awaiting approval. While + /// this OR `login`/`authorize`/`money` is `Some`, new incoming requests are + /// ignored (one approval at a time). + trust: Option, + /// The hold-to-confirm gesture for the single high-value trust decision. + trust_hold: w::HoldToSend, + /// A money-tier sign or encrypt arriving mid-session that must be + /// password-approved per action (the v1-style prompt raised over the channel). + money: Option, + /// The hold-to-confirm gesture for a money-tier approval. + money_hold: w::HoldToSend, } /// Whether the per-identity cue is drawn on activity rows (owner-approved). The @@ -193,6 +204,9 @@ enum SettingsPage { Advanced, /// The identity switcher: one wallet, one balance, many nostr identities. Identities, + /// Trusted Sites: the active Authorize Sessions, what each can sign, and a + /// one-tap end (revocation). + TrustedSites, } /// Inline state for the Advanced (wallet-recovery) settings page: the @@ -284,6 +298,10 @@ impl Default for GoblinWalletView { login: None, authorize: None, login_toast: None, + trust: None, + trust_hold: w::HoldToSend::default(), + money: None, + money_hold: w::HoldToSend::default(), } } } @@ -429,6 +447,54 @@ struct AuthorizeState { result: std::sync::Arc>>>, } +/// The "Trust with Goblin" (Authorize Sessions) grant modal id. Its verb is +/// "Trust", kept distinct from login's "Sign in" and authorize's "Authorize" so +/// the user always knows which decision they are making. +const TRUST_MODAL: &str = "goblin_trust_modal"; +/// The money-tier per-action approval modal id (a channel request the wallet +/// classified as money, raised mid-session with the same shape as v1 authorize). +const MONEY_MODAL: &str = "goblin_money_modal"; + +/// One pending "Trust with Goblin" grant. Mirrors [`LoginState`] (it folds login +/// in as its identity step, then establishes the session), plus the freshly +/// generated wallet channel keypair carried into session creation on success. +struct TrustState { + /// The validated request (nonce, domain, callback, channel key, relay, kinds). + uri: crate::nostr::trusturi::TrustUri, + /// The CHOSEN signing identity (pubkey hex); defaults to the active one. + selected: String, + /// When the request arrived, for the [`LOGIN_EXPIRY_SECS`] deadline. + created: std::time::Instant, + /// Wallet password typed into the modal; cleared as soon as consumed. + pass: String, + /// The typed password did not verify; the request stays pending. + wrong_pass: bool, + /// The login event is signed and its callback POST is in flight; on success + /// the session is created, on failure the whole grant fails (spec 4.3). + posting: bool, + /// The wallet's ephemeral channel keypair for this session, generated at + /// approval and carried into [`crate::nostr::session::Session::new`]. + channel: nostr_sdk::Keys, + /// A friendly label for the identity (for the Trusted Sites list). + label: String, + /// Result slot the login-POST worker fills. + result: std::sync::Arc>>>, +} + +/// One pending money-tier approval arriving over a live session channel. The +/// user sees exactly what they are committing to and gates it with the wallet +/// password, hold-to-confirm, every time. +struct MoneyState { + /// The request awaiting the user's decision (sign or pay-committing encrypt). + pending: crate::nostr::session::PendingMoney, + /// When it arrived, for the [`LOGIN_EXPIRY_SECS`] deadline. + created: std::time::Instant, + /// Wallet password typed into the modal; cleared as soon as consumed. + pass: String, + /// The typed password did not verify; the request stays pending. + wrong_pass: bool, +} + /// A password-gated identity action the modal executes. Switching no longer uses /// the modal (it is instant and local); only these need the wallet password. #[derive(Clone)] @@ -901,6 +967,160 @@ impl GoblinWalletView { }); } + // A pending "Trust with Goblin" (Authorize Sessions) grant. One approval + // at a time: while login/authorize/trust/money is pending, drop new URIs. + if let Some(trust) = crate::take_pending_trust() { + if self.login.is_none() + && self.authorize.is_none() + && self.trust.is_none() + && self.money.is_none() + { + if let Some(active) = wallet.active_nostr_pubkey() { + self.send = None; + let label = wallet + .nostr_identities() + .into_iter() + .find(|i| i.pubkey_hex == active) + .map(|i| i.display()) + .unwrap_or_else(|| data::short_npub(&active)); + self.trust = Some(TrustState { + uri: trust, + selected: active, + created: std::time::Instant::now(), + pass: String::new(), + wrong_pass: false, + posting: false, + channel: nostr_sdk::Keys::generate(), + label, + result: std::sync::Arc::new(std::sync::Mutex::new(None)), + }); + self.trust_hold = w::HoldToSend::default(); + Modal::new(TRUST_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("goblin.trust.title")) + .show(); + } + } + } + // An untouched grant dies after 2 minutes (posting is governed by the POST + // timeout, so it is exempt here). + let trust_expired = matches!(&self.trust, Some(st) + if !st.posting && st.created.elapsed().as_secs() >= LOGIN_EXPIRY_SECS); + if trust_expired { + self.trust = None; + if Modal::opened() == Some(TRUST_MODAL) { + Modal::close(); + } + } + // Poll the login-callback POST. On success the session is created together + // with the identity; on failure the whole grant fails (spec 4.3). + let mut trust_outcome = None; + if let Some(st) = &self.trust { + if st.posting { + trust_outcome = st + .result + .lock() + .unwrap() + .take() + .map(|res| (res, st.uri.domain.clone())); + if trust_outcome.is_none() { + ui.ctx().request_repaint(); + } + } + } + if let Some((res, domain)) = trust_outcome { + let st = self.trust.take(); + let text = match (res, st) { + (Ok(()), Some(st)) => { + if let Some(svc) = wallet.nostr_service() { + if let (Ok(site_pk), Ok(id_pk)) = ( + nostr_sdk::PublicKey::from_hex(&st.uri.site_session_pubkey), + nostr_sdk::PublicKey::from_hex(&st.selected), + ) { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let session = crate::nostr::session::Session::new( + st.uri.domain.clone(), + id_pk, + st.label.clone(), + &st.uri.requested_kinds, + &st.channel, + site_pk, + vec![st.uri.relay.clone()], + now, + ); + svc.add_session(session); + } + } + t!("goblin.trust.sent", domain => domain).to_string() + } + (Err(e), _) => { + log::warn!("trust login callback failed: {e}"); + t!("goblin.trust.failed", domain => domain).to_string() + } + (Ok(()), None) => t!("goblin.trust.sent", domain => domain).to_string(), + }; + self.login_toast = Some((text, std::time::Instant::now())); + } + + // A money-tier request arriving over a live session channel raises the + // per-action approval, every time (never silent). One at a time. + if self.money.is_none() + && self.login.is_none() + && self.authorize.is_none() + && self.trust.is_none() + { + if let Some(pending) = wallet.nostr_service().and_then(|s| s.peek_money_prompt()) { + self.money = Some(MoneyState { + pending, + created: std::time::Instant::now(), + pass: String::new(), + wrong_pass: false, + }); + self.money_hold = w::HoldToSend::default(); + Modal::new(MONEY_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("goblin.money.title")) + .show(); + } + } + // An unanswered money prompt times out and declines the action. + let money_expired = matches!(&self.money, Some(st) + if st.created.elapsed().as_secs() >= LOGIN_EXPIRY_SECS); + if money_expired { + if let (Some(st), Some(svc)) = (self.money.as_ref(), wallet.nostr_service()) { + svc.answer_money_prompt(st.pending.id(), false); + } + self.money = None; + if Modal::opened() == Some(MONEY_MODAL) { + Modal::close(); + } + } + // A session "signing a lot" notice surfaces as the quiet toast. + if wallet + .nostr_service() + .and_then(|s| s.take_session_notice()) + .is_some() + { + self.login_toast = Some(( + t!("goblin.trust.notice_volume").to_string(), + std::time::Instant::now(), + )); + } + // Draw the trust and money modals. + if Modal::opened() == Some(TRUST_MODAL) { + Modal::ui(ui.ctx(), cb, |ui, modal, cb| { + self.trust_modal_content(ui, modal, wallet, cb); + }); + } + if Modal::opened() == Some(MONEY_MODAL) { + Modal::ui(ui.ctx(), cb, |ui, modal, cb| { + self.money_modal_content(ui, modal, wallet, cb); + }); + } + // Send flow takes the full surface when active. if let Some(send) = &mut self.send { let done = send.ui(ui, wallet, cb, &mut self.avatars); @@ -2969,6 +3189,7 @@ impl GoblinWalletView { SettingsPage::Privacy => return self.privacy_ui(ui), SettingsPage::Advanced => return self.advanced_ui(ui, wallet, cb), SettingsPage::Identities => return self.identities_ui(ui, wallet, cb), + SettingsPage::TrustedSites => return self.trusted_sites_ui(ui, wallet, cb), SettingsPage::Main => {} } // Minimum-confirmations edit modal (GRIM parity), opened from the @@ -3273,6 +3494,7 @@ impl GoblinWalletView { ui.add_space(16.0); let mut open_node = false; let mut open_slatepack = false; + let mut open_trusted = false; settings_group(ui, &t!("goblin.settings.wallet"), |ui| { if settings_row_nav(ui, &t!("goblin.settings.node"), &node_summary(wallet)) { open_node = true; @@ -3302,11 +3524,27 @@ impl GoblinWalletView { ) { open_slatepack = true; } + // Trusted Sites: the active Authorize Sessions, with a one-tap + // end. Shows the live count as the row value. + let session_count = wallet + .nostr_service() + .map(|s| s.session_summaries().len()) + .unwrap_or(0); + if settings_row_nav( + ui, + &t!("goblin.settings.trusted_sites"), + &session_count.to_string(), + ) { + open_trusted = true; + } }); if open_slatepack { self.slatepack = SlatepackManual::default(); self.settings_page = SettingsPage::Slatepack; } + if open_trusted { + self.settings_page = SettingsPage::TrustedSites; + } if open_relays { // The ACTIVE set (override or per-identity advertised set), // so the editor shows what is really in use. @@ -6815,6 +7053,501 @@ impl GoblinWalletView { } } + /// The "Trust with Goblin" (Authorize Sessions) grant modal: proves identity + /// (folds login in) AND establishes the session in one password-gated, + /// hold-to-confirm decision. Shows the identity, the low-tier categories being + /// granted for silent signing, and the fixed line that money always asks. + fn trust_modal_content( + &mut self, + ui: &mut egui::Ui, + modal: &Modal, + wallet: &Wallet, + cb: &dyn PlatformCallbacks, + ) { + let Some(st) = self.trust.as_mut() else { + Modal::close(); + return; + }; + if st.posting { + Modal::close(); + return; + } + let domain = st.uri.domain.clone(); + let identities = wallet.nostr_identities(); + let display = crate::nostr::session::render_grant(&st.uri.requested_kinds); + let mut go = false; + let mut cancel = false; + ui.vertical_centered(|ui| { + ui.add_space(6.0); + ui.label( + RichText::new(t!("goblin.trust.headline", domain => domain.clone())) + .size(17.0) + .color(Colors::title(false)), + ); + ui.add_space(10.0); + ui.label( + RichText::new(t!("goblin.trust.identity")) + .size(13.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + // Identity picker (defaults to the active identity), the truncated + // npub always shown as the anchor. + if identities.len() > 1 { + for id in &identities { + let selected = st.selected == id.pubkey_hex; + let title = id.display(); + let short = data::short_npub(&id.pubkey_hex); + let row = ui + .scope(|ui| { + ui.horizontal(|ui| { + ui.add_space(4.0); + ui.label( + RichText::new(if selected { + crate::gui::icons::CHECK_CIRCLE + } else { + crate::gui::icons::CIRCLE + }) + .size(18.0) + .color(if selected { + Colors::green() + } else { + Colors::gray() + }), + ); + ui.add_space(8.0); + ui.vertical(|ui| { + if title != short { + ui.label( + RichText::new(&title) + .size(15.0) + .color(Colors::text(false)), + ); + } + ui.label( + RichText::new(&short).size(12.5).color(Colors::gray()), + ); + }); + }); + }) + .response + .rect; + let hit = ui.interact( + row, + egui::Id::from(modal.id).with(("trust_id", id.pubkey_hex.as_str())), + Sense::click(), + ); + if hit + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + st.selected = id.pubkey_hex.clone(); + } + ui.add_space(4.0); + } + } else if let Some(id) = identities.first() { + let title = id.display(); + let short = data::short_npub(&id.pubkey_hex); + if title != short { + ui.label(RichText::new(&title).size(15.0).color(Colors::text(false))); + } + ui.label(RichText::new(&short).size(12.5).color(Colors::gray())); + } + ui.add_space(12.0); + // What the silent grant covers, as human categories. + ui.label( + RichText::new(t!("goblin.trust.grant_intro", domain => domain.clone())) + .size(13.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + for cat in &display.categories { + ui.label( + RichText::new(format!("• {}", t!(cat.key()))) + .size(13.5) + .color(Colors::text(false)), + ); + } + for kind in &display.unknown_kinds { + ui.label( + RichText::new(format!( + "• {}", + t!("goblin.trust.cat_unknown", n => kind.to_string()) + )) + .size(13.5) + .color(Colors::red()), + ); + } + if display.stripped_login { + ui.label( + RichText::new(t!("goblin.trust.login_excluded")) + .size(12.5) + .color(Colors::red()), + ); + } + ui.add_space(10.0); + // The fixed money line: the low-tier grant is not a money grant. + ui.label( + RichText::new(t!("goblin.trust.money_line")) + .size(13.5) + .color(Colors::title(false)), + ); + ui.add_space(8.0); + ui.label( + RichText::new(t!("goblin.trust.duration")) + .size(12.5) + .color(Colors::gray()), + ); + ui.add_space(12.0); + ui.label( + RichText::new(t!("goblin.trust.pass_prompt")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(10.0); + let mut field = TextEdit::new(egui::Id::from(modal.id).with("trust_pass")).password(); + field.ui(ui, &mut st.pass, cb); + if st.pass.is_empty() { + st.wrong_pass = false; + } else if st.wrong_pass { + ui.add_space(10.0); + ui.label( + RichText::new(t!("goblin.advanced.wrong_password")) + .size(16.0) + .color(Colors::red()), + ); + } + ui.add_space(12.0); + }); + // Hold-to-confirm: the single high-value decision cannot be a stray tap. + if self.trust_hold.ui(ui, &t!("goblin.trust.confirm_hold")) { + go = true; + } + ui.add_space(6.0); + ui.scope(|ui| { + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + ui.vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + cancel = true; + }, + ); + }); + ui.add_space(6.0); + }); + if cancel { + self.trust = None; + Modal::close(); + return; + } + if go { + let (pass, selected) = match self.trust.as_ref() { + Some(st) => (st.pass.clone(), st.selected.clone()), + None => return, + }; + if pass.is_empty() { + self.trust_hold = w::HoldToSend::default(); + return; + } + if !wallet.verify_nostr_password(&pass) { + if let Some(st) = self.trust.as_mut() { + st.wrong_pass = true; + } + self.trust_hold = w::HoldToSend::default(); + return; + } + let keys = wallet.nostr_service().and_then(|s| { + s.recv_snapshot() + .into_iter() + .find(|h| h.keys.public_key().to_hex() == selected) + .map(|h| h.keys) + }); + let Some(keys) = keys else { + self.trust = None; + Modal::close(); + return; + }; + let st = self.trust.as_mut().unwrap(); + st.pass.clear(); + st.wrong_pass = false; + // Sign and POST the kind-22242 login event exactly as Build 150 does; + // the session is created by the router once this POST succeeds. + match crate::nostr::loginuri::build_login_event( + &keys, + &st.uri.challenge, + &st.uri.domain, + ) { + Ok(event) => { + st.posting = true; + let callback = st.uri.callback.clone(); + let slot = st.result.clone(); + std::thread::spawn(move || { + let res = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt.block_on(async { + let post = + crate::nostr::loginuri::post_login_event(&callback, &event); + match tokio::time::timeout( + std::time::Duration::from_secs(LOGIN_POST_TIMEOUT_SECS), + post, + ) + .await + { + Ok(r) => r, + Err(_) => Err("timeout".to_string()), + } + }), + Err(e) => Err(e.to_string()), + }; + *slot.lock().unwrap() = Some(res); + }); + Modal::close(); + cb.return_to_caller(); + } + Err(e) => { + log::error!("trust login event signing failed: {e}"); + self.login_toast = Some(( + t!("goblin.trust.failed", domain => domain).to_string(), + std::time::Instant::now(), + )); + self.trust = None; + Modal::close(); + } + } + } + } + + /// The money-tier per-action approval modal: a value-moving sign (or a + /// pay-committing encrypt) arriving over a live session channel. Identical + /// gravity to a v1 authorize — what is being done, which identity, a masked + /// password, hold-to-confirm — raised every time, never silent. + fn money_modal_content( + &mut self, + ui: &mut egui::Ui, + modal: &Modal, + wallet: &Wallet, + cb: &dyn PlatformCallbacks, + ) { + use crate::nostr::session::ChannelOp; + let Some(st) = self.money.as_mut() else { + Modal::close(); + return; + }; + let domain = st.pending.domain.clone(); + let req_id = st.pending.id().to_string(); + let short_id = data::short_npub(&st.pending.identity_pubkey.to_hex()); + // A one-line description of exactly what is being committed to. + let what = match &st.pending.op { + ChannelOp::Sign(req) => { + let label = crate::nostr::authuri::kind_label(req.event.kind); + let (preview, _) = crate::nostr::authuri::content_preview(&req.event.content); + let preview = crate::nostr::authuri::escape_for_display(&preview); + if preview.trim().is_empty() { + t!(label.key(), n => req.event.kind.to_string()).to_string() + } else { + format!( + "{}: {}", + t!(label.key(), n => req.event.kind.to_string()), + preview + ) + } + } + ChannelOp::Encrypt(_) => t!("goblin.money.encrypt_desc").to_string(), + }; + let mut approve = false; + let mut decline = false; + ui.vertical_centered(|ui| { + ui.add_space(6.0); + ui.label( + RichText::new(t!("goblin.money.headline", domain => domain.clone())) + .size(17.0) + .color(Colors::title(false)), + ); + ui.add_space(8.0); + ui.label(RichText::new(&what).size(14.0).color(Colors::text(false))); + ui.add_space(8.0); + ui.label( + RichText::new(t!("goblin.money.identity", id => short_id.clone())) + .size(12.5) + .color(Colors::gray()), + ); + ui.add_space(8.0); + ui.label( + RichText::new(t!("goblin.money.explain")) + .size(13.0) + .color(Colors::gray()), + ); + ui.add_space(10.0); + ui.label( + RichText::new(t!("goblin.money.pass_prompt")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(10.0); + let mut field = TextEdit::new(egui::Id::from(modal.id).with("money_pass")).password(); + field.ui(ui, &mut st.pass, cb); + if st.pass.is_empty() { + st.wrong_pass = false; + } else if st.wrong_pass { + ui.add_space(10.0); + ui.label( + RichText::new(t!("goblin.advanced.wrong_password")) + .size(16.0) + .color(Colors::red()), + ); + } + ui.add_space(12.0); + }); + if self.money_hold.ui(ui, &t!("goblin.money.confirm_hold")) { + approve = true; + } + ui.add_space(6.0); + ui.vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + decline = true; + }, + ); + }); + ui.add_space(6.0); + if decline { + if let Some(svc) = wallet.nostr_service() { + svc.answer_money_prompt(&req_id, false); + } + self.money = None; + Modal::close(); + return; + } + if approve { + let pass = self + .money + .as_ref() + .map(|s| s.pass.clone()) + .unwrap_or_default(); + if pass.is_empty() || !wallet.verify_nostr_password(&pass) { + if let Some(st) = self.money.as_mut() { + st.wrong_pass = true; + } + self.money_hold = w::HoldToSend::default(); + return; + } + if let Some(svc) = wallet.nostr_service() { + svc.answer_money_prompt(&req_id, true); + } + self.money = None; + Modal::close(); + } + } + + /// Trusted Sites: the active Authorize Sessions, what each can sign silently, + /// time remaining, and a one-tap end (immediate, unilateral revocation). + fn trusted_sites_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + _cb: &dyn PlatformCallbacks, + ) { + let t = theme::tokens(); + if self.sub_header(ui, &t!("goblin.trusted_sites.title")) { + self.settings_page = SettingsPage::Main; + return; + } + let summaries = wallet + .nostr_service() + .map(|s| s.session_summaries()) + .unwrap_or_default(); + ScrollArea::vertical() + .id_salt("goblin_trusted_sites_scroll") + .auto_shrink([false; 2]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + ui.label( + RichText::new(t!("goblin.trusted_sites.intro")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(12.0); + if summaries.is_empty() { + ui.label( + RichText::new(t!("goblin.trusted_sites.empty")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + return; + } + let mut to_end: Option = None; + let mut to_resume: Option = None; + for s in &summaries { + settings_group(ui, &s.domain, |ui| { + // The low-tier categories this session signs silently. + let cats: Vec = s + .categories + .iter() + .map(|c| t!(c.key()).to_string()) + .collect(); + let cats = if cats.is_empty() { + t!("goblin.trusted_sites.none").to_string() + } else { + cats.join(", ") + }; + ui.label( + RichText::new(t!("goblin.trusted_sites.can_sign", cats => cats)) + .font(FontId::new(12.5, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(4.0); + let mins = s.ttl_remaining_secs / 60; + ui.label( + RichText::new( + t!("goblin.trusted_sites.time_left", mins => mins.to_string()), + ) + .font(FontId::new(12.5, fonts::regular())) + .color(t.text_dim), + ); + if s.paused { + ui.add_space(4.0); + ui.label( + RichText::new(t!("goblin.trusted_sites.paused")) + .font(FontId::new(12.5, fonts::regular())) + .color(Colors::red()), + ); + if settings_row_btn( + ui, + &t!("goblin.trusted_sites.resume"), + crate::gui::icons::PLAY, + ) { + to_resume = Some(s.domain.clone()); + } + } + if settings_row_btn( + ui, + &t!("goblin.trusted_sites.end"), + crate::gui::icons::X_CIRCLE, + ) { + to_end = Some(s.domain.clone()); + } + }); + ui.add_space(8.0); + } + if let Some(svc) = wallet.nostr_service() { + if let Some(d) = to_end { + svc.end_session(&d); + } + if let Some(d) = to_resume { + svc.resume_session(&d); + } + } + }); + } + /// Draw the transient login-outcome toast: the same quiet pill as the back /// hint (solid soft pill, no border, small dim text), bottom-anchored, /// non-blocking, fading out.