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:
@@ -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
202
gui/src/recovery_page.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user