diff --git a/gui/src/keys_page.rs b/gui/src/keys_page.rs index 5e68df2..35497c1 100644 --- a/gui/src/keys_page.rs +++ b/gui/src/keys_page.rs @@ -1,8 +1,7 @@ //! "Security keys" preferences page. v1 surface: the current user's enrolled //! FIDO2 credentials with per-row remove + a single "Enroll a new key" button. -//! Task 4 lands the empty state + retry banner; Task 5 fills in the list. -use crate::bus::Daemon; +use crate::bus::{current_user, Daemon}; use crate::error::user_message; use adw::prelude::*; use gtk::glib; @@ -63,17 +62,112 @@ impl KeysPage { } async fn render_connected(self: &Rc) { - // Placeholder until Task 5 fills in the list. + let daemon = match self.daemon.borrow().clone() { + Some(d) => d, + None => { + self.render_disconnected("internal: daemon handle missing"); + return; + } + }; + + let user = current_user(); + let creds = match daemon.list_credentials(&user).await { + Ok(v) => v, + Err(e) => { + self.render_disconnected(&user_message(&e)); + return; + } + }; + let page = adw::PreferencesPage::new(); let group = adw::PreferencesGroup::builder() .title("Security keys") - .description("FIDO2 keys enrolled for your account") + .description(if creds.is_empty() { + "No keys enrolled yet. Click below to enroll your first." + } else { + "FIDO2 keys enrolled for your account" + }) .build(); - let placeholder = adw::ActionRow::builder() - .title("(Task 5 will populate this)") + + for c in &creds { + let title = if c.nickname.is_empty() { + c.id.as_str() + } else { + c.nickname.as_str() + }; + let row = adw::ActionRow::builder() + .title(title) + .subtitle(short_id(&c.id)) + .build(); + let remove = gtk::Button::builder() + .icon_name("user-trash-symbolic") + .valign(gtk::Align::Center) + .css_classes(["flat"]) + .tooltip_text("Remove this key") + .build(); + let me = self.clone(); + let cred_id = c.id.clone(); + remove.connect_clicked(move |_| { + let me = me.clone(); + let cred_id = cred_id.clone(); + glib::MainContext::default().spawn_local(async move { + me.remove_credential(&cred_id).await; + }); + }); + row.add_suffix(&remove); + group.add(&row); + } + + let enroll_group = adw::PreferencesGroup::new(); + let enroll = adw::ActionRow::builder() + .title("Enroll a new key") + .activatable(true) .build(); - group.add(&placeholder); + enroll.add_suffix(>k::Image::builder().icon_name("list-add-symbolic").build()); + let me = self.clone(); + enroll.connect_activated(move |_| { + let me = me.clone(); + glib::MainContext::default().spawn_local(async move { + me.start_enroll().await; + }); + }); + enroll_group.add(&enroll); + page.add(&group); + page.add(&enroll_group); self.root.set_child(Some(&page)); } + + async fn remove_credential(self: &Rc, cred_id: &str) { + let daemon = match self.daemon.borrow().clone() { + Some(d) => d, + None => return, + }; + let user = current_user(); + if let Err(e) = daemon.remove_own(&user, cred_id).await { + self.show_toast(&user_message(&e)); + return; + } + self.render_connected().await; + } + + async fn start_enroll(self: &Rc) { + // Stub until Task 6 lands the TouchDialog. Until then, surface the + // intent so manual smoke can see the click was registered. + self.show_toast("Enroll: implemented in Task 6"); + } + + fn show_toast(self: &Rc, msg: &str) { + // ToastOverlay wiring lands in Task 7. Until then write to stderr so + // manual smoke can observe what would be shown. + eprintln!("[gui toast] {msg}"); + } +} + +fn short_id(id: &str) -> String { + if id.len() > 16 { + format!("{}…{}", &id[..8], &id[id.len() - 4..]) + } else { + id.to_string() + } }