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()); + } +}