KeysPage::new now takes a single AppContext that owns parent_window, toast_overlay, and the shared daemon Rc<RefCell<Option<Daemon>>>. Phases 9, 10, 11, and 12 GUI tab will all consume this struct. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
178 lines
5.5 KiB
Rust
178 lines
5.5 KiB
Rust
//! "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<Self> {
|
|
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<Self>) {
|
|
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<Self>, 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<Self>) {
|
|
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<Self>, 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<Self>) {
|
|
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<Self>, 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()
|
|
}
|
|
}
|