//! "Security keys" preferences page. v1 surface: the current user's enrolled //! FIDO2 credentials with per-row remove + a single "Enroll a new key" button. 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 KeysPage { pub root: adw::Bin, ctx: AppContext, } impl KeysPage { 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.try_connect_and_render().await; }); page } async fn try_connect_and_render(self: Rc) { match Daemon::connect().await { Ok(d) => { *self.ctx.daemon.borrow_mut() = Some(d); self.render_connected().await; } 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(); let retry = gtk::Button::builder() .label("Retry") .css_classes(["pill", "suggested-action"]) .halign(gtk::Align::Center) .build(); let me = self.clone(); retry.connect_clicked(move |_| { let me = me.clone(); glib::MainContext::default().spawn_local(async move { me.try_connect_and_render().await; }); }); status.set_child(Some(&retry)); self.root.set_child(Some(&status)); } async fn render_connected(self: &Rc) { let daemon = match self.ctx.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(if creds.is_empty() { "No keys enrolled yet. Click below to enroll your first." } else { "FIDO2 keys enrolled for your account" }) .build(); 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(); 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.ctx.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) { let daemon = match self.ctx.daemon.borrow().clone() { Some(d) => d, None => return, }; let me = self.clone(); crate::enroll_dialog::present(&self.ctx.parent_window, daemon, move || { let me = me.clone(); glib::MainContext::default().spawn_local(async move { me.render_connected().await; }); }); } fn show_toast(self: &Rc, msg: &str) { let toast = adw::Toast::builder().title(msg).timeout(5).build(); self.ctx.toast_overlay.add_toast(toast); } } fn short_id(id: &str) -> String { if id.len() > 16 { format!("{}…{}", &id[..8], &id[id.len() - 4..]) } else { id.to_string() } }