diff --git a/gui/src/main.rs b/gui/src/main.rs index 73c6881..db38e94 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -9,6 +9,7 @@ mod firstrun; mod keys_page; mod policy_form; mod policy_page; +mod recovery_page; const APP_ID: &str = "io.dangerousthings.AuthForge"; @@ -64,6 +65,9 @@ fn main() -> glib::ExitCode { let policy = policy_page::PolicyPage::new(ctx.clone()); stack.add_titled(&policy.root, Some("policy"), "Policy"); + let recovery = recovery_page::RecoveryPage::new(ctx.clone()); + stack.add_titled(&recovery.root, Some("recovery"), "Recovery"); + // 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/recovery_page.rs b/gui/src/recovery_page.rs new file mode 100644 index 0000000..27801a9 --- /dev/null +++ b/gui/src/recovery_page.rs @@ -0,0 +1,202 @@ +//! "Recovery" preferences page. Generate / list / revoke recovery codes. +//! +//! Generated codes are shown in a one-shot copy dialog because the daemon +//! stores Argon2id hashes only — there is no path to recover the plaintext +//! after this dialog closes. + +use crate::app_context::AppContext; +use crate::error::user_message; +use adw::prelude::*; +use authforge_common::types::RecoveryCodeSummary; +use gtk::glib; +use std::rc::Rc; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(crate) struct RecoveryPage { + pub root: adw::Bin, + ctx: AppContext, +} + +impl RecoveryPage { + 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 => { + self.render_disconnected("Connect via the Keys tab first."); + return; + } + }; + match daemon.list_recovery_codes().await { + Ok(entries) => self.render_list(&entries), + Err(e) => self.render_disconnected(&user_message(&e)), + } + } + + 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_list(self: &Rc, entries: &[RecoveryCodeSummary]) { + let pref = adw::PreferencesPage::new(); + let group = adw::PreferencesGroup::builder() + .title("Active recovery codes") + .description(if entries.is_empty() { + "No active recovery codes. Use the form below to issue one." + } else { + "Each code is one-shot and Argon2id-hashed at rest. The 8-digit plaintext is shown once at generation." + }) + .build(); + + for e in entries { + let row = adw::ActionRow::builder() + .title(&e.user) + .subtitle(format!("expires {}", expires_relative(e.expires_unix))) + .build(); + let revoke = gtk::Button::builder() + .label("Revoke") + .css_classes(["destructive-action"]) + .valign(gtk::Align::Center) + .build(); + let me = self.clone(); + let user = e.user.clone(); + revoke.connect_clicked(move |_| { + let me = me.clone(); + let user = user.clone(); + glib::MainContext::default().spawn_local(async move { + me.do_revoke(&user).await; + }); + }); + row.add_suffix(&revoke); + group.add(&row); + } + pref.add(&group); + + let issue_group = adw::PreferencesGroup::new(); + let user_entry = adw::EntryRow::builder().title("Username").build(); + let me = self.clone(); + let entry_clone = user_entry.clone(); + let issue_btn = gtk::Button::builder() + .label("Generate code") + .css_classes(["pill", "suggested-action"]) + .halign(gtk::Align::End) + .valign(gtk::Align::Center) + .build(); + issue_btn.connect_clicked(move |_| { + let me = me.clone(); + let user = entry_clone.text().to_string(); + if user.is_empty() { + me.show_toast("Enter a username first."); + return; + } + glib::MainContext::default().spawn_local(async move { + me.do_generate(&user).await; + }); + }); + issue_group.add(&user_entry); + let issue_row = adw::ActionRow::new(); + issue_row.add_suffix(&issue_btn); + issue_group.add(&issue_row); + pref.add(&issue_group); + + self.root.set_child(Some(&pref)); + } + + async fn do_generate(self: &Rc, user: &str) { + let daemon = match self.ctx.daemon.borrow().clone() { + Some(d) => d, + None => return, + }; + match daemon.generate_recovery_code(user).await { + Ok(code) => { + self.show_code_dialog(user, &code); + self.refresh().await; + } + Err(e) => self.show_toast(&user_message(&e)), + } + } + + async fn do_revoke(self: &Rc, user: &str) { + let daemon = match self.ctx.daemon.borrow().clone() { + Some(d) => d, + None => return, + }; + match daemon.revoke_recovery_code(user).await { + Ok(true) => { + self.show_toast(&format!("Revoked {user}'s recovery code.")); + self.refresh().await; + } + Ok(false) => self.show_toast(&format!("No recovery code for {user}.")), + Err(e) => self.show_toast(&user_message(&e)), + } + } + + fn show_code_dialog(self: &Rc, user: &str, code: &str) { + let dialog = adw::AlertDialog::builder() + .heading(format!("Recovery code for {user}")) + .body("This code is shown only once. Copy it now — it cannot be recovered later.") + .build(); + dialog.add_response("copy", "Copy"); + dialog.add_response("close", "Close"); + dialog.set_default_response(Some("copy")); + dialog.set_close_response("close"); + + let label = gtk::Label::builder() + .label(code) + .css_classes(["title-1"]) + .selectable(true) + .build(); + dialog.set_extra_child(Some(&label)); + + let code_owned = code.to_string(); + dialog.connect_response(None, move |d, resp| { + if resp == "copy" { + if let Some(disp) = gtk::gdk::Display::default() { + disp.clipboard().set_text(&code_owned); + } + } + d.close(); + }); + 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()); + } +} + +fn expires_relative(unix: u64) -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + if unix <= now { + return "expired".to_string(); + } + let delta = unix - now; + if delta >= 24 * 3600 { + format!("in {} day(s)", delta / (24 * 3600)) + } else if delta >= 3600 { + format!("in {} hour(s)", delta / 3600) + } else { + format!("in {} min", delta / 60) + } +}