From 8060364973c647e1e8e828767c9c5391fe4f944e Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 12:10:54 -0700 Subject: [PATCH] feat(gui): TOTP tab with QR code modal + revoke (feature-gated) --- gui/src/bus.rs | 18 +++++ gui/src/main.rs | 8 ++ gui/src/totp_page.rs | 171 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+) create mode 100644 gui/src/totp_page.rs diff --git a/gui/src/bus.rs b/gui/src/bus.rs index ac487ae..82185d4 100644 --- a/gui/src/bus.rs +++ b/gui/src/bus.rs @@ -88,6 +88,24 @@ impl Daemon { } } +#[cfg(feature = "totp")] +impl Daemon { + pub async fn enroll_totp( + &self, + user: &str, + ) -> zbus::Result { + self.proxy.call("EnrollTotp", &(user,)).await + } + + pub async fn is_totp_enrolled(&self, user: &str) -> zbus::Result { + self.proxy.call("IsTotpEnrolled", &(user,)).await + } + + pub async fn revoke_totp(&self, user: &str) -> zbus::Result { + self.proxy.call("RevokeTotp", &(user,)).await + } +} + #[allow(dead_code)] // wired through keys_page.rs in Task 4. pub(crate) fn current_user() -> String { std::env::var("USER").unwrap_or_else(|_| "unknown".into()) diff --git a/gui/src/main.rs b/gui/src/main.rs index db38e94..338e5f9 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -10,6 +10,8 @@ mod keys_page; mod policy_form; mod policy_page; mod recovery_page; +#[cfg(feature = "totp")] +mod totp_page; const APP_ID: &str = "io.dangerousthings.AuthForge"; @@ -68,6 +70,12 @@ fn main() -> glib::ExitCode { let recovery = recovery_page::RecoveryPage::new(ctx.clone()); stack.add_titled(&recovery.root, Some("recovery"), "Recovery"); + #[cfg(feature = "totp")] + { + let totp = totp_page::TotpPage::new(ctx.clone()); + stack.add_titled(&totp.root, Some("totp"), "TOTP"); + } + // ToolbarView is libadwaita v1_4-gated; the project sticks with the // simpler Box layout for portability (see commit c6a5e94). let layout = gtk::Box::new(gtk::Orientation::Vertical, 0); diff --git a/gui/src/totp_page.rs b/gui/src/totp_page.rs new file mode 100644 index 0000000..a0b02e5 --- /dev/null +++ b/gui/src/totp_page.rs @@ -0,0 +1,171 @@ +//! "TOTP" preferences page. Single-user (current user via $USER). +//! Feature-gated: only built when --features=totp is on (default). + +use crate::app_context::AppContext; +use crate::bus::{current_user, Daemon}; +use crate::error::user_message; +use adw::prelude::*; +use gtk::glib; +use std::rc::Rc; + +pub(crate) struct TotpPage { + pub root: adw::Bin, + ctx: AppContext, +} + +impl TotpPage { + pub fn new(ctx: AppContext) -> Rc { + let page = Rc::new(Self { + root: adw::Bin::new(), + ctx, + }); + let p = page.clone(); + glib::MainContext::default().spawn_local(async move { + p.refresh().await; + }); + page + } + + async fn refresh(self: &Rc) { + let daemon = match self.ctx.daemon.borrow().clone() { + Some(d) => d, + None => { + // The Keys page is the connect entry point — it populates + // ctx.daemon. If it hasn't run / failed, prompt the user there. + self.render_disconnected("Connect via the Keys tab first."); + return; + } + }; + let user = current_user(); + let enrolled = match daemon.is_totp_enrolled(&user).await { + Ok(b) => b, + Err(e) => { + self.render_disconnected(&user_message(&e)); + return; + } + }; + self.render(daemon, &user, enrolled); + } + + fn render_disconnected(self: &Rc, msg: &str) { + let status = adw::StatusPage::builder() + .icon_name("network-offline-symbolic") + .title("Daemon unavailable") + .description(msg) + .build(); + self.root.set_child(Some(&status)); + } + + fn render(self: &Rc, daemon: Daemon, user: &str, enrolled: bool) { + let pref = adw::PreferencesPage::new(); + let group = adw::PreferencesGroup::builder() + .title("Authenticator app (TOTP)") + .description(if enrolled { + "Your account is enrolled. Use your authenticator app's 6-digit code at the password prompt." + } else { + "Enroll a TOTP secret to use a phone authenticator app (Aegis, Authenticator, etc.) as a second factor." + }) + .build(); + + let row = adw::ActionRow::builder() + .title(if enrolled { "Enrolled" } else { "Not enrolled" }) + .subtitle(user) + .build(); + + let btn = if enrolled { + let b = gtk::Button::builder() + .label("Revoke") + .css_classes(["destructive-action"]) + .valign(gtk::Align::Center) + .build(); + let me = self.clone(); + let user_owned = user.to_string(); + let daemon_owned = daemon.clone(); + b.connect_clicked(move |_| { + let me = me.clone(); + let user = user_owned.clone(); + let daemon = daemon_owned.clone(); + glib::MainContext::default().spawn_local(async move { + match daemon.revoke_totp(&user).await { + Ok(true) => { + me.show_toast("TOTP enrollment revoked."); + me.refresh().await; + } + Ok(false) => me.show_toast("Was not enrolled."), + Err(e) => me.show_toast(&user_message(&e)), + } + }); + }); + b + } else { + let b = gtk::Button::builder() + .label("Enroll") + .css_classes(["pill", "suggested-action"]) + .valign(gtk::Align::Center) + .build(); + let me = self.clone(); + let user_owned = user.to_string(); + let daemon_owned = daemon.clone(); + b.connect_clicked(move |_| { + let me = me.clone(); + let user = user_owned.clone(); + let daemon = daemon_owned.clone(); + glib::MainContext::default().spawn_local(async move { + match daemon.enroll_totp(&user).await { + Ok(e) => { + me.show_qr_dialog(&e.otpauth_uri, &e.secret_b32); + me.refresh().await; + } + Err(err) => me.show_toast(&user_message(&err)), + } + }); + }); + b + }; + row.add_suffix(&btn); + group.add(&row); + pref.add(&group); + self.root.set_child(Some(&pref)); + } + + fn show_qr_dialog(self: &Rc, otpauth_uri: &str, secret_b32: &str) { + let dialog = adw::AlertDialog::builder() + .heading("Scan with your authenticator app") + .body(format!("Or enter this secret manually: {secret_b32}")) + .build(); + dialog.add_response("close", "Done"); + dialog.set_default_response(Some("close")); + dialog.set_close_response("close"); + + // Render the QR as SVG, then attempt to load it as a gdk::Texture. + // gdk4 0.8.2 has no `Texture::from_bytes`; route the SVG through + // gdk-pixbuf's stream loader (librsvg-backed) and wrap the resulting + // Pixbuf as a Texture via `for_pixbuf`. If the SVG loader isn't + // installed, Pixbuf creation fails and we silently skip the visual — + // the secret_b32 + URI in the body remain a working manual-entry + // fallback. + let qr = + qrcode::QrCode::new(otpauth_uri.as_bytes()).expect("otpauth URI is QR-encodable"); + let svg = qr + .render::>() + .min_dimensions(256, 256) + .build(); + let bytes = gtk::glib::Bytes::from(svg.as_bytes()); + let stream = gtk::gio::MemoryInputStream::from_bytes(&bytes); + if let Ok(pixbuf) = + gtk::gdk_pixbuf::Pixbuf::from_stream(&stream, gtk::gio::Cancellable::NONE) + { + let tex = gtk::gdk::Texture::for_pixbuf(&pixbuf); + let pic = gtk::Picture::for_paintable(&tex); + pic.set_size_request(256, 256); + dialog.set_extra_child(Some(&pic)); + } + dialog.present(&self.ctx.parent_window); + } + + fn show_toast(self: &Rc, msg: &str) { + self.ctx + .toast_overlay + .add_toast(adw::Toast::builder().title(msg).timeout(5).build()); + } +}