diff --git a/common/src/types.rs b/common/src/types.rs index bb4cd0a..9e86f31 100644 --- a/common/src/types.rs +++ b/common/src/types.rs @@ -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, 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()); + } } diff --git a/daemon/src/dbus.rs b/daemon/src/dbus.rs index 0bc8bb3..2368559 100644 --- a/daemon/src/dbus.rs +++ b/daemon/src/dbus.rs @@ -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 { + // 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 { 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; diff --git a/daemon/src/state.rs b/daemon/src/state.rs index 8aeaa87..2691d6f 100644 --- a/daemon/src/state.rs +++ b/daemon/src/state.rs @@ -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 { Ok(self.pending.get(user)?.is_some()) } + + pub async fn get_pending_status(&self, user: &str) -> Result { + match self.pending.get(user)? { + Some(flag) => Ok(PendingStatus { + present: true, + flag, + }), + None => Ok(PendingStatus { + present: false, + flag: PendingFlag::default(), + }), + } + } } #[cfg(test)] diff --git a/gui/src/app_context.rs b/gui/src/app_context.rs new file mode 100644 index 0000000..6a13868 --- /dev/null +++ b/gui/src/app_context.rs @@ -0,0 +1,27 @@ +//! Shared per-window handles passed to every page constructor. +//! +//! `daemon` is a `RefCell>` 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>>, +} + +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)), + } + } +} diff --git a/gui/src/bus.rs b/gui/src/bus.rs index 92dfb66..a0580f5 100644 --- a/gui/src/bus.rs +++ b/gui/src/bus.rs @@ -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> { self.proxy.receive_signal("EnrollmentFailed").await } + + pub async fn get_pending_status(&self, user: &str) -> zbus::Result { + 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. diff --git a/gui/src/firstrun.rs b/gui/src/firstrun.rs new file mode 100644 index 0000000..970c361 --- /dev/null +++ b/gui/src/firstrun.rs @@ -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); +} diff --git a/gui/src/keys_page.rs b/gui/src/keys_page.rs index c85300a..353a2b5 100644 --- a/gui/src/keys_page.rs +++ b/gui/src/keys_page.rs @@ -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>>, - parent_window: gtk::Window, - toast_overlay: adw::ToastOverlay, + ctx: AppContext, } impl KeysPage { - pub fn new(parent_window: gtk::Window, toast_overlay: adw::ToastOverlay) -> Rc { + pub fn new(ctx: AppContext) -> Rc { 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) { 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) { - 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, 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) { - 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, 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); } } diff --git a/gui/src/main.rs b/gui/src/main.rs index 133775e..fc5f9da 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -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 = std::env::args().collect(); + let first_run = raw_args.iter().any(|a| a == "--first-run"); + let gtk_args: Vec = 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(>k_args) }