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:
@@ -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
183
gui/src/policy_page.rs
Normal 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(
|
||||||
|
>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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user