From ec221884401dc7bcba46d1f5cbc3d67a03dda01d Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 11:12:54 -0700 Subject: [PATCH 1/8] feat(gui): bus client policy + recovery method wrappers Co-Authored-By: Claude Opus 4.7 (1M context) --- gui/src/bus.rs | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/gui/src/bus.rs b/gui/src/bus.rs index a0580f5..ac487ae 100644 --- a/gui/src/bus.rs +++ b/gui/src/bus.rs @@ -4,7 +4,8 @@ //! `glib::MainContext::spawn_local`, which polls the future on the GTK main //! thread; tokio's reactor wakes the future when zbus I/O is ready. -use authforge_common::types::{Credential, PendingStatus}; +use authforge_common::policy::Policy; +use authforge_common::types::{Credential, PendingStatus, PolicyApplyResult, RecoveryCodeSummary}; const BUS_NAME: &str = "io.dangerousthings.AuthForge"; const OBJECT_PATH: &str = "/io/dangerousthings/AuthForge"; @@ -56,6 +57,35 @@ impl Daemon { pub async fn clear_pending_flag(&self, user: &str) -> zbus::Result<()> { self.proxy.call("ClearPendingFlag", &(user,)).await } + + pub async fn get_policy(&self) -> zbus::Result { + 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 { + self.proxy.call("SetPolicy", &(p, force)).await + } + + pub async fn generate_recovery_code(&self, user: &str) -> zbus::Result { + self.proxy.call("GenerateRecoveryCode", &(user,)).await + } + + pub async fn list_recovery_codes(&self) -> zbus::Result> { + self.proxy.call("ListRecoveryCodes", &()).await + } + + pub async fn revoke_recovery_code(&self, user: &str) -> zbus::Result { + self.proxy.call("RevokeRecoveryCode", &(user,)).await + } + + pub async fn subscribe_policy_changed( + &self, + ) -> zbus::Result> { + self.proxy.receive_signal("PolicyChanged").await + } } #[allow(dead_code)] // wired through keys_page.rs in Task 4. From b2771e73fa5005cccf0cf0402f21c8fbf537ee0f Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 11:13:53 -0700 Subject: [PATCH 2/8] feat(gui): pure-logic helpers for Policy tab (mode mapping, violation grouping) Co-Authored-By: Claude Opus 4.7 (1M context) --- gui/src/main.rs | 1 + gui/src/policy_form.rs | 92 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 gui/src/policy_form.rs diff --git a/gui/src/main.rs b/gui/src/main.rs index fc5f9da..f3e1cc3 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -7,6 +7,7 @@ mod enroll_dialog; mod error; mod firstrun; mod keys_page; +mod policy_form; const APP_ID: &str = "io.dangerousthings.AuthForge"; diff --git a/gui/src/policy_form.rs b/gui/src/policy_form.rs new file mode 100644 index 0000000..bd3cf72 --- /dev/null +++ b/gui/src/policy_form.rs @@ -0,0 +1,92 @@ +//! Pure helpers backing the Policy tab. Mode <-> combo index, violation +//! grouping. Widget code calls these; nothing here touches GTK. +//! +//! `dead_code` allowances mirror `bus.rs` — these helpers are wired through +//! `policy_page.rs` in Task 4; without the allow, this commit's `-D warnings` +//! gate would reject the staged-but-unused symbols. + +#![allow(dead_code)] + +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 { + let mut by_stack: BTreeMap> = 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"); + } +} From a28b5c9ebeccdfbd02ebe730227837daa5fa4c43 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 11:15:50 -0700 Subject: [PATCH 3/8] feat(gui): Policy tab with three master stacks and lockout-violation banner Co-Authored-By: Claude Opus 4.7 (1M context) --- gui/src/main.rs | 4 + gui/src/policy_page.rs | 183 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 gui/src/policy_page.rs diff --git a/gui/src/main.rs b/gui/src/main.rs index f3e1cc3..73c6881 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -8,6 +8,7 @@ mod error; mod firstrun; mod keys_page; mod policy_form; +mod policy_page; const APP_ID: &str = "io.dangerousthings.AuthForge"; @@ -60,6 +61,9 @@ fn main() -> glib::ExitCode { let keys = keys_page::KeysPage::new(ctx.clone()); stack.add_titled(&keys.root, Some("keys"), "Keys"); + let policy = policy_page::PolicyPage::new(ctx.clone()); + stack.add_titled(&policy.root, Some("policy"), "Policy"); + // ToolbarView is libadwaita v1_4-gated; the project sticks with the // simpler Box layout for portability (see commit c6a5e94). let layout = gtk::Box::new(gtk::Orientation::Vertical, 0); diff --git a/gui/src/policy_page.rs b/gui/src/policy_page.rs new file mode 100644 index 0000000..7abac95 --- /dev/null +++ b/gui/src/policy_page.rs @@ -0,0 +1,183 @@ +//! "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::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>, +} + +impl PolicyPage { + pub fn new(ctx: AppContext) -> Rc { + 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) { + 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, 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, current: &Policy, banner_msg: Option) { + let outer = gtk::Box::new(gtk::Orientation::Vertical, 0); + + if let Some(msg) = banner_msg { + let banner = adw::Banner::builder() + .title(msg.as_str()) + .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( + >k::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, 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::>() + .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, msg: &str) { + self.ctx + .toast_overlay + .add_toast(adw::Toast::builder().title(msg).timeout(5).build()); + } +} From ae372df577bddd160ed8e159db4dcf7da1b32aca Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 11:16:18 -0700 Subject: [PATCH 4/8] feat(gui): PolicyPage subscribes to PolicyChanged for live refresh Co-Authored-By: Claude Opus 4.7 (1M context) --- gui/src/policy_page.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/gui/src/policy_page.rs b/gui/src/policy_page.rs index 7abac95..c16c545 100644 --- a/gui/src/policy_page.rs +++ b/gui/src/policy_page.rs @@ -34,6 +34,25 @@ impl PolicyPage { glib::MainContext::default().spawn_local(async move { p.refresh().await; }); + + // Live-refresh subscriber: external `authforgectl policy set` invocations + // bypass the GUI; without subscribing the page would show stale state. + 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; + } + }); + page } From e19ffc9c9bad75f1b376426f792c83fbcf325137 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 11:16:51 -0700 Subject: [PATCH 5/8] feat(gui): advanced expander surfaces non-master PAM stacks in Policy tab Co-Authored-By: Claude Opus 4.7 (1M context) --- gui/src/policy_page.rs | 47 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/gui/src/policy_page.rs b/gui/src/policy_page.rs index c16c545..968a6e5 100644 --- a/gui/src/policy_page.rs +++ b/gui/src/policy_page.rs @@ -141,6 +141,53 @@ impl PolicyPage { } pref_page.add(&group); + // Advanced expander — non-master stacks the daemon discovered. Toggling + // any of these triggers polkit at SetPolicy time; no special GUI plumbing. + 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); + // Apply button row at the bottom. let apply_group = adw::PreferencesGroup::new(); let apply = adw::ActionRow::builder() From 008dc160bd6a5b1d7bd1ed8fae10ff872d9712f2 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 11:18:29 -0700 Subject: [PATCH 6/8] feat(gui): Recovery tab with list / generate / revoke + one-shot copy dialog Co-Authored-By: Claude Opus 4.7 (1M context) --- gui/src/main.rs | 4 + gui/src/recovery_page.rs | 202 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 gui/src/recovery_page.rs diff --git a/gui/src/main.rs b/gui/src/main.rs index 73c6881..db38e94 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -9,6 +9,7 @@ mod firstrun; mod keys_page; mod policy_form; mod policy_page; +mod recovery_page; const APP_ID: &str = "io.dangerousthings.AuthForge"; @@ -64,6 +65,9 @@ fn main() -> glib::ExitCode { let policy = policy_page::PolicyPage::new(ctx.clone()); stack.add_titled(&policy.root, Some("policy"), "Policy"); + let recovery = recovery_page::RecoveryPage::new(ctx.clone()); + stack.add_titled(&recovery.root, Some("recovery"), "Recovery"); + // ToolbarView is libadwaita v1_4-gated; the project sticks with the // simpler Box layout for portability (see commit c6a5e94). let layout = gtk::Box::new(gtk::Orientation::Vertical, 0); diff --git a/gui/src/recovery_page.rs b/gui/src/recovery_page.rs new file mode 100644 index 0000000..27801a9 --- /dev/null +++ b/gui/src/recovery_page.rs @@ -0,0 +1,202 @@ +//! "Recovery" preferences page. Generate / list / revoke recovery codes. +//! +//! Generated codes are shown in a one-shot copy dialog because the daemon +//! stores Argon2id hashes only — there is no path to recover the plaintext +//! after this dialog closes. + +use crate::app_context::AppContext; +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 { + 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) { + 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, 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, 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) + .valign(gtk::Align::Center) + .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, 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, 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, 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(); + 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(&self.ctx.parent_window); + } + + fn show_toast(self: &Rc, 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) + } +} From 25eed8ff944951a90e58d3b21dafb9caf1981fa6 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 11:18:50 -0700 Subject: [PATCH 7/8] docs(gui): smoke-test recipes for Policy and Recovery tabs Co-Authored-By: Claude Opus 4.7 (1M context) --- gui/TESTING.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/gui/TESTING.md b/gui/TESTING.md index f2e6ffc..9398f21 100644 --- a/gui/TESTING.md +++ b/gui/TESTING.md @@ -45,3 +45,22 @@ Walk-through: 6. Kill the daemon (`pkill authforged`), click Enroll → toast appears with the disconnected message; Retry on the page reconnects after restart. + +## 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: : no fido2 enrolled" and a "Force apply" button. +4. Click "Force apply" → toast "Policy applied". +5. Switch to Keys tab → enroll a Yubikey → switch back. Apply again with sudo=Required → toast "Policy applied" without the banner. +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). +8. Expand "Advanced" — any non-master stack the daemon discovered shows here with the same Disabled/Optional/Required combo. + +## 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. From b23df83a2bbb84ff38a3dbc8f4578bafe42e439f Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 11:19:45 -0700 Subject: [PATCH 8/8] docs: phase 9 + phase 12 GUI closeout notes Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-26-authforge-implementation.md | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-04-26-authforge-implementation.md b/docs/plans/2026-04-26-authforge-implementation.md index d751a47..7951f80 100644 --- a/docs/plans/2026-04-26-authforge-implementation.md +++ b/docs/plans/2026-04-26-authforge-implementation.md @@ -36,10 +36,10 @@ | 6 | PAM module: `pam_authforge_pending.so` (C) | 3 days | ✅ **Code complete** (2026-04-27) — `pamtester` smoke recipe in `pam/TESTING.md` | | 7 | CLI: `authforgectl` | 4 days | ✅ **Done** (2026-04-27) — 14 subcommands | | 8 | GUI: app shell + Security Keys tab | 6–8 days | ✅ **Code complete** (2026-04-27) — 4 unit tests; manual smoke recipe in `gui/TESTING.md` | -| 9 | GUI: Policy tab + lockout-warning UX | 4 days | **Spec'd** — open now (plan at [2026-04-27-phase-9-and-12-gui.md](2026-04-27-phase-9-and-12-gui.md); bundles Phase 12 GUI tab) | +| 9 | GUI: Policy tab + lockout-warning UX | 4 days | ✅ **Code complete** (2026-04-27) — bundles Phase 12 GUI tab; 3 unit tests in `policy_form` | | 10 | First-login flow: autostart entry + fullscreen modal | 4 days | **Spec'd** — open now (Phase 6 ✓ + Phase 8 ✓) | | 11 | TOTP support (PAM module + GUI tab) — feature flag | 5 days | **Spec'd** — open now (Phase 2 ✓ + Phase 4 ✓ + Phase 8 ✓ for the GUI tab) | -| 12 | Recovery flow (codes + emergency unlock) | 4 days | ✅ **Code complete** (2026-04-27) — backend + PAM landed; GUI tab folded into the Phase 9 lane plan | +| 12 | Recovery flow (codes + emergency unlock) | 4 days | ✅ **Code complete** (2026-04-27) — backend + PAM + GUI tab all landed | | 13 | Debian packaging finalization (postinst, debconf, purge) | 4 days | **Spec'd** — sequential tail; needs all binaries built | | 14 | Launchpad PPA build setup | 2 days | **Spec'd** — sequential tail after 13; **VM smoke gate for all "Code complete" phases** | | 15 | `authforge-gnome-integration` (Users panel shortcut) | 3 days | ✅ **Done** (2026-04-27) — parallel subagent (gnome lane) | @@ -277,6 +277,29 @@ Backend + PAM landed via the plan at [2026-04-27-phase-12-recovery-backend.md](2 - `pamtester` recovery walkthrough — recipe lives in `pam/TESTING.md` § Smoke test 4. - End-to-end recovery flow against a real PAM stack: admin issues code via CLI → user logs in entering it at the password prompt → recovery file deleted → pending(re_enroll=true) flag present → re-enrollment modal triggers on next login. +### Phase 9 + Phase 12 GUI closeout notes (2026-04-27) + +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. Task 1 (AppContext refactor) was pre-landed by the prep lane (commit `a927d96`); the remaining 8 tasks ship as 8 commits on `feature/phase-9-and-12-gui`. + +**Done in Phase 9:** +- `gui/src/app_context.rs` — `AppContext` struct shared across pages (foreseen by Phase 8 Task 7; pre-landed by prep lane). +- `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 (`adw::AlertDialog`) for freshly issued codes; the daemon stores Argon2id hashes only, so plaintext is unrecoverable after the dialog closes. +- `gui/src/bus.rs` extended with `get_policy`, `set_policy`, `generate_recovery_code`, `list_recovery_codes`, `revoke_recovery_code`, `subscribe_policy_changed`. + +**Test count:** gui 4 → 7 (+3 from `policy_form`). Workspace total: 7 cli + 13 common + 60 daemon + 7 gui = **87 tests**. + +**Plan deviations:** +- `add_titled_with_icon` from the plan replaced with `add_titled` to match the existing `KeysPage` registration in `main.rs` (icons aren't surfaced by the current `ViewSwitcher`). +- `policy_form.rs` carries a module-level `#![allow(dead_code)]` so the staged-but-not-yet-wired symbols pass the `-D warnings` gate at the Task 3 commit; consistent with the same pattern in `bus.rs` / `error.rs` from Phase 8. +- `dialog.present(Some(&parent))` from the plan corrected to `dialog.present(&parent)` — `adw::Dialog::present` in libadwaita 0.6.0 takes `&impl IsA` not `Option<…>`. + +**Deferred until Phase 14 VM smoke:** +- Manual Policy + Recovery walkthroughs in `gui/TESTING.md` against a real GNOME session with a Yubikey. + --- # Phase 0: Repository Scaffolding (FULLY DETAILED)