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.
|
#[allow(dead_code)] // wired through keys_page.rs in Task 4.
|
||||||
pub(crate) fn current_user() -> String {
|
pub(crate) fn current_user() -> String {
|
||||||
std::env::var("USER").unwrap_or_else(|_| "unknown".into())
|
std::env::var("USER").unwrap_or_else(|_| "unknown".into())
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ mod keys_page;
|
|||||||
mod policy_form;
|
mod policy_form;
|
||||||
mod policy_page;
|
mod policy_page;
|
||||||
mod recovery_page;
|
mod recovery_page;
|
||||||
|
#[cfg(feature = "totp")]
|
||||||
|
mod totp_page;
|
||||||
|
|
||||||
const APP_ID: &str = "io.dangerousthings.AuthForge";
|
const APP_ID: &str = "io.dangerousthings.AuthForge";
|
||||||
|
|
||||||
@@ -68,6 +70,12 @@ fn main() -> glib::ExitCode {
|
|||||||
let recovery = recovery_page::RecoveryPage::new(ctx.clone());
|
let recovery = recovery_page::RecoveryPage::new(ctx.clone());
|
||||||
stack.add_titled(&recovery.root, Some("recovery"), "Recovery");
|
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
|
// ToolbarView is libadwaita v1_4-gated; the project sticks with the
|
||||||
// simpler Box layout for portability (see commit c6a5e94).
|
// simpler Box layout for portability (see commit c6a5e94).
|
||||||
let layout = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
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