1
0
forked from GRIN/grim

goblin: Authorize Sessions GUI (trust grant, money prompt, Trusted Sites) + 6 locales

- Trust-grant modal: password-gated hold-to-confirm 'Trust <domain>?' 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.
This commit is contained in:
2ro
2026-07-06 17:40:50 -04:00
parent c178f84bf0
commit 5da92d0147
7 changed files with 967 additions and 0 deletions
+39
View File
@@ -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"
+39
View File
@@ -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"
+39
View File
@@ -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é"
+39
View File
@@ -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: "Сменить личность"
+39
View File
@@ -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"
+39
View File
@@ -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: "切换身份"
+733
View File
@@ -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<TrustState>,
/// 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<MoneyState>,
/// 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<std::sync::Mutex<Option<Result<(), String>>>>,
}
/// 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<std::sync::Mutex<Option<Result<(), String>>>>,
}
/// 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<String> = None;
let mut to_resume: Option<String> = None;
for s in &summaries {
settings_group(ui, &s.domain, |ui| {
// The low-tier categories this session signs silently.
let cats: Vec<String> = 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.