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>
This commit is contained in:
1170
docs/plans/2026-04-27-phase-12-recovery-backend.md
Normal file
1170
docs/plans/2026-04-27-phase-12-recovery-backend.md
Normal file
File diff suppressed because it is too large
Load Diff
971
docs/plans/2026-04-27-phase-9-and-12-gui.md
Normal file
971
docs/plans/2026-04-27-phase-9-and-12-gui.md
Normal file
@@ -0,0 +1,971 @@
|
||||
# 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](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 `AdwComboRow`s 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](2026-04-27-phase-8-gui-keys.md#L812)) — 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](../../daemon/src/dbus.rs#L142)) 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](2026-04-27-phase-8-gui-keys.md#L23-L25), 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:**
|
||||
- Master plan: [2026-04-26-authforge-implementation.md](2026-04-26-authforge-implementation.md) § Phase 9 (line 1190) and § Phase 12 (line 1260).
|
||||
- Phase 8 plan (consumes this lane's contracts): [2026-04-27-phase-8-gui-keys.md](2026-04-27-phase-8-gui-keys.md).
|
||||
- Phase 12 backend plan (the daemon contract these views consume): [2026-04-27-phase-12-recovery-backend.md](2026-04-27-phase-12-recovery-backend.md).
|
||||
- Sibling page (mirror this style): `gui/src/keys_page.rs` (after Phase 8 lands).
|
||||
- D-Bus methods this lane calls: `GetPolicy`, `SetPolicy`, `GenerateRecoveryCode`, `ListRecoveryCodes`, `RevokeRecoveryCode`, signal `PolicyChanged`.
|
||||
|
||||
---
|
||||
|
||||
## 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**
|
||||
|
||||
```rust
|
||||
// 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:
|
||||
|
||||
```rust
|
||||
pub fn new(parent_window: gtk::Window, toast_overlay: adw::ToastOverlay) -> Rc<Self>
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```rust
|
||||
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**
|
||||
|
||||
```rust
|
||||
// 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**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```rust
|
||||
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**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```rust
|
||||
//! 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**
|
||||
|
||||
```bash
|
||||
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 `AdwComboRow`s, 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**
|
||||
|
||||
```rust
|
||||
//! "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(
|
||||
>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<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`**
|
||||
|
||||
```rust
|
||||
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**
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```rust
|
||||
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**
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```rust
|
||||
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**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```rust
|
||||
//! "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`**
|
||||
|
||||
```rust
|
||||
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**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```markdown
|
||||
## 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**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```markdown
|
||||
### 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**
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
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](2026-04-27-phase-8-gui-keys.md#L184-L235). | 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`.**
|
||||
Reference in New Issue
Block a user