Merge branch 'feature/phase-8-gui-keys'
Phase 8: GUI Keys tab. Lists current user's enrolled FIDO2 credentials, enrolls a new key with a touch-prompt modal driven by the daemon's EnrollmentFailed signal (success comes from the call return), removes credentials per row, degrades gracefully to a Retry banner when the daemon is unreachable, and surfaces transient errors via adw::Toast. 8 tasks, 4 unit tests (error classifier).
This commit is contained in:
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -309,8 +309,11 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"authforge-common",
|
"authforge-common",
|
||||||
|
"futures-util",
|
||||||
"gtk4",
|
"gtk4",
|
||||||
"libadwaita",
|
"libadwaita",
|
||||||
|
"tokio",
|
||||||
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -4,12 +4,15 @@
|
|||||||
|
|
||||||
**Goal:** Land a working `authforge` GUI that lists the current user's enrolled FIDO2 credentials, enrolls a new key with a touch-prompt modal driven by the daemon's `EnrollmentSucceeded`/`EnrollmentFailed` signals, removes a credential, and degrades gracefully when the daemon is unreachable.
|
**Goal:** Land a working `authforge` GUI that lists the current user's enrolled FIDO2 credentials, enrolls a new key with a touch-prompt modal driven by the daemon's `EnrollmentSucceeded`/`EnrollmentFailed` signals, removes a credential, and degrades gracefully when the daemon is unreachable.
|
||||||
|
|
||||||
**Architecture:** Single-window GTK4 + libadwaita app on the user's session, talking to the daemon on the **system bus** at `io.dangerousthings.AuthForge / /io/dangerousthings/AuthForge / io.dangerousthings.AuthForge1`. zbus uses its **glib** integration (`features = ["glib"]`) instead of the workspace tokio config, so all async runs on the GTK main thread via `glib::MainContext::spawn_local`. No tokio dep in the GUI process.
|
**Architecture:** Single-window GTK4 + libadwaita app on the user's session, talking to the daemon on the **system bus** at `io.dangerousthings.AuthForge / /io/dangerousthings/AuthForge / io.dangerousthings.AuthForge1`. zbus uses the workspace tokio runtime; a multi-thread tokio runtime is installed at app startup and its enter-guard is held for the lifetime of `app.run()`. Closures that touch widgets dispatch async work via `glib::MainContext::spawn_local`, which polls the future on the GTK main thread; tokio's reactor (running on its own worker threads) wakes the future when zbus I/O is ready, and glib re-polls.
|
||||||
|
|
||||||
|
> **Plan note (2026-04-27):** an earlier draft of this plan claimed zbus 4 has a `glib` feature for runtime integration. It does not — zbus 4.4's features are `async-io` (default), `tokio`, `bus-impl`, `chrono`, `p2p`, `time`, `url`, `uuid`, `vsock`. We use `tokio` to match the rest of the workspace.
|
||||||
|
|
||||||
**Tech Stack:**
|
**Tech Stack:**
|
||||||
- `gtk4-rs` 0.8 (already pinned in `gui/Cargo.toml`).
|
- `gtk4-rs` 0.8 (already pinned in `gui/Cargo.toml`).
|
||||||
- `libadwaita` 0.6 (already pinned).
|
- `libadwaita` 0.6 (already pinned).
|
||||||
- `zbus = { version = "4", default-features = false, features = ["glib"] }` — **not** `workspace = true`; the workspace dep is tokio-flavored and would conflict.
|
- `zbus = { workspace = true }` — workspace config is `default-features = false, features = ["tokio", "p2p"]`.
|
||||||
|
- `tokio = { workspace = true }` — needed for the runtime guard that hosts zbus's tokio executor.
|
||||||
- `authforge-common = { path = "../common" }` for the shared `Credential` wire type.
|
- `authforge-common = { path = "../common" }` for the shared `Credential` wire type.
|
||||||
|
|
||||||
**Reference:**
|
**Reference:**
|
||||||
@@ -38,18 +41,20 @@
|
|||||||
**Step 1:** Append to `gui/Cargo.toml` `[dependencies]`:
|
**Step 1:** Append to `gui/Cargo.toml` `[dependencies]`:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
zbus = { version = "4", default-features = false, features = ["glib"] }
|
zbus = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
futures-util = { workspace = true }
|
||||||
```
|
```
|
||||||
|
|
||||||
(Do **not** use `workspace = true`. The workspace zbus is tokio-flavored — the GUI needs the glib runtime feature, which is mutually exclusive at the zbus crate level.)
|
The tokio runtime guard installed in Task 4 (`main.rs`) is what makes zbus's tokio-flavored futures actually run when `glib::MainContext::spawn_local` polls them. `futures-util` shows up here so Task 6's `select!`-style race in `enroll_dialog.rs` doesn't need a follow-up Cargo.toml edit.
|
||||||
|
|
||||||
**Step 2:** `cargo build -p authforge-gui`. Expected: clean compile (no usages yet, but the dep resolves).
|
**Step 2:** `cargo build -p authforge-gui`. Expected: clean compile (no usages yet, but the deps resolve).
|
||||||
|
|
||||||
**Step 3:** Commit.
|
**Step 3:** Commit.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add gui/Cargo.toml Cargo.lock
|
git add gui/Cargo.toml Cargo.lock
|
||||||
git commit --no-gpg-sign -m "chore(gui): add zbus glib feature for D-Bus client"
|
git commit --no-gpg-sign -m "chore(gui): add zbus + tokio + futures-util deps for D-Bus client"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -357,6 +362,16 @@ mod keys_page;
|
|||||||
const APP_ID: &str = "io.dangerousthings.AuthForge";
|
const APP_ID: &str = "io.dangerousthings.AuthForge";
|
||||||
|
|
||||||
fn main() -> glib::ExitCode {
|
fn main() -> glib::ExitCode {
|
||||||
|
// Multi-thread tokio runtime hosts zbus's tokio-flavored futures. The
|
||||||
|
// `_guard` keeps the runtime context active on this thread so calls like
|
||||||
|
// `zbus::Connection::system().await` from within `glib::MainContext::
|
||||||
|
// spawn_local` closures don't panic with "no current reactor".
|
||||||
|
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("build tokio runtime");
|
||||||
|
let _guard = runtime.enter();
|
||||||
|
|
||||||
let app = adw::Application::builder().application_id(APP_ID).build();
|
let app = adw::Application::builder().application_id(APP_ID).build();
|
||||||
app.connect_activate(|app| {
|
app.connect_activate(|app| {
|
||||||
let win = adw::ApplicationWindow::builder()
|
let win = adw::ApplicationWindow::builder()
|
||||||
@@ -624,13 +639,7 @@ fn show_failure(dialog: &adw::AlertDialog, msg: &str) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 3:** Add `futures-util` to `gui/Cargo.toml`:
|
**Step 3:** Replace the `start_enroll` body in `gui/src/keys_page.rs`:
|
||||||
|
|
||||||
```toml
|
|
||||||
futures-util = { workspace = true }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 4:** Replace the `start_enroll` body in `gui/src/keys_page.rs`:
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
async fn start_enroll(self: &Rc<Self>) {
|
async fn start_enroll(self: &Rc<Self>) {
|
||||||
@@ -648,12 +657,12 @@ futures-util = { workspace = true }
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 5:** `cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings`. Clean.
|
**Step 4:** `cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings`. Clean.
|
||||||
|
|
||||||
**Step 6:** Commit.
|
**Step 5:** Commit.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add gui/src/enroll_dialog.rs gui/src/keys_page.rs gui/src/main.rs gui/Cargo.toml Cargo.lock
|
git add gui/src/enroll_dialog.rs gui/src/keys_page.rs gui/src/main.rs
|
||||||
git commit --no-gpg-sign -m "feat(gui): TouchDialog modal racing EnrollOwn call against EnrollmentFailed signal"
|
git commit --no-gpg-sign -m "feat(gui): TouchDialog modal racing EnrollOwn call against EnrollmentFailed signal"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
gtk = { package = "gtk4", version = "0.8" }
|
gtk = { package = "gtk4", version = "0.8" }
|
||||||
adw = { package = "libadwaita", version = "0.6" }
|
adw = { package = "libadwaita", version = "0.6", features = ["v1_5"] }
|
||||||
authforge-common = { path = "../common" }
|
authforge-common = { path = "../common" }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
zbus = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
futures-util = { workspace = true }
|
||||||
|
|||||||
47
gui/TESTING.md
Normal file
47
gui/TESTING.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Testing the AuthForge GUI
|
||||||
|
|
||||||
|
## Automated gate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build -p authforge-gui
|
||||||
|
cargo clippy -p authforge-gui --all-targets -- -D warnings
|
||||||
|
cargo test -p authforge-gui # 4 error-classifier tests
|
||||||
|
```
|
||||||
|
|
||||||
|
CI runs all three on every push (libgtk-4-dev / libadwaita-1-dev are
|
||||||
|
installed in the workflow image).
|
||||||
|
|
||||||
|
The GUI requires libadwaita 1.5 (Ubuntu 24.04+) for `adw::AlertDialog` and
|
||||||
|
the v1_5 feature gate enabled in `gui/Cargo.toml`. Older distros need the
|
||||||
|
plan's MessageDialog fallback (not implemented in v1).
|
||||||
|
|
||||||
|
## Manual smoke
|
||||||
|
|
||||||
|
Requires a USB FIDO2 device (Yubikey 5, Solo, etc.) and a built daemon.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1 — daemon (permissive polkit so you don't get prompted).
|
||||||
|
AUTHFORGE_POLKIT_BYPASS=1 \
|
||||||
|
AUTHFORGE_POLICY_DIR=/tmp/af/policy.d \
|
||||||
|
AUTHFORGE_PENDING_DIR=/tmp/af/pending \
|
||||||
|
AUTHFORGE_USERDB=/tmp/af/users.db \
|
||||||
|
target/release/authforged
|
||||||
|
|
||||||
|
# Terminal 2 — GUI.
|
||||||
|
target/release/authforge
|
||||||
|
```
|
||||||
|
|
||||||
|
Walk-through:
|
||||||
|
|
||||||
|
1. Window opens, Keys tab visible. Empty list with hint text "No keys
|
||||||
|
enrolled yet."
|
||||||
|
2. Click "Enroll a new key" → modal opens with spinner + "Touch your
|
||||||
|
security key now". Touch the device.
|
||||||
|
3. Modal closes, the list re-renders with one row showing the key's
|
||||||
|
short id.
|
||||||
|
4. Click the trash icon on the row → row vanishes immediately.
|
||||||
|
5. Pull the security key, click Enroll → modal opens, swaps to error
|
||||||
|
text ("authenticator: no FIDO2 device found") with a Close button.
|
||||||
|
6. Kill the daemon (`pkill authforged`), click Enroll → toast appears
|
||||||
|
with the disconnected message; Retry on the page reconnects after
|
||||||
|
restart.
|
||||||
56
gui/src/bus.rs
Normal file
56
gui/src/bus.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
//! D-Bus client for the GUI. zbus runs on the workspace tokio runtime; the
|
||||||
|
//! runtime guard installed in `main` keeps the runtime context alive for the
|
||||||
|
//! lifetime of `app.run()`. Closures dispatch async work via
|
||||||
|
//! `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;
|
||||||
|
|
||||||
|
const BUS_NAME: &str = "io.dangerousthings.AuthForge";
|
||||||
|
const OBJECT_PATH: &str = "/io/dangerousthings/AuthForge";
|
||||||
|
const IFACE: &str = "io.dangerousthings.AuthForge1";
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
#[allow(dead_code)] // wired through keys_page.rs / enroll_dialog.rs in Tasks 4-6.
|
||||||
|
pub(crate) struct Daemon {
|
||||||
|
proxy: zbus::Proxy<'static>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // wired through keys_page.rs / enroll_dialog.rs in Tasks 4-6.
|
||||||
|
impl Daemon {
|
||||||
|
pub async fn connect() -> zbus::Result<Self> {
|
||||||
|
let conn = zbus::Connection::system().await?;
|
||||||
|
// Mirror the CLI's known-working pattern; `Proxy::new_owned` returns a
|
||||||
|
// Proxy<'static> owning its connection so `Daemon` can be passed
|
||||||
|
// around freely.
|
||||||
|
let proxy = zbus::Proxy::new_owned(conn, BUS_NAME, OBJECT_PATH, IFACE).await?;
|
||||||
|
Ok(Self { proxy })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_credentials(&self, user: &str) -> zbus::Result<Vec<Credential>> {
|
||||||
|
self.proxy.call("ListCredentials", &(user,)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn enroll_own(&self, user: &str, nickname: &str) -> zbus::Result<Credential> {
|
||||||
|
self.proxy.call("EnrollOwn", &(user, nickname)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_own(&self, user: &str, cred_id: &str) -> zbus::Result<()> {
|
||||||
|
self.proxy.call("RemoveOwn", &(user, cred_id)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribes to `EnrollmentFailed`. The returned stream is alive as long
|
||||||
|
/// as the caller holds it; the touch dialog drops it when it closes. We
|
||||||
|
/// don't subscribe to `EnrollmentSucceeded` because the call's return
|
||||||
|
/// value carries the new `Credential` — the source of truth for success.
|
||||||
|
pub async fn subscribe_enrollment_failed(
|
||||||
|
&self,
|
||||||
|
) -> zbus::Result<zbus::proxy::SignalStream<'static>> {
|
||||||
|
self.proxy.receive_signal("EnrollmentFailed").await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // wired through keys_page.rs in Task 4.
|
||||||
|
pub(crate) fn current_user() -> String {
|
||||||
|
std::env::var("USER").unwrap_or_else(|_| "unknown".into())
|
||||||
|
}
|
||||||
79
gui/src/enroll_dialog.rs
Normal file
79
gui/src/enroll_dialog.rs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
//! Modal touch-prompt dialog. Lifetime is one enrollment attempt.
|
||||||
|
//!
|
||||||
|
//! Races the EnrollOwn call against the EnrollmentFailed signal — the call
|
||||||
|
//! return is the source of truth for success (carries the new Credential),
|
||||||
|
//! the signal lets us surface a useful failure string fast.
|
||||||
|
|
||||||
|
use crate::bus::{current_user, Daemon};
|
||||||
|
use crate::error::user_message;
|
||||||
|
use adw::prelude::*;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use gtk::glib;
|
||||||
|
|
||||||
|
/// Present the modal and run an EnrollOwn against the daemon. `on_success`
|
||||||
|
/// fires after a successful enrollment so the caller can refresh its list.
|
||||||
|
pub(crate) fn present(parent: >k::Window, daemon: Daemon, on_success: impl Fn() + 'static) {
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading("Touch your security key")
|
||||||
|
.body("Touch the metal contact on your security key to enroll it.")
|
||||||
|
.build();
|
||||||
|
dialog.add_response("cancel", "Cancel");
|
||||||
|
dialog.set_default_response(Some("cancel"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
|
||||||
|
let spinner = gtk::Spinner::builder()
|
||||||
|
.spinning(true)
|
||||||
|
.height_request(48)
|
||||||
|
.width_request(48)
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
dialog.set_extra_child(Some(&spinner));
|
||||||
|
|
||||||
|
dialog.present(parent);
|
||||||
|
|
||||||
|
let dialog_clone = dialog.clone();
|
||||||
|
glib::MainContext::default().spawn_local(async move {
|
||||||
|
let user = current_user();
|
||||||
|
// Subscribe BEFORE the call so a fast EnrollmentFailed isn't missed.
|
||||||
|
let mut failed = match daemon.subscribe_enrollment_failed().await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
show_failure(&dialog_clone, &user_message(&e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let call_fut = daemon.enroll_own(&user, "Security Key");
|
||||||
|
futures_util::pin_mut!(call_fut);
|
||||||
|
|
||||||
|
let outcome = futures_util::future::select(call_fut, failed.next()).await;
|
||||||
|
match outcome {
|
||||||
|
futures_util::future::Either::Left((Ok(_cred), _)) => {
|
||||||
|
dialog_clone.close();
|
||||||
|
on_success();
|
||||||
|
}
|
||||||
|
futures_util::future::Either::Left((Err(e), _)) => {
|
||||||
|
show_failure(&dialog_clone, &user_message(&e));
|
||||||
|
}
|
||||||
|
futures_util::future::Either::Right((Some(msg), _)) => {
|
||||||
|
let reason: String = msg
|
||||||
|
.body()
|
||||||
|
.deserialize()
|
||||||
|
.unwrap_or_else(|_| "enrollment failed".into());
|
||||||
|
show_failure(&dialog_clone, &reason);
|
||||||
|
}
|
||||||
|
futures_util::future::Either::Right((None, _)) => {
|
||||||
|
show_failure(&dialog_clone, "signal stream closed unexpectedly");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_failure(dialog: &adw::AlertDialog, msg: &str) {
|
||||||
|
dialog.set_heading(Some("Enrollment failed"));
|
||||||
|
dialog.set_body(msg);
|
||||||
|
dialog.set_extra_child(None::<>k::Widget>);
|
||||||
|
// AlertDialog responses can't be removed dynamically; relabel "cancel"
|
||||||
|
// to "Close" so the surviving button reads correctly.
|
||||||
|
dialog.set_response_label("cancel", "Close");
|
||||||
|
}
|
||||||
87
gui/src/error.rs
Normal file
87
gui/src/error.rs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
//! Map zbus errors to short, user-facing strings. The GUI surfaces these in
|
||||||
|
//! `adw::Toast` and inside the touch-prompt dialog's failure state.
|
||||||
|
//!
|
||||||
|
//! Pure logic; testable without a display server.
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[allow(dead_code)] // wired through keys_page.rs / enroll_dialog.rs in Tasks 4-6.
|
||||||
|
pub(crate) enum ErrorKind {
|
||||||
|
/// Daemon unreachable / bus dropped — caller should swap to disconnected banner.
|
||||||
|
Disconnected,
|
||||||
|
/// polkit denied. Daemon's message is usually fine to show verbatim.
|
||||||
|
AccessDenied,
|
||||||
|
/// Anything else — daemon returned a typed `Failed(msg)` or similar.
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // wired through keys_page.rs / enroll_dialog.rs in Tasks 4-6.
|
||||||
|
pub(crate) fn classify(err: &zbus::Error) -> ErrorKind {
|
||||||
|
match err {
|
||||||
|
zbus::Error::InputOutput(_) | zbus::Error::Address(_) | zbus::Error::Handshake(_) => {
|
||||||
|
ErrorKind::Disconnected
|
||||||
|
}
|
||||||
|
zbus::Error::FDO(boxed) => match boxed.as_ref() {
|
||||||
|
zbus::fdo::Error::AccessDenied(_) => ErrorKind::AccessDenied,
|
||||||
|
zbus::fdo::Error::ServiceUnknown(_) | zbus::fdo::Error::NameHasNoOwner(_) => {
|
||||||
|
ErrorKind::Disconnected
|
||||||
|
}
|
||||||
|
_ => ErrorKind::Other,
|
||||||
|
},
|
||||||
|
_ => ErrorKind::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // wired through keys_page.rs / enroll_dialog.rs in Tasks 4-6.
|
||||||
|
pub(crate) fn user_message(err: &zbus::Error) -> String {
|
||||||
|
match classify(err) {
|
||||||
|
ErrorKind::Disconnected => {
|
||||||
|
"Couldn't reach the authentication daemon. Make sure authforge-daemon is running."
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
ErrorKind::AccessDenied => match err {
|
||||||
|
zbus::Error::FDO(boxed) => match boxed.as_ref() {
|
||||||
|
zbus::fdo::Error::AccessDenied(msg) => msg.clone(),
|
||||||
|
_ => "Access denied.".to_string(),
|
||||||
|
},
|
||||||
|
_ => "Access denied.".to_string(),
|
||||||
|
},
|
||||||
|
ErrorKind::Other => err.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn service_unknown_classifies_as_disconnected() {
|
||||||
|
let e = zbus::Error::FDO(Box::new(zbus::fdo::Error::ServiceUnknown(
|
||||||
|
"no daemon".to_string(),
|
||||||
|
)));
|
||||||
|
assert_eq!(classify(&e), ErrorKind::Disconnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn access_denied_classifies_as_access_denied() {
|
||||||
|
let e = zbus::Error::FDO(Box::new(zbus::fdo::Error::AccessDenied(
|
||||||
|
"polkit said no".to_string(),
|
||||||
|
)));
|
||||||
|
assert_eq!(classify(&e), ErrorKind::AccessDenied);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn access_denied_user_message_is_daemon_message_verbatim() {
|
||||||
|
let e = zbus::Error::FDO(Box::new(zbus::fdo::Error::AccessDenied(
|
||||||
|
"polkit said no".to_string(),
|
||||||
|
)));
|
||||||
|
assert_eq!(user_message(&e), "polkit said no");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disconnected_user_message_mentions_authforge_daemon() {
|
||||||
|
let e = zbus::Error::FDO(Box::new(zbus::fdo::Error::ServiceUnknown(
|
||||||
|
"no daemon".to_string(),
|
||||||
|
)));
|
||||||
|
assert!(user_message(&e).contains("authforge-daemon"));
|
||||||
|
}
|
||||||
|
}
|
||||||
181
gui/src/keys_page.rs
Normal file
181
gui/src/keys_page.rs
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
//! "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::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,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeysPage {
|
||||||
|
pub fn new(parent_window: gtk::Window, toast_overlay: adw::ToastOverlay) -> Rc<Self> {
|
||||||
|
let page = Rc::new(Self {
|
||||||
|
root: adw::Bin::new(),
|
||||||
|
daemon: Rc::new(RefCell::new(None)),
|
||||||
|
parent_window,
|
||||||
|
toast_overlay,
|
||||||
|
});
|
||||||
|
let p = page.clone();
|
||||||
|
glib::MainContext::default().spawn_local(async move {
|
||||||
|
p.try_connect_and_render().await;
|
||||||
|
});
|
||||||
|
page
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn try_connect_and_render(self: Rc<Self>) {
|
||||||
|
match Daemon::connect().await {
|
||||||
|
Ok(d) => {
|
||||||
|
*self.daemon.borrow_mut() = Some(d);
|
||||||
|
self.render_connected().await;
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
let retry = gtk::Button::builder()
|
||||||
|
.label("Retry")
|
||||||
|
.css_classes(["pill", "suggested-action"])
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
let me = self.clone();
|
||||||
|
retry.connect_clicked(move |_| {
|
||||||
|
let me = me.clone();
|
||||||
|
glib::MainContext::default().spawn_local(async move {
|
||||||
|
me.try_connect_and_render().await;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
status.set_child(Some(&retry));
|
||||||
|
self.root.set_child(Some(&status));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn render_connected(self: &Rc<Self>) {
|
||||||
|
let daemon = match self.daemon.borrow().clone() {
|
||||||
|
Some(d) => d,
|
||||||
|
None => {
|
||||||
|
self.render_disconnected("internal: daemon handle missing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let user = current_user();
|
||||||
|
let creds = match daemon.list_credentials(&user).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
self.render_disconnected(&user_message(&e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let page = adw::PreferencesPage::new();
|
||||||
|
let group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Security keys")
|
||||||
|
.description(if creds.is_empty() {
|
||||||
|
"No keys enrolled yet. Click below to enroll your first."
|
||||||
|
} else {
|
||||||
|
"FIDO2 keys enrolled for your account"
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
for c in &creds {
|
||||||
|
let title = if c.nickname.is_empty() {
|
||||||
|
c.id.as_str()
|
||||||
|
} else {
|
||||||
|
c.nickname.as_str()
|
||||||
|
};
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(title)
|
||||||
|
.subtitle(short_id(&c.id))
|
||||||
|
.build();
|
||||||
|
let remove = gtk::Button::builder()
|
||||||
|
.icon_name("user-trash-symbolic")
|
||||||
|
.valign(gtk::Align::Center)
|
||||||
|
.css_classes(["flat"])
|
||||||
|
.tooltip_text("Remove this key")
|
||||||
|
.build();
|
||||||
|
let me = self.clone();
|
||||||
|
let cred_id = c.id.clone();
|
||||||
|
remove.connect_clicked(move |_| {
|
||||||
|
let me = me.clone();
|
||||||
|
let cred_id = cred_id.clone();
|
||||||
|
glib::MainContext::default().spawn_local(async move {
|
||||||
|
me.remove_credential(&cred_id).await;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
row.add_suffix(&remove);
|
||||||
|
group.add(&row);
|
||||||
|
}
|
||||||
|
|
||||||
|
let enroll_group = adw::PreferencesGroup::new();
|
||||||
|
let enroll = adw::ActionRow::builder()
|
||||||
|
.title("Enroll a new key")
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
enroll.add_suffix(>k::Image::builder().icon_name("list-add-symbolic").build());
|
||||||
|
let me = self.clone();
|
||||||
|
enroll.connect_activated(move |_| {
|
||||||
|
let me = me.clone();
|
||||||
|
glib::MainContext::default().spawn_local(async move {
|
||||||
|
me.start_enroll().await;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
enroll_group.add(&enroll);
|
||||||
|
|
||||||
|
page.add(&group);
|
||||||
|
page.add(&enroll_group);
|
||||||
|
self.root.set_child(Some(&page));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_credential(self: &Rc<Self>, cred_id: &str) {
|
||||||
|
let daemon = match self.daemon.borrow().clone() {
|
||||||
|
Some(d) => d,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let user = current_user();
|
||||||
|
if let Err(e) = daemon.remove_own(&user, cred_id).await {
|
||||||
|
self.show_toast(&user_message(&e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.render_connected().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_enroll(self: &Rc<Self>) {
|
||||||
|
let daemon = match self.daemon.borrow().clone() {
|
||||||
|
Some(d) => d,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let me = self.clone();
|
||||||
|
crate::enroll_dialog::present(&self.parent_window, daemon, move || {
|
||||||
|
let me = me.clone();
|
||||||
|
glib::MainContext::default().spawn_local(async move {
|
||||||
|
me.render_connected().await;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_toast(self: &Rc<Self>, msg: &str) {
|
||||||
|
let toast = adw::Toast::builder().title(msg).timeout(5).build();
|
||||||
|
self.toast_overlay.add_toast(toast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn short_id(id: &str) -> String {
|
||||||
|
if id.len() > 16 {
|
||||||
|
format!("{}…{}", &id[..8], &id[id.len() - 4..])
|
||||||
|
} else {
|
||||||
|
id.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,53 @@
|
|||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
use gtk::glib;
|
use gtk::glib;
|
||||||
|
|
||||||
|
mod bus;
|
||||||
|
mod enroll_dialog;
|
||||||
|
mod error;
|
||||||
|
mod keys_page;
|
||||||
|
|
||||||
const APP_ID: &str = "io.dangerousthings.AuthForge";
|
const APP_ID: &str = "io.dangerousthings.AuthForge";
|
||||||
|
|
||||||
fn main() -> glib::ExitCode {
|
fn main() -> glib::ExitCode {
|
||||||
|
// Multi-thread tokio runtime hosts zbus's tokio-flavored futures. The
|
||||||
|
// `_guard` keeps the runtime context active on this thread so calls like
|
||||||
|
// `zbus::Connection::system().await` from within `glib::MainContext::
|
||||||
|
// spawn_local` closures don't panic with "no current reactor".
|
||||||
|
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("build tokio runtime");
|
||||||
|
let _guard = runtime.enter();
|
||||||
|
|
||||||
let app = adw::Application::builder().application_id(APP_ID).build();
|
let app = adw::Application::builder().application_id(APP_ID).build();
|
||||||
app.connect_activate(|app| {
|
app.connect_activate(|app| {
|
||||||
let status = adw::StatusPage::builder()
|
|
||||||
.icon_name("dialog-password-symbolic")
|
|
||||||
.title("Authentication setup")
|
|
||||||
.description("Phase 0 placeholder. Real UI in Phase 8.")
|
|
||||||
.build();
|
|
||||||
let win = adw::ApplicationWindow::builder()
|
let win = adw::ApplicationWindow::builder()
|
||||||
.application(app)
|
.application(app)
|
||||||
.default_width(720)
|
.default_width(720)
|
||||||
.default_height(540)
|
.default_height(540)
|
||||||
.title("Authentication")
|
.title("Authentication")
|
||||||
.content(&status)
|
|
||||||
.build();
|
.build();
|
||||||
|
let header = adw::HeaderBar::new();
|
||||||
|
let stack = adw::ViewStack::new();
|
||||||
|
let switcher = adw::ViewSwitcher::builder()
|
||||||
|
.stack(&stack)
|
||||||
|
.policy(adw::ViewSwitcherPolicy::Wide)
|
||||||
|
.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());
|
||||||
|
stack.add_titled(&keys.root, Some("keys"), "Keys");
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
layout.append(&header);
|
||||||
|
layout.append(&stack);
|
||||||
|
stack.set_vexpand(true);
|
||||||
|
toast_overlay.set_child(Some(&layout));
|
||||||
|
|
||||||
|
win.set_content(Some(&toast_overlay));
|
||||||
win.present();
|
win.present();
|
||||||
});
|
});
|
||||||
app.run()
|
app.run()
|
||||||
|
|||||||
Reference in New Issue
Block a user