feat(gui): Policy tab with three master stacks and lockout-violation banner

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
michael
2026-04-27 11:15:50 -07:00
parent b2771e73fa
commit a28b5c9ebe
2 changed files with 187 additions and 0 deletions

View File

@@ -8,6 +8,7 @@ mod error;
mod firstrun; mod firstrun;
mod keys_page; mod keys_page;
mod policy_form; mod policy_form;
mod policy_page;
const APP_ID: &str = "io.dangerousthings.AuthForge"; const APP_ID: &str = "io.dangerousthings.AuthForge";
@@ -60,6 +61,9 @@ fn main() -> glib::ExitCode {
let keys = keys_page::KeysPage::new(ctx.clone()); let keys = keys_page::KeysPage::new(ctx.clone());
stack.add_titled(&keys.root, Some("keys"), "Keys"); 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 // ToolbarView is libadwaita v1_4-gated; the project sticks with the
// simpler Box layout for portability (see commit c6a5e94). // simpler Box layout for portability (see commit c6a5e94).
let layout = gtk::Box::new(gtk::Orientation::Vertical, 0); let layout = gtk::Box::new(gtk::Orientation::Vertical, 0);

183
gui/src/policy_page.rs Normal file
View File

@@ -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<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.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(
&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());
}
}