feat(gui): TOTP tab with QR code modal + revoke (feature-gated)

This commit is contained in:
michael
2026-04-27 12:10:54 -07:00
parent 85c8f29b68
commit 8060364973
3 changed files with 197 additions and 0 deletions

View File

@@ -88,6 +88,24 @@ impl Daemon {
}
}
#[cfg(feature = "totp")]
impl Daemon {
pub async fn enroll_totp(
&self,
user: &str,
) -> zbus::Result<authforge_common::types::TotpEnrollment> {
self.proxy.call("EnrollTotp", &(user,)).await
}
pub async fn is_totp_enrolled(&self, user: &str) -> zbus::Result<bool> {
self.proxy.call("IsTotpEnrolled", &(user,)).await
}
pub async fn revoke_totp(&self, user: &str) -> zbus::Result<bool> {
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())

View File

@@ -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);

171
gui/src/totp_page.rs Normal file
View File

@@ -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<Self> {
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<Self>) {
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<Self>, 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<Self>, 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<Self>, 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::<qrcode::render::svg::Color<'_>>()
.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<Self>, msg: &str) {
self.ctx
.toast_overlay
.add_toast(adw::Toast::builder().title(msg).timeout(5).build());
}
}