Files
authforge/docs/plans/2026-04-27-phase-9-and-12-gui.md
michael de369ef390 docs(plan): step-level plans for Phase 12 backend and Phase 9 + Phase 12 GUI
Phase 12 backend lane is unblocked by Phase 6+7 and runs independently of
Phase 8. Phase 9 + Phase 12 GUI lane gates on Phase 8 landing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 09:14:57 -07:00

35 KiB

Phase 9 (Policy Tab) + Phase 12 GUI (Recovery Tab) — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

EXECUTION GATE: Do not start until Phase 8 has merged to main (the plan at 2026-04-27-phase-8-gui-keys.md lands gui/src/{main,bus,error,keys_page,enroll_dialog}.rs and the Daemon D-Bus client wrapper this lane builds on).

Goal: Add two new tabs to the AuthForge GUI:

  1. Policy tab (Phase 9) — three master AdwComboRows for gdm-password, sudo, sshd (Disabled / Optional / Required), with pre-apply lockout simulation surfaced via inline AdwBanner. An "Advanced…" expander that opens the full stack list (gated by polkit prompt on first toggle).
  2. Recovery tab (Phase 12 GUI half) — generate / list / revoke recovery codes. Shows the freshly-generated 8-digit code in a one-shot copy-to-clipboard dialog (it's only stored hashed, so we cannot show it again).

Architecture:

  • Introduce the AppContext struct foreseen by Phase 8's Task 7 risk note (2026-04-27-phase-8-gui-keys.md:812) — single owner of Daemon and adw::ToastOverlay shared across pages.
  • Each page is a struct PolicyPage { root: adw::Bin, ... } / struct RecoveryPage { root: adw::Bin, ... }, mirroring KeysPage's shape (Rc<Self>, glib::MainContext::spawn_local for async, render swap on disconnect).
  • Extend gui/src/bus.rs Daemon with policy + recovery methods. No new file there — additive only.
  • Subscribe to PolicyChanged D-Bus signal (already emitted by daemon — see daemon/src/dbus.rs:142) so external authforgectl policy set invocations refresh the GUI.
  • The "Advanced…" expander is the polkit prompt trigger — touching any non-master stack toggles the gating, which the daemon enforces via its existing …apply-policy action.

Tech Stack:

  • gtk4-rs + libadwaita (already pinned by Phase 8).
  • zbus with the workspace tokio runtime feature (per 2026-04-27-phase-8-gui-keys.md:23-25, the GUI uses zbus's tokio integration via the workspace dep — no glib feature exists).
  • authforge_common::{policy::{Policy, StackPolicy, StorageBackend}, types::{Mode, Method, Violation, PolicyApplyResult, RecoveryCodeSummary}} for all wire types.

Reference:


Conventions

  • One logical change per commit. Conventional prefixes scoped to gui.
  • Always commit with --no-gpg-sign.
  • TDD only for pure logic in policy_form.rs (mode-mapping helpers, violation rendering). Widget code ships with manual smoke (gui/TESTING.md).
  • After each commit: cargo fmt --all && cargo clippy -p authforge-gui --all-targets -- -D warnings && cargo build -p authforge-gui.
  • Don't pre-emptively refactor KeysPage beyond what the AppContext refactor strictly requires — Phase 8 owns its surface.

Task 1: AppContext struct + KeysPage migration

Files:

  • Create: gui/src/app_context.rs
  • Modify: gui/src/main.rs (construct AppContext, pass to pages)
  • Modify: gui/src/keys_page.rs (constructor takes AppContext instead of bare overlay+window)

Background: Phase 8 punted this refactor to Phase 9. AppContext owns the shared handles every page needs: parent_window, the populated Daemon (or its disconnected state), and toast_overlay. Pages clone the Rc to make their own copies.

Step 1: Create the AppContext

// gui/src/app_context.rs
//! Shared app-wide handles passed to every page constructor.

use crate::bus::Daemon;
use std::cell::RefCell;
use std::rc::Rc;

/// One-per-window struct holding handles every page needs. `daemon` is
/// `RefCell<Option<...>>` because the connect-on-startup attempt may fail
/// and pages render their disconnected banner; a successful Retry repopulates
/// this same cell.
#[derive(Clone)]
pub(crate) struct AppContext {
    pub parent_window: gtk::Window,
    pub toast_overlay: adw::ToastOverlay,
    pub daemon: Rc<RefCell<Option<Daemon>>>,
}

impl AppContext {
    pub fn new(parent_window: gtk::Window, toast_overlay: adw::ToastOverlay) -> Self {
        Self {
            parent_window,
            toast_overlay,
            daemon: Rc::new(RefCell::new(None)),
        }
    }
}

Step 2: Migrate KeysPage::new to take AppContext

In gui/src/keys_page.rs, change:

pub fn new(parent_window: gtk::Window, toast_overlay: adw::ToastOverlay) -> Rc<Self>

to:

pub fn new(ctx: AppContext) -> Rc<Self>

Replace the three field accesses (self.parent_window, self.toast_overlay, self.daemon) with self.ctx.parent_window, self.ctx.toast_overlay, self.ctx.daemon. Drop the duplicate daemon field on KeysPage itself — it now lives in AppContext.

Step 3: Update main.rs to thread the context

// gui/src/main.rs
mod app_context;
mod bus;
mod error;
mod keys_page;
// (Phase 9/12 modules added below — declared once Tasks 2/5 land.)

// ... inside connect_activate ...
let toast_overlay = adw::ToastOverlay::new();
let ctx = app_context::AppContext::new(win.clone().upcast(), toast_overlay.clone());
let keys = keys_page::KeysPage::new(ctx.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 4: Build + clippy

Run: cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings Expected: clean.

Step 5: Commit

git add gui/src/app_context.rs gui/src/main.rs gui/src/keys_page.rs
git commit --no-gpg-sign -m "refactor(gui): introduce AppContext for shared page handles"

Task 2: Extend bus.rs with policy + recovery methods

Files:

  • Modify: gui/src/bus.rs

Background: All additive. The existing Daemon proxy works for any method on io.dangerousthings.AuthForge1.

Step 1: Append to the impl Daemon block

use authforge_common::policy::Policy;
use authforge_common::types::{PolicyApplyResult, RecoveryCodeSummary};

impl Daemon {
    // ... existing methods ...

    pub async fn get_policy(&self) -> zbus::Result<Policy> {
        self.proxy.call("GetPolicy", &()).await
    }

    /// `force=false` runs the daemon's lockout simulator first; on
    /// violations, returns `PolicyApplyResult { applied: false, violations }`
    /// without writing.
    pub async fn set_policy(&self, p: &Policy, force: bool) -> zbus::Result<PolicyApplyResult> {
        self.proxy.call("SetPolicy", &(p, force)).await
    }

    pub async fn generate_recovery_code(&self, user: &str) -> zbus::Result<String> {
        self.proxy.call("GenerateRecoveryCode", &(user,)).await
    }

    pub async fn list_recovery_codes(&self) -> zbus::Result<Vec<RecoveryCodeSummary>> {
        self.proxy.call("ListRecoveryCodes", &()).await
    }

    pub async fn revoke_recovery_code(&self, user: &str) -> zbus::Result<bool> {
        self.proxy.call("RevokeRecoveryCode", &(user,)).await
    }

    pub async fn subscribe_policy_changed(
        &self,
    ) -> zbus::Result<zbus::proxy::SignalStream<'static>> {
        self.proxy.receive_signal("PolicyChanged").await
    }
}

Step 2: Build + clippy

Run: cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings Expected: clean.

Step 3: Commit

git add gui/src/bus.rs
git commit --no-gpg-sign -m "feat(gui): bus client policy + recovery method wrappers"

Task 3: policy_form.rs — pure mode-mapping helpers (TDD)

Files:

  • Create: gui/src/policy_form.rs
  • Modify: gui/src/main.rs (add mod policy_form;)

Background: Pulling pure logic out of the widget code keeps the testable bits isolated. mode_to_combo_index, combo_index_to_mode, and the violation-grouping helper all run without a display server.

Step 1: Write the tests + impl

//! Pure helpers backing the Policy tab. Mode <-> combo index, violation
//! grouping. Widget code calls these; nothing here touches GTK.

use authforge_common::types::{Mode, Violation};
use std::collections::BTreeMap;

pub(crate) const MODE_LABELS: &[(&str, Mode)] = &[
    ("Disabled", Mode::Disabled),
    ("Optional", Mode::Optional),
    ("Required", Mode::Required),
];

pub(crate) fn mode_to_combo_index(m: Mode) -> u32 {
    match m {
        Mode::Disabled => 0,
        Mode::Optional => 1,
        Mode::Required => 2,
    }
}

pub(crate) fn combo_index_to_mode(idx: u32) -> Mode {
    match idx {
        0 => Mode::Disabled,
        1 => Mode::Optional,
        _ => Mode::Required,
    }
}

/// Group violations by stack, joining reasons with "; ". Used to render the
/// inline AdwBanner content when `SetPolicy(force=false)` returns violations.
pub(crate) fn group_violations_by_stack(vs: &[Violation]) -> BTreeMap<String, String> {
    let mut by_stack: BTreeMap<String, Vec<String>> = BTreeMap::new();
    for v in vs {
        by_stack
            .entry(v.stack.clone())
            .or_default()
            .push(format!("{}: {}", v.user, v.reason));
    }
    by_stack
        .into_iter()
        .map(|(k, vs)| (k, vs.join("; ")))
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn mode_combo_roundtrips() {
        for m in [Mode::Disabled, Mode::Optional, Mode::Required] {
            assert_eq!(combo_index_to_mode(mode_to_combo_index(m)), m);
        }
    }

    #[test]
    fn out_of_range_index_clamps_to_required() {
        assert_eq!(combo_index_to_mode(99), Mode::Required);
    }

    #[test]
    fn violations_group_by_stack() {
        let vs = vec![
            Violation { user: "alice".into(), stack: "sudo".into(), reason: "no fido2".into() },
            Violation { user: "bob".into(),   stack: "sudo".into(), reason: "no fido2".into() },
            Violation { user: "alice".into(), stack: "sshd".into(), reason: "no fido2".into() },
        ];
        let g = group_violations_by_stack(&vs);
        assert_eq!(g.len(), 2);
        assert!(g.get("sudo").unwrap().contains("alice"));
        assert!(g.get("sudo").unwrap().contains("bob"));
        assert_eq!(g.get("sshd").unwrap(), "alice: no fido2");
    }
}

Step 2: Run tests

Run: cargo test -p authforge-gui policy_form Expected: 3 PASS.

Step 3: Clippy

Run: cargo clippy -p authforge-gui --all-targets -- -D warnings Expected: clean.

Step 4: Commit

git add gui/src/policy_form.rs gui/src/main.rs
git commit --no-gpg-sign -m "feat(gui): pure-logic helpers for Policy tab (mode mapping, violation grouping)"

Task 4: policy_page.rs — three master toggles + Apply

Files:

  • Create: gui/src/policy_page.rs
  • Modify: gui/src/main.rs (add mod policy_page; + register the tab)

Background: Three AdwComboRows, one per master stack (gdm-password, sudo, sshd). Each row's combo emits to a local working Policy clone; the bottom Apply button calls SetPolicy(force=false). On violations, render an AdwBanner above the page with the grouped messages and a "Force apply" action that re-calls with force=true.

Step 1: Create the page

//! "Policy" preferences page. Three master stacks as AdwComboRow with
//! Disabled/Optional/Required. Pre-apply lockout simulation surfaces via an
//! inline AdwBanner. Advanced expander opens the full stack list (Task 6).

use crate::app_context::AppContext;
use crate::bus::Daemon;
use crate::error::user_message;
use crate::policy_form::{combo_index_to_mode, group_violations_by_stack, mode_to_combo_index, MODE_LABELS};
use adw::prelude::*;
use authforge_common::policy::{Policy, StackPolicy};
use authforge_common::types::{Method, Mode};
use gtk::glib;
use std::cell::RefCell;
use std::rc::Rc;

const MASTER_STACKS: &[&str] = &["gdm-password", "sudo", "sshd"];

pub(crate) struct PolicyPage {
    pub root: adw::Bin,
    ctx: AppContext,
    /// Working copy of policy, mutated by combo callbacks; sent to daemon on Apply.
    working: Rc<RefCell<Policy>>,
}

impl PolicyPage {
    pub fn new(ctx: AppContext) -> Rc<Self> {
        let page = Rc::new(Self {
            root: adw::Bin::new(),
            ctx,
            working: Rc::new(RefCell::new(Policy::default())),
        });
        let p = page.clone();
        glib::MainContext::default().spawn_local(async move {
            p.refresh().await;
        });
        page
    }

    async fn refresh(self: &Rc<Self>) {
        let daemon = match self.ctx.daemon.borrow().clone() {
            Some(d) => d,
            None => {
                self.render_disconnected("Connect via the Keys tab first.");
                return;
            }
        };
        match daemon.get_policy().await {
            Ok(p) => {
                *self.working.borrow_mut() = p.clone();
                self.render_form(&p, None);
            }
            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();
        self.root.set_child(Some(&status));
    }

    fn render_form(self: &Rc<Self>, current: &Policy, banner_msg: Option<String>) {
        let outer = gtk::Box::new(gtk::Orientation::Vertical, 0);

        if let Some(msg) = banner_msg {
            let banner = adw::Banner::builder()
                .title(&msg)
                .button_label("Force apply")
                .revealed(true)
                .build();
            let me = self.clone();
            banner.connect_button_clicked(move |_| {
                let me = me.clone();
                glib::MainContext::default().spawn_local(async move {
                    me.apply(true).await;
                });
            });
            outer.append(&banner);
        }

        let pref_page = adw::PreferencesPage::new();
        let group = adw::PreferencesGroup::builder()
            .title("Where MFA is enforced")
            .description("Disabled = no MFA. Optional = key prompted but not required. Required = blocks login without a key.")
            .build();

        for stack_name in MASTER_STACKS {
            let mode = current
                .stacks
                .get(*stack_name)
                .map(|s| s.mode)
                .unwrap_or(Mode::Disabled);

            let labels: Vec<&str> = MODE_LABELS.iter().map(|(l, _)| *l).collect();
            let model = gtk::StringList::new(&labels);
            let combo = adw::ComboRow::builder()
                .title(*stack_name)
                .model(&model)
                .selected(mode_to_combo_index(mode))
                .build();

            let me = self.clone();
            let stack_owned = stack_name.to_string();
            combo.connect_selected_notify(move |c| {
                let new_mode = combo_index_to_mode(c.selected());
                me.working
                    .borrow_mut()
                    .stacks
                    .entry(stack_owned.clone())
                    .or_insert(StackPolicy {
                        mode: Mode::Disabled,
                        methods: vec![Method::Fido2],
                    })
                    .mode = new_mode;
            });

            group.add(&combo);
        }
        pref_page.add(&group);

        // Apply button row at the bottom.
        let apply_group = adw::PreferencesGroup::new();
        let apply = adw::ActionRow::builder()
            .title("Apply policy")
            .activatable(true)
            .build();
        apply.add_suffix(
            &gtk::Image::builder()
                .icon_name("emblem-ok-symbolic")
                .build(),
        );
        let me = self.clone();
        apply.connect_activated(move |_| {
            let me = me.clone();
            glib::MainContext::default().spawn_local(async move {
                me.apply(false).await;
            });
        });
        apply_group.add(&apply);
        pref_page.add(&apply_group);

        outer.append(&pref_page);
        self.root.set_child(Some(&outer));
    }

    async fn apply(self: &Rc<Self>, force: bool) {
        let daemon = match self.ctx.daemon.borrow().clone() {
            Some(d) => d,
            None => {
                self.show_toast("Daemon unavailable.");
                return;
            }
        };
        let pol = self.working.borrow().clone();
        match daemon.set_policy(&pol, force).await {
            Ok(r) if r.applied => {
                self.show_toast("Policy applied.");
                self.refresh().await;
            }
            Ok(r) => {
                let by_stack = group_violations_by_stack(&r.violations);
                let summary = by_stack
                    .into_iter()
                    .map(|(s, msg)| format!("{s}: {msg}"))
                    .collect::<Vec<_>>()
                    .join(" • ");
                let banner_msg = format!("Would lock users out — {summary}");
                self.render_form(&pol, Some(banner_msg));
            }
            Err(e) => self.show_toast(&user_message(&e)),
        }
    }

    fn show_toast(self: &Rc<Self>, msg: &str) {
        self.ctx
            .toast_overlay
            .add_toast(adw::Toast::builder().title(msg).timeout(5).build());
    }
}

Step 2: Wire into main.rs

let policy = policy_page::PolicyPage::new(ctx.clone());
stack.add_titled_with_icon(&policy.root, Some("policy"), "Policy", "preferences-system-symbolic");

Step 3: Build + clippy

Run: cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings Expected: clean.

Step 4: Commit

git add gui/src/policy_page.rs gui/src/main.rs
git commit --no-gpg-sign -m "feat(gui): Policy tab with three master stacks and lockout-violation banner"

Task 5: PolicyChanged signal subscription → live refresh

Files:

  • Modify: gui/src/policy_page.rs

Background: External authforgectl policy set invocations bypass the GUI; without subscribing the GUI shows stale state. Subscribe once on construction; on every signal call refresh().

Step 1: Spawn the subscriber in PolicyPage::new

After the existing spawn_local(async move { p.refresh().await }), add:

let p = page.clone();
glib::MainContext::default().spawn_local(async move {
    let daemon = match p.ctx.daemon.borrow().clone() {
        Some(d) => d,
        None => return,
    };
    let mut stream = match daemon.subscribe_policy_changed().await {
        Ok(s) => s,
        Err(_) => return,
    };
    use futures_util::StreamExt;
    while stream.next().await.is_some() {
        p.refresh().await;
    }
});

Step 2: Build + clippy

Run: cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings Expected: clean.

Step 3: Commit

git add gui/src/policy_page.rs
git commit --no-gpg-sign -m "feat(gui): PolicyPage subscribes to PolicyChanged for live refresh"

Task 6: Advanced expander — full stack list (polkit-gated)

Files:

  • Modify: gui/src/policy_page.rs

Background: Master toggles cover the 95% case; the remaining 5% (auto-detected stacks) lives in an AdwExpanderRow titled "Advanced…". Toggling any non-master combo triggers the daemon's polkit prompt at SetPolicy time — no special GUI plumbing needed. Daemon's polkit module surfaces the prompt via the system polkit agent.

Step 1: Add an AdwExpanderRow after the master group

In render_form, after pref_page.add(&group) and before the Apply group:

let advanced_group = adw::PreferencesGroup::new();
let expander = adw::ExpanderRow::builder()
    .title("Advanced")
    .subtitle("Per-stack overrides for any PAM stack the daemon knows about.")
    .build();

let mut others: Vec<&String> = current
    .stacks
    .keys()
    .filter(|k| !MASTER_STACKS.contains(&k.as_str()))
    .collect();
others.sort();
for stack_name in others {
    let mode = current.stacks.get(stack_name).map(|s| s.mode).unwrap_or(Mode::Disabled);
    let labels: Vec<&str> = MODE_LABELS.iter().map(|(l, _)| *l).collect();
    let model = gtk::StringList::new(&labels);
    let combo = adw::ComboRow::builder()
        .title(stack_name.as_str())
        .model(&model)
        .selected(mode_to_combo_index(mode))
        .build();
    let me = self.clone();
    let s = stack_name.clone();
    combo.connect_selected_notify(move |c| {
        let new_mode = combo_index_to_mode(c.selected());
        me.working
            .borrow_mut()
            .stacks
            .entry(s.clone())
            .or_insert(StackPolicy { mode: Mode::Disabled, methods: vec![Method::Fido2] })
            .mode = new_mode;
    });
    expander.add_row(&combo);
}

advanced_group.add(&expander);
pref_page.add(&advanced_group);

Step 2: Build + clippy

Run: cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings Expected: clean.

Step 3: Commit

git add gui/src/policy_page.rs
git commit --no-gpg-sign -m "feat(gui): advanced expander surfaces non-master PAM stacks in Policy tab"

Task 7: recovery_page.rs — list + revoke

Files:

  • Create: gui/src/recovery_page.rs
  • Modify: gui/src/main.rs (add mod recovery_page; + register tab)

Background: Mirrors KeysPage shape. List rows show user + "expires in N days/hours" + a revoke button. A "Generate code for…" entry at the bottom prompts for a username and shows the generated code in a one-shot copy dialog.

Step 1: Create the page

//! "Recovery" preferences page. Generate / list / revoke recovery codes.

use crate::app_context::AppContext;
use crate::bus::Daemon;
use crate::error::user_message;
use adw::prelude::*;
use authforge_common::types::RecoveryCodeSummary;
use gtk::glib;
use std::rc::Rc;
use std::time::{SystemTime, UNIX_EPOCH};

pub(crate) struct RecoveryPage {
    pub root: adw::Bin,
    ctx: AppContext,
}

impl RecoveryPage {
    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.refresh().await;
        });
        page
    }

    async fn refresh(self: &Rc<Self>) {
        let daemon = match self.ctx.daemon.borrow().clone() {
            Some(d) => d,
            None => {
                self.render_disconnected("Connect via the Keys tab first.");
                return;
            }
        };
        match daemon.list_recovery_codes().await {
            Ok(entries) => self.render_list(&entries),
            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();
        self.root.set_child(Some(&status));
    }

    fn render_list(self: &Rc<Self>, entries: &[RecoveryCodeSummary]) {
        let pref = adw::PreferencesPage::new();
        let group = adw::PreferencesGroup::builder()
            .title("Active recovery codes")
            .description(if entries.is_empty() {
                "No active recovery codes. Use the form below to issue one."
            } else {
                "Each code is one-shot and Argon2id-hashed at rest. The 8-digit plaintext is shown once at generation."
            })
            .build();

        for e in entries {
            let row = adw::ActionRow::builder()
                .title(&e.user)
                .subtitle(&format!("expires {}", expires_relative(e.expires_unix)))
                .build();
            let revoke = gtk::Button::builder()
                .label("Revoke")
                .css_classes(["destructive-action"])
                .valign(gtk::Align::Center)
                .build();
            let me = self.clone();
            let user = e.user.clone();
            revoke.connect_clicked(move |_| {
                let me = me.clone();
                let user = user.clone();
                glib::MainContext::default().spawn_local(async move {
                    me.do_revoke(&user).await;
                });
            });
            row.add_suffix(&revoke);
            group.add(&row);
        }
        pref.add(&group);

        let issue_group = adw::PreferencesGroup::new();
        let user_entry = adw::EntryRow::builder().title("Username").build();
        let me = self.clone();
        let entry_clone = user_entry.clone();
        let issue_btn = gtk::Button::builder()
            .label("Generate code")
            .css_classes(["pill", "suggested-action"])
            .halign(gtk::Align::End)
            .build();
        issue_btn.connect_clicked(move |_| {
            let me = me.clone();
            let user = entry_clone.text().to_string();
            if user.is_empty() {
                me.show_toast("Enter a username first.");
                return;
            }
            glib::MainContext::default().spawn_local(async move {
                me.do_generate(&user).await;
            });
        });
        issue_group.add(&user_entry);
        let issue_row = adw::ActionRow::new();
        issue_row.add_suffix(&issue_btn);
        issue_group.add(&issue_row);
        pref.add(&issue_group);

        self.root.set_child(Some(&pref));
    }

    async fn do_generate(self: &Rc<Self>, user: &str) {
        let daemon = match self.ctx.daemon.borrow().clone() {
            Some(d) => d,
            None => return,
        };
        match daemon.generate_recovery_code(user).await {
            Ok(code) => {
                self.show_code_dialog(user, &code);
                self.refresh().await;
            }
            Err(e) => self.show_toast(&user_message(&e)),
        }
    }

    async fn do_revoke(self: &Rc<Self>, user: &str) {
        let daemon = match self.ctx.daemon.borrow().clone() {
            Some(d) => d,
            None => return,
        };
        match daemon.revoke_recovery_code(user).await {
            Ok(true) => {
                self.show_toast(&format!("Revoked {user}'s recovery code."));
                self.refresh().await;
            }
            Ok(false) => self.show_toast(&format!("No recovery code for {user}.")),
            Err(e) => self.show_toast(&user_message(&e)),
        }
    }

    fn show_code_dialog(self: &Rc<Self>, user: &str, code: &str) {
        let dialog = adw::AlertDialog::builder()
            .heading(&format!("Recovery code for {user}"))
            .body("This code is shown only once. Copy it now — it cannot be recovered later.")
            .build();
        dialog.add_response("copy", "Copy");
        dialog.add_response("close", "Close");
        dialog.set_default_response(Some("copy"));
        dialog.set_close_response("close");

        let label = gtk::Label::builder()
            .label(code)
            .css_classes(["title-1"])
            .selectable(true)
            .build();
        dialog.set_extra_child(Some(&label));

        let code_owned = code.to_string();
        let parent = self.ctx.parent_window.clone();
        dialog.connect_response(None, move |d, resp| {
            if resp == "copy" {
                if let Some(disp) = gtk::gdk::Display::default() {
                    disp.clipboard().set_text(&code_owned);
                }
            }
            d.close();
        });
        dialog.present(Some(&parent));
    }

    fn show_toast(self: &Rc<Self>, msg: &str) {
        self.ctx
            .toast_overlay
            .add_toast(adw::Toast::builder().title(msg).timeout(5).build());
    }
}

fn expires_relative(unix: u64) -> String {
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    if unix <= now {
        return "expired".to_string();
    }
    let delta = unix - now;
    if delta >= 24 * 3600 {
        format!("in {} day(s)", delta / (24 * 3600))
    } else if delta >= 3600 {
        format!("in {} hour(s)", delta / 3600)
    } else {
        format!("in {} min", delta / 60)
    }
}

Step 2: Wire into main.rs

let recovery = recovery_page::RecoveryPage::new(ctx.clone());
stack.add_titled_with_icon(&recovery.root, Some("recovery"), "Recovery", "emblem-important-symbolic");

Step 3: Build + clippy

Run: cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings Expected: clean.

Step 4: Commit

git add gui/src/recovery_page.rs gui/src/main.rs
git commit --no-gpg-sign -m "feat(gui): Recovery tab with list / generate / revoke + one-shot copy dialog"

Task 8: Acceptance gate — extend gui/TESTING.md

Files:

  • Modify: gui/TESTING.md

Step 1: Append the new manual-smoke walkthroughs

## Policy tab smoke

1. Daemon running (same setup as Keys tab smoke).
2. Open the GUI → Policy tab. Three rows: gdm-password, sudo, sshd. All start at "Disabled" on a clean system.
3. Set sudo to "Required" → Apply. With no enrolled keys you'll see an inline banner "Would lock users out — sudo: <user>: no fido2 enrolled" and a "Force apply" button.
4. Click the trash icon on the row → row vanishes immediately.
5. Switch to Keys tab → enroll a Yubikey → switch back. Apply again with sudo=Required → toast "Policy applied".
6. In another terminal: `authforgectl policy show` should reflect the change.
7. Run `authforgectl policy set sshd disabled --methods fido2` externally → Policy tab refreshes within ~1s (PolicyChanged signal).

## Recovery tab smoke

1. Open Recovery tab. Empty list with hint text.
2. Type "alice" in the username row, click "Generate code". Modal opens showing the 8-digit code with a Copy button. Click Copy → paste somewhere to verify clipboard worked.
3. Close the modal. List now shows alice with "expires in 7 day(s)".
4. Click "Revoke" on alice's row → row vanishes, toast confirms.
5. Generate again, then run `authforgectl recovery list` externally — should show alice with the same expiry.

Step 2: Commit

git add gui/TESTING.md
git commit --no-gpg-sign -m "docs(gui): smoke-test recipes for Policy and Recovery tabs"

Task 9: Master plan closeout note

Files:

  • Modify: docs/plans/2026-04-26-authforge-implementation.md

Step 1: Flip Phase 9 + Phase 12 GUI to Code complete

Update the roadmap rows at lines 39 and 42:

| 9  | GUI: Policy tab + lockout-warning UX | 4 days | ✅ **Code complete** (yyyy-mm-dd) |
| 12 | Recovery flow (codes + emergency unlock) | 4 days | ✅ **Code complete** (yyyy-mm-dd) — backend + GUI both landed |

Step 2: Append a closeout-notes section

### Phase 9 + Phase 12 GUI closeout notes (yyyy-mm-dd)

Landed via the plan at [2026-04-27-phase-9-and-12-gui.md](2026-04-27-phase-9-and-12-gui.md). Built atop the Phase 8 GUI shell.

**Done in Phase 9:**
- `gui/src/app_context.rs``AppContext` struct shared across pages (foreseen by Phase 8 Task 7).
- `gui/src/policy_form.rs` — pure mode-mapping + violation-grouping helpers. 3 unit tests.
- `gui/src/policy_page.rs` — Policy tab with three master stacks (gdm-password / sudo / sshd) as `AdwComboRow`, inline `AdwBanner` for lockout simulation results, "Force apply" action, `AdwExpanderRow` for non-master stacks, live refresh via `PolicyChanged` subscription.

**Done in Phase 12 GUI:**
- `gui/src/recovery_page.rs` — list/generate/revoke. One-shot copy dialog for freshly issued codes (codes are Argon2id-hashed at rest by the daemon — never showable twice).
- `gui/src/bus.rs` extended with `get_policy`, `set_policy`, `generate_recovery_code`, `list_recovery_codes`, `revoke_recovery_code`, `subscribe_policy_changed`.

**Plan deviations:** _(fill in)_.

**Deferred until Phase 14 VM smoke:**
- Manual Policy + Recovery walkthroughs in `gui/TESTING.md` against a real GNOME session.

Step 3: Commit

git add docs/plans/2026-04-26-authforge-implementation.md
git commit --no-gpg-sign -m "docs: phase 9 + phase 12 GUI closeout notes"

Phase 9 + Phase 12 GUI 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 — all error-classifier tests + 3 new policy_form tests PASS.
  • cargo fmt --all -- --check clean.
  • cargo build --workspace --release succeeds.
  • Manual smoke (deferred to live system + Yubikey): Policy and Recovery tab walkthroughs in gui/TESTING.md pass.

Tag the milestone:

git tag -a v0.6.0-gui-policy-recovery -m "Phase 9 + Phase 12 GUI: Policy and Recovery tabs"

Risks / known unknowns

Risk Mitigation
Phase 8's final Daemon shape diverges from what's documented in 2026-04-27-phase-8-gui-keys.md:184-235. EXECUTION GATE waits for Phase 8 to land. If Phase 8 chose a different shape, Task 2 absorbs the rename — purely additive method names map 1:1.
adw::Banner API differs across libadwaita 0.6 minors (set_revealed vs revealed builder). Plan uses the builder + connect_button_clicked signal which has been stable since libadwaita 0.5. If a particular minor drops it, fall back to a top-of-page gtk::InfoBar.
PolicyChanged signal uses zbus tokio runtime here whereas Phase 8 may have inadvertently picked the glib feature. The Phase 8 plan was patched (commit a17f70c) to clarify "no glib feature exists" — this lane uses workspace = true for zbus, matching what Phase 8 lands. Verify at execution time by reading gui/Cargo.toml after Phase 8 merges.
Clipboard set on a non-default display fails silently. Acceptable — the dialog also shows the code in a selectable label, so the user can copy by hand. Toast doesn't fire because no error is returned by gdk::Clipboard::set_text.
AdwEntryRow::text() returns a glib::GString; needs .to_string() for to_owned. Plan already does this; flagged so future edits don't accidentally borrow across an await.
Daemon's lockout simulator (Phase 5) returns Vec<Violation> but doesn't currently include user-friendly fix suggestions — only "no fido2 enrolled". The banner copy in Task 4 reflects this: it lists the violation reasons verbatim. A richer "fix it" UX is a Phase 17/18 polish item, not Phase 9.

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. Do not start until Phase 8 lands on main.