feat(gui): Recovery tab with list / generate / revoke + one-shot copy dialog

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
michael
2026-04-27 11:18:29 -07:00
parent e19ffc9c9b
commit 008dc160bd
2 changed files with 206 additions and 0 deletions

View File

@@ -9,6 +9,7 @@ mod firstrun;
mod keys_page; mod keys_page;
mod policy_form; mod policy_form;
mod policy_page; mod policy_page;
mod recovery_page;
const APP_ID: &str = "io.dangerousthings.AuthForge"; const APP_ID: &str = "io.dangerousthings.AuthForge";
@@ -64,6 +65,9 @@ fn main() -> glib::ExitCode {
let policy = policy_page::PolicyPage::new(ctx.clone()); let policy = policy_page::PolicyPage::new(ctx.clone());
stack.add_titled(&policy.root, Some("policy"), "Policy"); 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 // 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);

202
gui/src/recovery_page.rs Normal file
View File

@@ -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<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 => {
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<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_list(self: &Rc<Self>, 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<Self>, 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<Self>, 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<Self>, 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<Self>, 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)
}
}