Merge branch 'feature/prep-shell-and-pending'

This commit is contained in:
michael
2026-04-27 10:56:14 -07:00
8 changed files with 176 additions and 24 deletions

View File

@@ -36,7 +36,7 @@ pub struct Credential {
pub created_unix: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
pub struct PendingFlag {
pub required_methods: Vec<Method>,
pub created_unix: u64,
@@ -46,6 +46,13 @@ pub struct PendingFlag {
pub re_enroll: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
pub struct PendingStatus {
pub present: bool,
/// Contents are the default when `!present`; consumer must check `present` first.
pub flag: PendingFlag,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
pub struct Violation {
pub user: String,
@@ -119,4 +126,30 @@ mod tests {
let back: Credential = serde_json::from_str(&json).unwrap();
assert_eq!(c, back);
}
#[test]
fn pending_status_serde() {
let s = PendingStatus {
present: true,
flag: PendingFlag {
required_methods: vec![Method::Fido2],
created_unix: 1_700_000_000,
deadline_unix: 0,
re_enroll: false,
},
};
let bytes = serde_json::to_vec(&s).unwrap();
let back: PendingStatus = serde_json::from_slice(&bytes).unwrap();
assert_eq!(s, back);
}
#[test]
fn pending_status_default_when_absent() {
let s = PendingStatus {
present: false,
flag: PendingFlag::default(),
};
assert!(!s.present);
assert!(s.flag.required_methods.is_empty());
}
}

View File

@@ -1,7 +1,9 @@
use crate::polkit::Polkit;
use crate::state::AppState;
use authforge_common::policy::Policy;
use authforge_common::types::{Credential, PendingFlag, PolicyApplyResult, RecoveryCodeSummary};
use authforge_common::types::{
Credential, PendingFlag, PendingStatus, PolicyApplyResult, RecoveryCodeSummary,
};
use std::sync::Arc;
pub struct AuthForge {
@@ -129,6 +131,14 @@ impl AuthForge {
Ok(())
}
async fn get_pending_status(&self, user: String) -> zbus::fdo::Result<PendingStatus> {
// No polkit gate — pure read; daemon's pattern is gates on writes only.
self.state
.get_pending_status(&user)
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
}
async fn generate_recovery_code(&self, user: String) -> zbus::fdo::Result<String> {
self.authz("io.dangerousthings.AuthForge.generate-recovery")
.await?;
@@ -387,6 +397,34 @@ central_path = "{}"
assert!(!state.has_pending("alice").await.unwrap());
}
#[tokio::test]
async fn pending_status_is_absent_for_fresh_user() {
let (_srv, client, _state, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
let s: PendingStatus = p.call("GetPendingStatus", &("alice",)).await.unwrap();
assert!(!s.present);
assert!(s.flag.required_methods.is_empty());
}
#[tokio::test]
async fn pending_status_round_trips_set_flag() {
let (_srv, client, _state, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
let flag = PendingFlag {
required_methods: vec![Method::Fido2],
created_unix: 100,
deadline_unix: 200,
re_enroll: true,
};
let _: () = p
.call("SetPendingFlag", &("alice", flag.clone()))
.await
.unwrap();
let s: PendingStatus = p.call("GetPendingStatus", &("alice",)).await.unwrap();
assert!(s.present);
assert_eq!(s.flag, flag);
}
#[tokio::test]
async fn generate_recovery_code_returns_8_digits() {
let (_srv, client, _state, _tmp) = p2p_pair().await;

View File

@@ -7,7 +7,9 @@ use crate::storage::policy::PolicyStore;
use crate::storage::recovery::{RecoveryEntry, RecoveryStore, RecoveryStoreError};
use crate::storage::userdb::{UserDb, UserDbError};
use authforge_common::policy::{Policy, PolicyError};
use authforge_common::types::{Credential, Method, PendingFlag, PolicyApplyResult, Transport};
use authforge_common::types::{
Credential, Method, PendingFlag, PendingStatus, PolicyApplyResult, Transport,
};
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
@@ -249,12 +251,26 @@ impl AppState {
}
/// Tests assert pending flag round-trips through SetPendingFlag /
/// ClearPendingFlag via this read accessor. Production readers (PAM
/// module, GUI status) read the on-disk file directly in later phases.
/// ClearPendingFlag via this read accessor. Production readers go
/// through `get_pending_status` (GUI) or read the on-disk file
/// directly (PAM module).
#[cfg(test)]
pub async fn has_pending(&self, user: &str) -> Result<bool, StateError> {
Ok(self.pending.get(user)?.is_some())
}
pub async fn get_pending_status(&self, user: &str) -> Result<PendingStatus, StateError> {
match self.pending.get(user)? {
Some(flag) => Ok(PendingStatus {
present: true,
flag,
}),
None => Ok(PendingStatus {
present: false,
flag: PendingFlag::default(),
}),
}
}
}
#[cfg(test)]

27
gui/src/app_context.rs Normal file
View File

@@ -0,0 +1,27 @@
//! Shared per-window handles passed to every page constructor.
//!
//! `daemon` is a `RefCell<Option<...>>` because the connect-on-startup attempt
//! may fail (pages render their disconnected banner); a successful Retry
//! repopulates this same cell, and every page sees the new value via the Rc
//! clone chain.
use crate::bus::Daemon;
use std::cell::RefCell;
use std::rc::Rc;
#[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)),
}
}
}

View File

@@ -4,7 +4,7 @@
//! `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;
use authforge_common::types::{Credential, PendingStatus};
const BUS_NAME: &str = "io.dangerousthings.AuthForge";
const OBJECT_PATH: &str = "/io/dangerousthings/AuthForge";
@@ -48,6 +48,14 @@ impl Daemon {
) -> zbus::Result<zbus::proxy::SignalStream<'static>> {
self.proxy.receive_signal("EnrollmentFailed").await
}
pub async fn get_pending_status(&self, user: &str) -> zbus::Result<PendingStatus> {
self.proxy.call("GetPendingStatus", &(user,)).await
}
pub async fn clear_pending_flag(&self, user: &str) -> zbus::Result<()> {
self.proxy.call("ClearPendingFlag", &(user,)).await
}
}
#[allow(dead_code)] // wired through keys_page.rs in Task 4.

14
gui/src/firstrun.rs Normal file
View File

@@ -0,0 +1,14 @@
//! First-login modal. Phase 10 implements the body of `run`.
#![allow(dead_code)] // body lands in Phase 10.
use crate::app_context::AppContext;
/// Entry point for `authforge --first-run`. Phase 10 fills this with the
/// fullscreen modal + watchdog + enroll-then-clear-pending logic.
/// Until then it exits cleanly so a developer who passes `--first-run`
/// doesn't get a stuck process.
pub(crate) fn run(_ctx: AppContext) {
eprintln!("authforge: --first-run mode is a Phase 10 stub; exiting.");
std::process::exit(0);
}

View File

@@ -1,27 +1,23 @@
//! "Security keys" preferences page. v1 surface: the current user's enrolled
//! FIDO2 credentials with per-row remove + a single "Enroll a new key" button.
use crate::app_context::AppContext;
use crate::bus::{current_user, Daemon};
use crate::error::user_message;
use adw::prelude::*;
use gtk::glib;
use std::cell::RefCell;
use std::rc::Rc;
pub(crate) struct KeysPage {
pub root: adw::Bin,
daemon: Rc<RefCell<Option<Daemon>>>,
parent_window: gtk::Window,
toast_overlay: adw::ToastOverlay,
ctx: AppContext,
}
impl KeysPage {
pub fn new(parent_window: gtk::Window, toast_overlay: adw::ToastOverlay) -> Rc<Self> {
pub fn new(ctx: AppContext) -> Rc<Self> {
let page = Rc::new(Self {
root: adw::Bin::new(),
daemon: Rc::new(RefCell::new(None)),
parent_window,
toast_overlay,
ctx,
});
let p = page.clone();
glib::MainContext::default().spawn_local(async move {
@@ -33,7 +29,7 @@ impl KeysPage {
async fn try_connect_and_render(self: Rc<Self>) {
match Daemon::connect().await {
Ok(d) => {
*self.daemon.borrow_mut() = Some(d);
*self.ctx.daemon.borrow_mut() = Some(d);
self.render_connected().await;
}
Err(e) => self.render_disconnected(&user_message(&e)),
@@ -63,7 +59,7 @@ impl KeysPage {
}
async fn render_connected(self: &Rc<Self>) {
let daemon = match self.daemon.borrow().clone() {
let daemon = match self.ctx.daemon.borrow().clone() {
Some(d) => d,
None => {
self.render_disconnected("internal: daemon handle missing");
@@ -140,7 +136,7 @@ impl KeysPage {
}
async fn remove_credential(self: &Rc<Self>, cred_id: &str) {
let daemon = match self.daemon.borrow().clone() {
let daemon = match self.ctx.daemon.borrow().clone() {
Some(d) => d,
None => return,
};
@@ -153,12 +149,12 @@ impl KeysPage {
}
async fn start_enroll(self: &Rc<Self>) {
let daemon = match self.daemon.borrow().clone() {
let daemon = match self.ctx.daemon.borrow().clone() {
Some(d) => d,
None => return,
};
let me = self.clone();
crate::enroll_dialog::present(&self.parent_window, daemon, move || {
crate::enroll_dialog::present(&self.ctx.parent_window, daemon, move || {
let me = me.clone();
glib::MainContext::default().spawn_local(async move {
me.render_connected().await;
@@ -168,7 +164,7 @@ impl KeysPage {
fn show_toast(self: &Rc<Self>, msg: &str) {
let toast = adw::Toast::builder().title(msg).timeout(5).build();
self.toast_overlay.add_toast(toast);
self.ctx.toast_overlay.add_toast(toast);
}
}

View File

@@ -1,9 +1,11 @@
use adw::prelude::*;
use gtk::glib;
mod app_context;
mod bus;
mod enroll_dialog;
mod error;
mod firstrun;
mod keys_page;
const APP_ID: &str = "io.dangerousthings.AuthForge";
@@ -19,14 +21,33 @@ fn main() -> glib::ExitCode {
.expect("build tokio runtime");
let _guard = runtime.enter();
// Strip `--first-run` from argv before GTK's own option parser sees it;
// GTK rejects unknown long options. Phase 10 fills the body of
// firstrun::run; the prep lane only wires the entry point.
let raw_args: Vec<String> = std::env::args().collect();
let first_run = raw_args.iter().any(|a| a == "--first-run");
let gtk_args: Vec<String> = raw_args
.iter()
.filter(|a| a.as_str() != "--first-run")
.cloned()
.collect();
let app = adw::Application::builder().application_id(APP_ID).build();
app.connect_activate(|app| {
app.connect_activate(move |app| {
let win = adw::ApplicationWindow::builder()
.application(app)
.default_width(720)
.default_height(540)
.title("Authentication")
.build();
let toast_overlay = adw::ToastOverlay::new();
let ctx = app_context::AppContext::new(win.clone().upcast(), toast_overlay.clone());
if first_run {
firstrun::run(ctx);
return;
}
let header = adw::HeaderBar::new();
let stack = adw::ViewStack::new();
let switcher = adw::ViewSwitcher::builder()
@@ -35,8 +56,7 @@ fn main() -> glib::ExitCode {
.build();
header.set_title_widget(Some(&switcher));
let toast_overlay = adw::ToastOverlay::new();
let keys = keys_page::KeysPage::new(win.clone().upcast(), toast_overlay.clone());
let keys = keys_page::KeysPage::new(ctx.clone());
stack.add_titled(&keys.root, Some("keys"), "Keys");
// ToolbarView is libadwaita v1_4-gated; the project sticks with the
@@ -50,5 +70,5 @@ fn main() -> glib::ExitCode {
win.set_content(Some(&toast_overlay));
win.present();
});
app.run()
app.run_with_args(&gtk_args)
}