Files
authforge/docs/plans/2026-04-27-phase-8-gui-keys.md
michael 5294ef61ce docs: add Phase 8 GUI Keys tab implementation plan
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.
2026-04-27 08:55:04 -07:00

820 lines
30 KiB
Markdown

# 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<Self> {
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<Vec<Credential>> {
self.proxy.call("ListCredentials", &(user,)).await
}
pub async fn enroll_own(&self, user: &str, nickname: &str) -> zbus::Result<Credential> {
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<zbus::proxy::SignalStream<'static>> {
self.proxy.receive_signal("EnrollmentSucceeded").await
}
pub async fn subscribe_enrollment_failed(
&self,
) -> zbus::Result<zbus::proxy::SignalStream<'static>> {
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<RefCell<Option<Daemon>>>,
parent_window: gtk::Window,
}
impl KeysPage {
pub fn new(parent_window: gtk::Window) -> Rc<Self> {
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<Self>) {
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<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>) {
// 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<Self>) {
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(
&gtk::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.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>) {
// Implemented in Task 6 (TouchDialog). Stub here so the button compiles.
self.show_toast("Enroll: implemented in Task 6");
}
fn show_toast(self: &Rc<Self>, 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: &gtk::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::<&gtk::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<Self>) {
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<RefCell<Option<Daemon>>>,
parent_window: gtk::Window,
toast_overlay: adw::ToastOverlay,
}
impl KeysPage {
pub fn new(parent_window: gtk::Window, toast_overlay: adw::ToastOverlay) -> Rc<Self> {
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<Self>, 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<KeysPage>`; 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.