feat(gui): TOTP tab with QR code modal + revoke (feature-gated)
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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
171
gui/src/totp_page.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user