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.
820 lines
30 KiB
Markdown
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(
|
|
>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.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: >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<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.
|