From 5294ef61cebe994f6a59adc7cc836de490083a62 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 08:55:04 -0700 Subject: [PATCH] docs: add Phase 8 GUI Keys tab implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-lane, sequential plan to land the authforge GUI Keys tab — empty-state banner with Retry, list of enrolled credentials with per-row remove, modal TouchDialog driving EnrollOwn against the Phase 3 EnrollmentSucceeded / EnrollmentFailed signals. zbus uses the glib runtime feature (not the workspace tokio config), so all async runs on the GTK main thread via glib::MainContext::spawn_local — no tokio dep in the GUI process. Eight tasks, ~1 day of work. TDD applies to error.rs (pure logic, 4 tests); widget code ships with the manual smoke recipe at gui/TESTING.md as the acceptance gate. --- docs/plans/2026-04-27-phase-8-gui-keys.md | 819 ++++++++++++++++++++++ 1 file changed, 819 insertions(+) create mode 100644 docs/plans/2026-04-27-phase-8-gui-keys.md diff --git a/docs/plans/2026-04-27-phase-8-gui-keys.md b/docs/plans/2026-04-27-phase-8-gui-keys.md new file mode 100644 index 0000000..b3a2b99 --- /dev/null +++ b/docs/plans/2026-04-27-phase-8-gui-keys.md @@ -0,0 +1,819 @@ +# Phase 8: GUI Keys Tab — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Land a working `authforge` GUI that lists the current user's enrolled FIDO2 credentials, enrolls a new key with a touch-prompt modal driven by the daemon's `EnrollmentSucceeded`/`EnrollmentFailed` signals, removes a credential, and degrades gracefully when the daemon is unreachable. + +**Architecture:** Single-window GTK4 + libadwaita app on the user's session, talking to the daemon on the **system bus** at `io.dangerousthings.AuthForge / /io/dangerousthings/AuthForge / io.dangerousthings.AuthForge1`. zbus uses its **glib** integration (`features = ["glib"]`) instead of the workspace tokio config, so all async runs on the GTK main thread via `glib::MainContext::spawn_local`. No tokio dep in the GUI process. + +**Tech Stack:** +- `gtk4-rs` 0.8 (already pinned in `gui/Cargo.toml`). +- `libadwaita` 0.6 (already pinned). +- `zbus = { version = "4", default-features = false, features = ["glib"] }` — **not** `workspace = true`; the workspace dep is tokio-flavored and would conflict. +- `authforge-common = { path = "../common" }` for the shared `Credential` wire type. + +**Reference:** +- Master plan: [2026-04-26-authforge-implementation.md](2026-04-26-authforge-implementation.md) § Phase 8 entry in Parallel Execution Lanes. +- Design doc: [2026-04-26-authforge-design.md](2026-04-26-authforge-design.md) — § Trust boundary (GUI is a thin client; daemon owns all writes). +- Sibling D-Bus client (mirror this pattern, swap runtime): [cli/src/bus.rs](../../cli/src/bus.rs). +- Daemon interface (read-only reference): [daemon/src/dbus.rs](../../daemon/src/dbus.rs). + +--- + +## Conventions + +- One logical change per commit. Conventional prefixes scoped to `gui`: `feat(gui):`, `test(gui):`, `chore(gui):`, `refactor(gui):`. +- Always commit with `--no-gpg-sign`. +- TDD only for `error.rs` (pure logic). Widget code (`keys_page`, `enroll_dialog`, `main`) ships with manual smoke as the acceptance gate — GUI testing requires a display server and is out of scope for v1. +- After each commit: `cargo fmt --all && cargo clippy -p authforge-gui --all-targets -- -D warnings && cargo build -p authforge-gui`. Run `cargo test -p authforge-gui` whenever an `error.rs` test is added. +- User identity is `std::env::var("USER").unwrap_or_else(|_| "unknown".into())` — same heuristic the CLI uses. No `getpwuid` in v1. + +--- + +## Task 1: Add GUI dependencies + +**Files:** +- Modify: `gui/Cargo.toml` + +**Step 1:** Append to `gui/Cargo.toml` `[dependencies]`: + +```toml +zbus = { version = "4", default-features = false, features = ["glib"] } +``` + +(Do **not** use `workspace = true`. The workspace zbus is tokio-flavored — the GUI needs the glib runtime feature, which is mutually exclusive at the zbus crate level.) + +**Step 2:** `cargo build -p authforge-gui`. Expected: clean compile (no usages yet, but the dep resolves). + +**Step 3:** Commit. + +```bash +git add gui/Cargo.toml Cargo.lock +git commit --no-gpg-sign -m "chore(gui): add zbus glib feature for D-Bus client" +``` + +--- + +## Task 2: `error.rs` — zbus-error → user-message helper (TDD) + +**Files:** +- Create: `gui/src/error.rs` +- Modify: `gui/src/main.rs` (add `mod error;`) + +**Step 1:** Add `mod error;` to the top of `gui/src/main.rs` (above `fn main`). + +**Step 2:** Write the failing tests + impl in `gui/src/error.rs`: + +```rust +//! Map zbus errors to short, user-facing strings. The GUI surfaces these in +//! `adw::Toast` and inside the touch-prompt dialog's failure state. +//! +//! Pure logic; testable without a display server. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ErrorKind { + /// Daemon unreachable / bus dropped — caller should swap to disconnected banner. + Disconnected, + /// polkit denied. Daemon's message is usually fine to show verbatim. + AccessDenied, + /// Anything else — daemon returned a typed `Failed(msg)` or similar. + Other, +} + +pub(crate) fn classify(err: &zbus::Error) -> ErrorKind { + match err { + zbus::Error::InputOutput(_) | zbus::Error::Address(_) | zbus::Error::Handshake(_) => { + ErrorKind::Disconnected + } + zbus::Error::FDO(boxed) => match boxed.as_ref() { + zbus::fdo::Error::AccessDenied(_) => ErrorKind::AccessDenied, + zbus::fdo::Error::ServiceUnknown(_) | zbus::fdo::Error::NameHasNoOwner(_) => { + ErrorKind::Disconnected + } + _ => ErrorKind::Other, + }, + _ => ErrorKind::Other, + } +} + +pub(crate) fn user_message(err: &zbus::Error) -> String { + match classify(err) { + ErrorKind::Disconnected => { + "Couldn't reach the authentication daemon. Make sure authforge-daemon is running.".to_string() + } + ErrorKind::AccessDenied => match err { + zbus::Error::FDO(boxed) => match boxed.as_ref() { + zbus::fdo::Error::AccessDenied(msg) => msg.clone(), + _ => "Access denied.".to_string(), + }, + _ => "Access denied.".to_string(), + }, + ErrorKind::Other => err.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn service_unknown_classifies_as_disconnected() { + let e = zbus::Error::FDO(Box::new(zbus::fdo::Error::ServiceUnknown( + "no daemon".to_string(), + ))); + assert_eq!(classify(&e), ErrorKind::Disconnected); + } + + #[test] + fn access_denied_classifies_as_access_denied() { + let e = zbus::Error::FDO(Box::new(zbus::fdo::Error::AccessDenied( + "polkit said no".to_string(), + ))); + assert_eq!(classify(&e), ErrorKind::AccessDenied); + } + + #[test] + fn access_denied_user_message_is_daemon_message_verbatim() { + let e = zbus::Error::FDO(Box::new(zbus::fdo::Error::AccessDenied( + "polkit said no".to_string(), + ))); + assert_eq!(user_message(&e), "polkit said no"); + } + + #[test] + fn disconnected_user_message_mentions_authforge_daemon() { + let e = zbus::Error::FDO(Box::new(zbus::fdo::Error::ServiceUnknown( + "no daemon".to_string(), + ))); + assert!(user_message(&e).contains("authforge-daemon")); + } +} +``` + +**Step 3:** Run `cargo test -p authforge-gui error::tests`. Expected: 4 PASS. + +**Step 4:** `cargo clippy -p authforge-gui --all-targets -- -D warnings`. Clean. + +**Step 5:** Commit. + +```bash +git add gui/src/error.rs gui/src/main.rs +git commit --no-gpg-sign -m "feat(gui): error helper to classify zbus errors and produce user messages" +``` + +--- + +## Task 3: `bus.rs` — D-Bus client wrapper (glib runtime) + +**Files:** +- Create: `gui/src/bus.rs` +- Modify: `gui/src/main.rs` (add `mod bus;`) + +**Background:** Mirror [cli/src/bus.rs](../../cli/src/bus.rs) but on the glib runtime. The GUI only needs `list_credentials`, `enroll_own`, `remove_own`, plus a way to subscribe to the two enrollment signals. Other methods (policy, pending, recovery) are not needed for Phase 8. + +**Step 1:** Add `mod bus;` to `gui/src/main.rs`. + +**Step 2:** Create `gui/src/bus.rs`: + +```rust +//! D-Bus client for the GUI. zbus is configured with `features = ["glib"]` at +//! the crate level, so all futures returned here run on the GTK main thread +//! via `glib::MainContext::spawn_local`. There is no tokio runtime in this +//! process — do not call `tokio::spawn` from anywhere in the GUI. + +use authforge_common::types::Credential; + +const BUS_NAME: &str = "io.dangerousthings.AuthForge"; +const OBJECT_PATH: &str = "/io/dangerousthings/AuthForge"; +const IFACE: &str = "io.dangerousthings.AuthForge1"; + +#[derive(Clone)] +pub(crate) struct Daemon { + proxy: zbus::Proxy<'static>, +} + +impl Daemon { + pub async fn connect() -> zbus::Result { + let conn = zbus::Connection::system().await?; + // CLI uses the same `new` -> `into_owned()` pattern; mirror it. + let proxy = zbus::Proxy::new(&conn, BUS_NAME, OBJECT_PATH, IFACE) + .await? + .into_owned(); + Ok(Self { proxy }) + } + + pub async fn list_credentials(&self, user: &str) -> zbus::Result> { + self.proxy.call("ListCredentials", &(user,)).await + } + + pub async fn enroll_own(&self, user: &str, nickname: &str) -> zbus::Result { + self.proxy.call("EnrollOwn", &(user, nickname)).await + } + + pub async fn remove_own(&self, user: &str, cred_id: &str) -> zbus::Result<()> { + self.proxy.call("RemoveOwn", &(user, cred_id)).await + } + + /// Subscribes to `EnrollmentSucceeded`. The returned `SignalStream` is + /// alive for as long as the caller holds it; the touch dialog drops it + /// when it closes. zbus removes the match rule on drop. + pub async fn subscribe_enrollment_succeeded( + &self, + ) -> zbus::Result> { + self.proxy.receive_signal("EnrollmentSucceeded").await + } + + pub async fn subscribe_enrollment_failed( + &self, + ) -> zbus::Result> { + self.proxy.receive_signal("EnrollmentFailed").await + } +} + +pub(crate) fn current_user() -> String { + std::env::var("USER").unwrap_or_else(|_| "unknown".into()) +} +``` + +> **Note on `Proxy::new(...).into_owned()`:** if the installed zbus 4 minor doesn't expose `into_owned()`, swap to whatever the CLI's [bus.rs](../../cli/src/bus.rs) is doing — that file is a known-working reference for this exact API surface in this exact zbus version. + +**Step 3:** `cargo build -p authforge-gui`. Expected: clean. + +**Step 4:** `cargo clippy -p authforge-gui --all-targets -- -D warnings`. Clean. + +**Step 5:** Commit. + +```bash +git add gui/src/bus.rs gui/src/main.rs +git commit --no-gpg-sign -m "feat(gui): D-Bus client wrapper around AuthForge interface (glib runtime)" +``` + +--- + +## Task 4: `keys_page.rs` — empty state + retry banner + +**Files:** +- Create: `gui/src/keys_page.rs` +- Modify: `gui/src/main.rs` (add `mod keys_page;`) + +**Background:** `KeysPage` is the only visible content in v1. It's an `adw::PreferencesPage` with one `PreferencesGroup`. When the daemon is unreachable, swap the group out for a `StatusPage` with a Retry button. We rebuild the page contents in place (no widget recycling tricks) — Phase 8 doesn't need to optimize redraw cost. + +**Step 1:** Add `mod keys_page;` to `gui/src/main.rs`. + +**Step 2:** Create `gui/src/keys_page.rs`: + +```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::bus::{current_user, Daemon}; +use crate::error::user_message; +use adw::prelude::*; +use gtk::glib; +use std::cell::RefCell; +use std::rc::Rc; + +pub(crate) struct KeysPage { + pub root: adw::Bin, + daemon: Rc>>, + parent_window: gtk::Window, +} + +impl KeysPage { + pub fn new(parent_window: gtk::Window) -> Rc { + let page = Rc::new(Self { + root: adw::Bin::new(), + daemon: Rc::new(RefCell::new(None)), + parent_window, + }); + // Connect attempt fires on construction; if it fails we render the + // disconnected banner. If it succeeds we render the populated page. + 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.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) { + // Placeholder until Task 5 fills in the list. + let page = adw::PreferencesPage::new(); + let group = adw::PreferencesGroup::builder() + .title("Security keys") + .description("FIDO2 keys enrolled for your account") + .build(); + let placeholder = adw::ActionRow::builder() + .title("(Task 5 will populate this)") + .build(); + group.add(&placeholder); + page.add(&group); + self.root.set_child(Some(&page)); + } +} +``` + +**Step 3:** Modify `gui/src/main.rs` to construct and embed `KeysPage` (replacing the StatusPage placeholder body): + +```rust +use adw::prelude::*; +use gtk::glib; + +mod bus; +mod error; +mod keys_page; + +const APP_ID: &str = "io.dangerousthings.AuthForge"; + +fn main() -> glib::ExitCode { + let app = adw::Application::builder().application_id(APP_ID).build(); + app.connect_activate(|app| { + let win = adw::ApplicationWindow::builder() + .application(app) + .default_width(720) + .default_height(540) + .title("Authentication") + .build(); + let header = adw::HeaderBar::new(); + let stack = adw::ViewStack::new(); + let switcher = adw::ViewSwitcher::builder() + .stack(&stack) + .policy(adw::ViewSwitcherPolicy::Wide) + .build(); + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&header); + header.set_title_widget(Some(&switcher)); + + let keys = keys_page::KeysPage::new(win.clone().upcast()); + stack.add_titled_with_icon(&keys.root, Some("keys"), "Keys", "dialog-password-symbolic"); + + toolbar.set_content(Some(&stack)); + win.set_content(Some(&toolbar)); + win.present(); + }); + app.run() +} +``` + +**Step 4:** `cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings`. Clean. + +**Step 5:** Commit. + +```bash +git add gui/src/keys_page.rs gui/src/main.rs +git commit --no-gpg-sign -m "feat(gui): KeysPage scaffold with disconnected-state retry banner" +``` + +--- + +## Task 5: `keys_page.rs` — populate list, refresh, remove + +**Files:** +- Modify: `gui/src/keys_page.rs` + +**Background:** Replace the placeholder row inside `render_connected` with a real loop over `daemon.list_credentials(user).await`, a `gtk::Button` with `destructive-action` class on each row, and a single "Enroll a new key" `ActionRow` at the bottom. Empty list shows a `PreferencesGroup` description hint instead of zero rows. + +**Step 1:** Replace the body of `render_connected` in `gui/src/keys_page.rs`: + +```rust + async fn render_connected(self: &Rc) { + 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(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 row = adw::ActionRow::builder() + .title(if c.nickname.is_empty() { c.id.as_str() } else { c.nickname.as_str() }) + .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); + } + + // Enroll button as its own row at the bottom. + 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.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) { + // Implemented in Task 6 (TouchDialog). Stub here so the button compiles. + self.show_toast("Enroll: implemented in Task 6"); + } + + fn show_toast(self: &Rc, msg: &str) { + // ToastOverlay wiring is added in Task 7 alongside the dialog. Until + // then, write to stderr so manual smoke can see 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() + } +} +``` + +**Step 2:** `cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings`. Clean. + +**Step 3:** Commit. + +```bash +git add gui/src/keys_page.rs +git commit --no-gpg-sign -m "feat(gui): list enrolled credentials with per-row remove + enroll button" +``` + +--- + +## Task 6: `enroll_dialog.rs` — TouchDialog with select! over call & signal + +**Files:** +- Create: `gui/src/enroll_dialog.rs` +- Modify: `gui/src/main.rs` (add `mod enroll_dialog;`) +- Modify: `gui/src/keys_page.rs` — replace `start_enroll` stub with the real call. + +**Background:** Modal `adw::AlertDialog`. On open: spinner + "Touch your security key now" + Cancel button. The dialog spawns ONE glib task that races the `enroll_own` call against the `EnrollmentFailed` signal stream — the call return is the source of truth for success (carries the `Credential`), so we don't need to race against `EnrollmentSucceeded`. On `Ok(_)` close & refresh; on `Err(_)` swap the dialog body to an error message + Close. Cancel just closes the dialog (no `CancelEnrollment` D-Bus method exists yet; daemon enrollment runs to completion or times out on its own). + +**Step 1:** Add `mod enroll_dialog;` to `gui/src/main.rs`. + +**Step 2:** Create `gui/src/enroll_dialog.rs`: + +```rust +//! Modal touch-prompt dialog. Lifetime is one enrollment attempt. + +use crate::bus::{current_user, Daemon}; +use crate::error::user_message; +use adw::prelude::*; +use futures_util::StreamExt; +use gtk::glib; + +/// Present the touch-prompt modal and run an EnrollOwn against the daemon. +/// Returns when the user dismisses the dialog (success, failure, or cancel). +/// `on_success` fires after a successful enrollment so the caller can refresh. +pub(crate) fn present(parent: >k::Window, daemon: Daemon, on_success: impl Fn() + 'static) { + let dialog = adw::AlertDialog::builder() + .heading("Touch your security key") + .body("Touch the metal contact on your security key to enroll it.") + .build(); + dialog.add_response("cancel", "Cancel"); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + + // Spinner inside the dialog body. AlertDialog accepts an extra child via + // set_extra_child. + let spinner = gtk::Spinner::builder() + .spinning(true) + .height_request(48) + .width_request(48) + .halign(gtk::Align::Center) + .build(); + dialog.set_extra_child(Some(&spinner)); + + dialog.present(Some(parent)); + + let dialog_clone = dialog.clone(); + glib::MainContext::default().spawn_local(async move { + let user = current_user(); + // Subscribe BEFORE the call so we don't miss a fast EnrollmentFailed. + let mut failed = match daemon.subscribe_enrollment_failed().await { + Ok(s) => s, + Err(e) => { + show_failure(&dialog_clone, &user_message(&e)); + return; + } + }; + + let call_fut = daemon.enroll_own(&user, "Security Key"); + futures_util::pin_mut!(call_fut); + + let outcome = futures_util::future::select(call_fut, failed.next()).await; + match outcome { + futures_util::future::Either::Left((Ok(_cred), _)) => { + dialog_clone.close(); + on_success(); + } + futures_util::future::Either::Left((Err(e), _)) => { + show_failure(&dialog_clone, &user_message(&e)); + } + futures_util::future::Either::Right((Some(msg), _)) => { + let reason: String = msg + .body() + .deserialize() + .unwrap_or_else(|_| "enrollment failed".into()); + show_failure(&dialog_clone, &reason); + } + futures_util::future::Either::Right((None, _)) => { + show_failure(&dialog_clone, "signal stream closed unexpectedly"); + } + } + }); +} + +fn show_failure(dialog: &adw::AlertDialog, msg: &str) { + dialog.set_heading(Some("Enrollment failed")); + dialog.set_body(msg); + // Drop the spinner; rebuild responses with a single "Close". + dialog.set_extra_child(None::<>k::Widget>); + // AlertDialog buttons aren't dynamically removable; the original "cancel" + // response stays but its label is now "Close". + dialog.set_response_label("cancel", "Close"); +} +``` + +**Step 3:** Add `futures-util` to `gui/Cargo.toml`: + +```toml +futures-util = { workspace = true } +``` + +**Step 4:** Replace the `start_enroll` body in `gui/src/keys_page.rs`: + +```rust + async fn start_enroll(self: &Rc) { + let daemon = match self.daemon.borrow().clone() { + Some(d) => d, + None => return, + }; + let me = self.clone(); + crate::enroll_dialog::present(&self.parent_window, daemon, move || { + let me = me.clone(); + glib::MainContext::default().spawn_local(async move { + me.render_connected().await; + }); + }); + } +``` + +**Step 5:** `cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings`. Clean. + +**Step 6:** Commit. + +```bash +git add gui/src/enroll_dialog.rs gui/src/keys_page.rs gui/src/main.rs gui/Cargo.toml Cargo.lock +git commit --no-gpg-sign -m "feat(gui): TouchDialog modal racing EnrollOwn call against EnrollmentFailed signal" +``` + +--- + +## Task 7: ToastOverlay for error surfacing + +**Files:** +- Modify: `gui/src/main.rs` — wrap content in `adw::ToastOverlay`. +- Modify: `gui/src/keys_page.rs` — accept the overlay handle, replace the stderr `show_toast` stub. + +**Background:** `show_toast` currently writes to stderr (Task 5 placeholder). Wire it through `adw::ToastOverlay`, which is the standard libadwaita pattern for ephemeral notifications. + +**Step 1:** Modify `gui/src/main.rs` — wrap the stack in a ToastOverlay and pass it down: + +```rust + let toast_overlay = adw::ToastOverlay::new(); + let keys = keys_page::KeysPage::new(win.clone().upcast(), toast_overlay.clone()); + stack.add_titled_with_icon(&keys.root, Some("keys"), "Keys", "dialog-password-symbolic"); + toast_overlay.set_child(Some(&stack)); + toolbar.set_content(Some(&toast_overlay)); +``` + +**Step 2:** Modify `gui/src/keys_page.rs` — store the overlay and use it: + +```rust +pub(crate) struct KeysPage { + pub root: adw::Bin, + daemon: Rc>>, + parent_window: gtk::Window, + toast_overlay: adw::ToastOverlay, +} + +impl KeysPage { + pub fn new(parent_window: gtk::Window, toast_overlay: adw::ToastOverlay) -> Rc { + let page = Rc::new(Self { + root: adw::Bin::new(), + daemon: Rc::new(RefCell::new(None)), + parent_window, + toast_overlay, + }); + let p = page.clone(); + glib::MainContext::default().spawn_local(async move { + p.try_connect_and_render().await; + }); + page + } + // ... unchanged through render_connected ... + + fn show_toast(self: &Rc, msg: &str) { + let toast = adw::Toast::builder().title(msg).timeout(5).build(); + self.toast_overlay.add_toast(toast); + } +} +``` + +**Step 3:** `cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings`. Clean. + +**Step 4:** Commit. + +```bash +git add gui/src/keys_page.rs gui/src/main.rs +git commit --no-gpg-sign -m "feat(gui): wire adw::ToastOverlay for error surfacing" +``` + +--- + +## Task 8: Acceptance gate doc + manual smoke recipe + +**Files:** +- Create: `gui/TESTING.md` + +**Step 1:** Create `gui/TESTING.md` with the recipe a maintainer follows on a Yubikey-equipped Ubuntu box: + +```markdown +# Testing the AuthForge GUI + +## Automated gate + +```bash +cargo build -p authforge-gui +cargo clippy -p authforge-gui --all-targets -- -D warnings +cargo test -p authforge-gui # 4 error-classifier tests +``` + +CI runs all three on every push (libgtk-4-dev / libadwaita-1-dev are +installed in the workflow image). + +## Manual smoke + +Requires a USB FIDO2 device (Yubikey 5, Solo, etc.) and a built daemon. + +```bash +# Terminal 1 — daemon (permissive polkit so you don't get prompted). +AUTHFORGE_POLKIT_BYPASS=1 \ + AUTHFORGE_POLICY_DIR=/tmp/af/policy.d \ + AUTHFORGE_PENDING_DIR=/tmp/af/pending \ + AUTHFORGE_USERDB=/tmp/af/users.db \ + target/release/authforged + +# Terminal 2 — GUI. +target/release/authforge +``` + +Walk-through: + +1. Window opens, Keys tab visible. Empty list with hint text "No keys + enrolled yet." +2. Click "Enroll a new key" → modal opens with spinner + "Touch your + security key now". Touch the device. +3. Modal closes, the list re-renders with one row showing the key's + short id. +4. Click the trash icon on the row → row vanishes immediately. +5. Pull the security key, click Enroll → modal opens, swaps to error + text ("authenticator: no FIDO2 device found") with a Close button. +6. Kill the daemon (`pkill authforged`), click Enroll → toast appears + with the disconnected message; Retry on the page reconnects after + restart. +``` + +**Step 2:** Commit. + +```bash +git add gui/TESTING.md +git commit --no-gpg-sign -m "docs(gui): smoke-test recipe and CI gate" +``` + +--- + +## Phase 8 Acceptance Gate + +Verify before tagging: + +- [ ] `cargo build -p authforge-gui` clean. +- [ ] `cargo clippy -p authforge-gui --all-targets -- -D warnings` clean. +- [ ] `cargo test -p authforge-gui` — 4 PASS (error classifier). +- [ ] `cargo fmt --all -- --check` clean. +- [ ] `cargo build --workspace --release` succeeds (the GUI now compiles into the workspace release output). +- [ ] Manual smoke (deferred to live system + Yubikey): all six steps in `gui/TESTING.md` pass. + +Tag the milestone: + +```bash +git tag -a v0.4.0-gui-keys -m "Phase 8: GUI Keys tab" +``` + +--- + +## Risks / known unknowns + +| Risk | Mitigation | +|---|---| +| zbus 4 API drift: `Proxy::new(...).into_owned()` may not be the API in the installed minor. | Mirror whatever pattern [cli/src/bus.rs](../../cli/src/bus.rs) uses — that file is committed and known to work in this exact zbus version. | +| `adw::AlertDialog` button label can't be changed after present in some libadwaita 0.6 patches. | Falls back to leaving the button labeled "Cancel" in the failure state; user-visible body text still conveys the failure. Acceptable until libadwaita 0.7 lets us swap responses dynamically. | +| `glib::MainContext::spawn_local` on a non-Send future in a closure that captures `Rc` — borrow checker may push back at the `RefCell` boundaries. | Each spawned task takes a `clone()` of the `Rc`; the inner `daemon` is `Clone` (just an `Arc` inside zbus's Connection), so we clone it out of the `RefCell` before the await point. | +| `ToastOverlay` wiring requires passing the overlay handle through three constructors. | Acceptable for v1; Phase 9 will introduce a small `AppContext` struct to carry shared handles when the Policy tab joins. | +| `EnrollOwn` blocks for the duration of touch (could be 30s+). The select! against `EnrollmentFailed` correctly handles fast failure, but a slow success keeps the dialog spinning. | Intended behavior — spinner explicitly tells the user to act. No timeout in v1; daemon-side timeout (ctap library default ~30s) governs. | + +--- + +## Execution Handoff + +Plan complete. Single lane, single agent. Suggested execution: open a fresh session in a worktree using `superpowers:using-git-worktrees`, then drive task-by-task with `superpowers:executing-plans`. Or stay in this session and run subagent-driven per task.