Ships four documents:
* 2026-04-27-parallel-fanout-9-10-11.md — coordinator. File-conflict map,
prep-lane scope rationale, post-prep parallel-safety guarantee, merge
order rule, and risks.
* 2026-04-27-prep-shell-and-pending.md — small ~1hr prep lane: extract
AppContext, add --first-run flag scaffold (with stub firstrun::run),
add daemon GetPendingStatus D-Bus method + GUI client wrapper, new
PendingStatus wire type. 6 tasks, single agent.
* 2026-04-27-phase-10-firstrun.md — first-login flow. Fullscreen modal,
60s idle watchdog (TDD'd as pure WatchdogState), enrollment via Phase
8 enroll_dialog, ClearPendingFlag on success, gnome-session-quit on
idle, autostart .desktop entry, debian install. 6 tasks.
* 2026-04-27-phase-11-totp.md — TOTP support behind default-on Cargo
feature. Daemon-side: 160-bit secret + base32 + otpauth URI + atomic
0600 writes in pam_google_authenticator format. PAM profile renderer
extension. D-Bus surface. CLI subcommand. GUI tab with QR modal.
Deviation flagged: TOTP recovery codes reuse the Phase 12 recovery
flow rather than introducing a parallel hashed-recovery file format.
9 tasks.
Execution model: prep lane first (single agent ~1hr), then dispatch three
subagents in parallel worktrees for the three follow-on lanes. Per the
conflict map, the lanes are file-disjoint after prep merges; merge order
is any-order.
If all four lanes land cleanly, the roadmap jumps from 13/19 to 17/19
phases code-complete in a single session, leaving Phase 13 (deb finalization),
14 (PPA + smoke), 17 (integration tests), 18 (user docs), and the v1.0
release tag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
23 KiB
Phase 10: First-Login Flow — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
EXECUTION GATE: Do not start until the prep lane (2026-04-27-prep-shell-and-pending.md) has merged to
main. This lane fills thefirstrun::run()stub the prep lane established and consumes theGetPendingStatusD-Bus method the prep lane added.
Goal: Land Flow C from the design doc end-to-end. When a user with chage -d 0 (forced password change) logs in for the first time, the GTK GUI launches in fullscreen modal mode, queries the daemon's pending flag for the current user, prompts them through enrollment, clears the pending flag on success, and exits cleanly. A 60-second watchdog falls back to gnome-session-quit --logout if the user goes idle.
Architecture:
gui/src/firstrun.rsis the modal. Replaces the prep lane's stub (fn run(_ctx) { exit(0) }) with the real flow.- The modal is an
adw::ApplicationWindowconfiguredfullscreened(true), withdecorated(false)and a sticky-on-top hint. NoHeaderBar, no ViewSwitcher. - Flow: connect to daemon →
GetPendingStatus(current_user)→ if absent, exit; if present, present anAdwStatusPagewith a "Get started" button → on click, run the sameenroll_dialogPhase 8 wired up → on success, callClearPendingFlag→ close window + exit. On user inactivity (no clicks/keypresses for 60s) →gnome-session-quit --logout --no-prompt. - Autostart:
gui/data/authforge-firstrun.desktopis a.desktopentry installed to/etc/xdg/autostart/withOnlyShowIn=GNOMEandX-GNOME-Autostart-Phase=Initialization. It runsauthforge --first-runat session start; the binary returns immediately if the daemon reports no pending flag, so the cost on every session start is one D-Bus call (~ms).
Tech Stack:
gtk4-rs+libadwaita(already pinned).zbus(workspace tokio config).gtk::EventControllerKey+gtk::GestureClickfor activity tracking the watchdog.glib::source::timeout_add_localfor the watchdog tick.std::process::Command::new("gnome-session-quit")for the fallback logout.
Reference:
- Master plan: 2026-04-26-authforge-implementation.md § Phase 10 (line 1209).
- Design doc: 2026-04-26-authforge-design.md § Flow C.
- Sibling page (mirror this style):
gui/src/keys_page.rs(post-Phase 8, post-prep AppContext refactor). - Enrollment dialog (reuse):
gui/src/enroll_dialog.rs(Phase 8). - Daemon contract:
GetPendingStatus(user) -> PendingStatus(added by prep lane),ClearPendingFlag(user)(Phase 1). - Coordinator: 2026-04-27-parallel-fanout-9-10-11.md.
Conventions
- One logical change per commit. Conventional prefixes scoped to
gui/pkg/docs. - Always commit with
--no-gpg-sign. - TDD only for the watchdog timer logic in
firstrun::watchdog::WatchdogState(pure logic — testable without a display server). - After every commit:
cargo fmt --all && cargo clippy -p authforge-gui --all-targets -- -D warnings && cargo build -p authforge-gui. - Manual smoke (the only realistic acceptance gate for the modal UX) is in Task 6. CI does not run it.
Task 1: WatchdogState pure-logic helper (TDD)
Files:
- Create:
gui/src/firstrun/watchdog.rs - Modify:
gui/src/firstrun.rs(currently a one-line stub from prep — convert to a module dir or addmod watchdog;)
Background: Watchdog state is "did anything happen in the last N seconds?" — that's testable without GTK. Pull it out so the widget code in Task 2 is just a glue layer.
Step 1: Convert firstrun.rs to a module dir
If gui/src/firstrun.rs exists as a single file (from prep lane Task 2), turn it into:
gui/src/firstrun/mod.rs <- the existing run(ctx) stub
gui/src/firstrun/watchdog.rs <- new
Move the prep-lane stub from firstrun.rs to firstrun/mod.rs, then delete firstrun.rs.
(If the prep lane already kept it as a single file and mod firstrun; resolves to firstrun.rs, deleting the file + creating firstrun/mod.rs is the same module from main.rs's perspective — no main.rs edit needed.)
Step 2: Write the test + impl
// gui/src/firstrun/watchdog.rs
//! Tracks the last-activity timestamp; the GTK timer queries this each tick
//! to decide whether 60s of idle has elapsed.
use std::time::{Duration, Instant};
pub(crate) struct WatchdogState {
last_activity: Instant,
timeout: Duration,
}
impl WatchdogState {
pub fn new(timeout: Duration) -> Self {
Self {
last_activity: Instant::now(),
timeout,
}
}
pub fn poke(&mut self) {
self.last_activity = Instant::now();
}
/// True iff `timeout` has passed since the last poke. Once expired,
/// stays expired until the caller drops + recreates.
pub fn expired(&self) -> bool {
self.last_activity.elapsed() >= self.timeout
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fresh_state_is_not_expired() {
let w = WatchdogState::new(Duration::from_secs(60));
assert!(!w.expired());
}
#[test]
fn zero_timeout_is_immediately_expired() {
let w = WatchdogState::new(Duration::from_millis(0));
std::thread::sleep(Duration::from_millis(2));
assert!(w.expired());
}
#[test]
fn poke_resets_the_clock() {
let mut w = WatchdogState::new(Duration::from_millis(100));
std::thread::sleep(Duration::from_millis(150));
assert!(w.expired());
w.poke();
assert!(!w.expired());
}
}
Step 3: Wire mod watchdog; into firstrun/mod.rs
// gui/src/firstrun/mod.rs
mod watchdog;
pub(crate) use watchdog::WatchdogState; // re-export for the widget code
#![allow(dead_code)] // run() body still stub; Task 2 fills it.
use crate::app_context::AppContext;
pub(crate) fn run(_ctx: AppContext) {
eprintln!("authforge: --first-run mode is a Phase 10 stub; exiting.");
std::process::exit(0);
}
Step 4: Run tests
Run: cargo test -p authforge-gui firstrun::watchdog
Expected: 3 PASS.
Step 5: Commit
git add gui/src/firstrun/mod.rs gui/src/firstrun/watchdog.rs
git rm gui/src/firstrun.rs # if it existed as a single file
git commit --no-gpg-sign -m "feat(gui): WatchdogState pure-logic helper for first-run modal"
Task 2: Fullscreen modal window + enrollment flow
Files:
- Modify:
gui/src/firstrun/mod.rs— replace the stubrun()with the real flow.
Background: The window is fullscreen, undecorated, and shows an AdwStatusPage with three states: connecting / pending-flag-found / pending-flag-absent. On a present pending flag, the user clicks "Get started" → enroll_dialog::present runs against the daemon → on success, ClearPendingFlag → window closes → process exits.
Step 1: Replace run()
// gui/src/firstrun/mod.rs
mod watchdog;
pub(crate) use watchdog::WatchdogState;
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;
use std::time::Duration;
const WATCHDOG_TIMEOUT: Duration = Duration::from_secs(60);
pub(crate) fn run(ctx: AppContext) {
let win = adw::ApplicationWindow::builder()
.application(>k::gio::Application::default()
.expect("application set on connect_activate")
.downcast::<adw::Application>()
.expect("adw::Application"))
.title("Authentication setup")
.decorated(false)
.fullscreened(true)
.build();
let _ = ctx; // window owns its own context for the modal; not strictly needed
let status = adw::StatusPage::builder()
.icon_name("dialog-password-symbolic")
.title("Setting up authentication")
.description("Connecting to the authentication daemon…")
.build();
win.set_content(Some(&status));
// Watchdog state shared across the activity-poke handlers and the timer.
let watchdog = Rc::new(RefCell::new(WatchdogState::new(WATCHDOG_TIMEOUT)));
install_activity_pokes(&win, watchdog.clone());
install_watchdog_timer(&win, watchdog.clone());
// Async startup: GetPendingStatus → render flow.
let win_for_async = win.clone();
let status_for_async = status.clone();
glib::MainContext::default().spawn_local(async move {
let daemon = match Daemon::connect().await {
Ok(d) => d,
Err(e) => {
status_for_async.set_description(Some(&user_message(&e)));
// Stay open so the user sees the error; watchdog will
// logout in 60s if they don't take action.
return;
}
};
let user = current_user();
let s = match daemon.get_pending_status(&user).await {
Ok(s) => s,
Err(e) => {
status_for_async.set_description(Some(&user_message(&e)));
return;
}
};
if !s.present {
// Nothing for us to do — exit cleanly. Most session starts
// hit this path; cost is one D-Bus call.
win_for_async.close();
std::process::exit(0);
}
render_get_started(&win_for_async, &status_for_async, daemon, user);
});
win.present();
}
fn render_get_started(
win: &adw::ApplicationWindow,
status: &adw::StatusPage,
daemon: Daemon,
user: String,
) {
status.set_title("Welcome");
status.set_description(Some("Enroll a security key to finish setting up your account."));
let go = gtk::Button::builder()
.label("Get started")
.css_classes(["pill", "suggested-action"])
.halign(gtk::Align::Center)
.build();
let win_for_click = win.clone();
let daemon_for_click = daemon.clone();
let user_for_click = user.clone();
go.connect_clicked(move |btn| {
// Disable the button so a second click doesn't double-prompt.
btn.set_sensitive(false);
let win_inner = win_for_click.clone();
let daemon_inner = daemon_for_click.clone();
let user_inner = user_for_click.clone();
crate::enroll_dialog::present(
&win_inner.clone().upcast::<gtk::Window>(),
daemon_inner.clone(),
move || {
// On success: clear the pending flag, then exit.
let win_done = win_inner.clone();
let daemon_done = daemon_inner.clone();
let user_done = user_inner.clone();
glib::MainContext::default().spawn_local(async move {
if let Err(e) = daemon_done.clear_pending_flag(&user_done).await {
// Even if clearing fails, exit; the user enrolled
// a key and the next login will succeed via pam_u2f
// even if pending is still set.
eprintln!("authforge: ClearPendingFlag failed: {}", user_message(&e));
}
win_done.close();
std::process::exit(0);
});
},
);
});
status.set_child(Some(&go));
}
fn install_activity_pokes(win: &adw::ApplicationWindow, watchdog: Rc<RefCell<WatchdogState>>) {
// Any keypress or click counts as activity.
let key = gtk::EventControllerKey::new();
let w = watchdog.clone();
key.connect_key_pressed(move |_, _, _, _| {
w.borrow_mut().poke();
glib::Propagation::Proceed
});
win.add_controller(key);
let click = gtk::GestureClick::new();
let w = watchdog.clone();
click.connect_pressed(move |_, _, _, _| w.borrow_mut().poke());
win.add_controller(click);
}
fn install_watchdog_timer(win: &adw::ApplicationWindow, watchdog: Rc<RefCell<WatchdogState>>) {
let win_for_timer = win.clone();
glib::source::timeout_add_local(Duration::from_secs(5), move || {
if watchdog.borrow().expired() {
// 60s idle — kick the user back out. gnome-session-quit
// is the documented path for "log this user out cleanly."
let _ = std::process::Command::new("gnome-session-quit")
.arg("--logout")
.arg("--no-prompt")
.spawn();
win_for_timer.close();
return glib::ControlFlow::Break;
}
glib::ControlFlow::Continue
});
}
pub(crate) fn run_or_stub(_ctx: AppContext) {
// Old prep stub kept as a fallback name during PR review; remove this fn
// once the real `run()` is reviewed.
}
(Drop the run_or_stub function before commit — it's only there to ease review of the diff against the prep stub.)
Step 2: Build + clippy
cargo build -p authforge-gui
cargo clippy -p authforge-gui --all-targets -- -D warnings
Expected: clean. The first compile may surface API-shape mismatches against the installed gtk4-rs minor (fullscreened builder, StatusPage::set_child, EventControllerKey return type); fix locally — these are stable surface in gtk4-rs 0.8 + libadwaita 0.6.
Step 3: Smoke test (deferred to manual; CI cannot run a fullscreen window)
Skip: covered in Task 6.
Step 4: Commit
git add gui/src/firstrun/mod.rs
git commit --no-gpg-sign -m "feat(gui): first-run modal with enrollment + 60s idle watchdog"
Task 3: Autostart .desktop entry
Files:
- Create:
gui/data/authforge-firstrun.desktop - Modify:
debian/authforge-gui.install— install the autostart entry to/etc/xdg/autostart/
Step 1: Create the .desktop file
# gui/data/authforge-firstrun.desktop
[Desktop Entry]
Type=Application
Name=AuthForge First-Run Setup
Comment=Prompts new users to enroll a security key.
Exec=authforge --first-run
NoDisplay=true
OnlyShowIn=GNOME;
X-GNOME-Autostart-Phase=Initialization
X-GNOME-Autostart-enabled=true
NoDisplay=true keeps it out of the apps menu — it's an autostart-only entry. OnlyShowIn=GNOME; matches the rest of the project's GNOME-first stance (other DEs can ship their own entries later). X-GNOME-Autostart-Phase=Initialization runs it before the user's normal autostarts.
Step 2: Install via debian/authforge-gui.install
Read the existing debian/authforge-gui.install — it likely has a single line installing the binary. Append:
gui/data/authforge-firstrun.desktop /etc/xdg/autostart
Step 3: Verify the file syntax
desktop-file-validate is the canonical check, but the harness sandbox may not have it. As a fallback, verify the file is parseable as INI:
python3 -c "import configparser; c = configparser.ConfigParser(); c.read('gui/data/authforge-firstrun.desktop'); print(dict(c['Desktop Entry']))"
Expected: dict prints with Type, Name, Exec, OnlyShowIn, etc.
Step 4: Commit
git add gui/data/authforge-firstrun.desktop debian/authforge-gui.install
git commit --no-gpg-sign -m "feat(pkg): autostart .desktop entry running authforge --first-run on GNOME login"
Task 4: Daemon-disconnect graceful exit
Files:
- Modify:
gui/src/firstrun/mod.rs
Background: Task 2 leaves a daemon-connect failure visible-but-stuck (the watchdog eventually fires, but the user sees no feedback for 60s). Tighten the UX: a connect failure shows a "Daemon unavailable" status with a manual "Skip" button that just exits the modal.
Step 1: Add the skip button on connect failure
Replace the Err(e) => { status_for_async.set_description(...); return; } arm in run():
Err(e) => {
status_for_async.set_title("Setup unavailable");
status_for_async.set_description(Some(&user_message(&e)));
let skip = gtk::Button::builder()
.label("Skip")
.css_classes(["pill"])
.halign(gtk::Align::Center)
.build();
let win_skip = win_for_async.clone();
skip.connect_clicked(move |_| {
win_skip.close();
std::process::exit(0);
});
status_for_async.set_child(Some(&skip));
return;
}
Also adjust the GetPendingStatus-error arm to render the same Skip button.
Step 2: Build + clippy
Run: cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings
Expected: clean.
Step 3: Commit
git add gui/src/firstrun/mod.rs
git commit --no-gpg-sign -m "feat(gui): firstrun modal shows Skip button on daemon-unreachable"
Task 5: Master plan closeout note
Files:
- Modify:
docs/plans/2026-04-26-authforge-implementation.md
Step 1: Flip Phase 10 to ✅ Code complete
In the roadmap row at line 40 (post-prep, post-Phase-12-merge):
| 10 | First-login flow: autostart entry + fullscreen modal | 4 days | ✅ **Code complete** (yyyy-mm-dd) — VM smoke deferred |
Step 2: Append a closeout note
After the Phase 12 backend closeout block:
### Phase 10 closeout notes (yyyy-mm-dd)
Landed via [2026-04-27-phase-10-firstrun.md](2026-04-27-phase-10-firstrun.md). Built atop the prep lane's `--first-run` flag scaffold and the `GetPendingStatus` D-Bus method.
**Done:**
- `gui/src/firstrun/{mod,watchdog}.rs` — fullscreen `adw::ApplicationWindow` modal, 60s idle watchdog (3 unit tests on `WatchdogState`), enrollment via the Phase 8 `enroll_dialog`, `ClearPendingFlag` on success, `gnome-session-quit --logout` on idle.
- `gui/data/authforge-firstrun.desktop` — autostart entry installed to `/etc/xdg/autostart/`. `OnlyShowIn=GNOME`, `X-GNOME-Autostart-Phase=Initialization`. Cost on every session start: one D-Bus call to `GetPendingStatus`; no pending flag → immediate exit 0.
- `debian/authforge-gui.install` — installs the autostart entry alongside the binary.
**Plan deviations:** _(fill in)_.
**Deferred until Phase 14 VM smoke:** the full Flow C walkthrough (`useradd -m testuser; chage -d 0 testuser; sudo authforgectl pending set testuser --methods fido2; logout; login; see modal; enroll; see desktop`).
Step 3: Commit
git add docs/plans/2026-04-26-authforge-implementation.md
git commit --no-gpg-sign -m "docs: phase 10 closeout notes"
Task 6: Acceptance gate + VM smoke recipe
Files:
- Create:
gui/FIRSTRUN-TESTING.md
Step 1: Document the manual recipe
# Testing the First-Run Modal (Phase 10)
Phase 10's user-visible flow can only be exercised against a real GTK
session. CI verifies that the binary builds and `--first-run` parses;
this recipe is the manual gate.
## Prerequisites
- Ubuntu 24.04 VM with GNOME on Wayland (matches the project's target).
- `authforge-daemon` + `authforge-gui` debs installed (Phase 14 PPA build).
- A USB FIDO2 device (Yubikey, Solo, etc.) attached to the VM.
## Walk-through
```bash
# As root in the VM:
sudo useradd -m testuser
sudo passwd testuser # set a temporary password
sudo chage -d 0 testuser # force password change at next login
sudo authforgectl pending set testuser --methods fido2
# Log out of the current session, log in as testuser.
# Expected sequence:
# 1. GDM forces a password change → set the real password.
# 2. Session starts.
# 3. authforge --first-run launches via the autostart entry.
# 4. Fullscreen modal appears: "Welcome — Enroll a security key…"
# 5. Click "Get started" → enrollment dialog appears.
# 6. Touch the Yubikey → modal closes → desktop usable.
# 7. Verify: authforgectl pending list (run as root in another tty) shows
# no pending flag for testuser.
# Re-test the auto-logout watchdog:
sudo authforgectl pending set testuser --methods fido2
# Log out, log in as testuser, do nothing for 60+ seconds.
# Expected: the user is logged out via gnome-session-quit and returned
# to GDM.
# Re-test the daemon-down case:
sudo systemctl stop authforge-daemon.service
# Log out, log in as testuser. The modal shows "Setup unavailable" with
# a Skip button; clicking it returns to the desktop.
sudo systemctl start authforge-daemon.service
Pass criteria
- The modal appears within 2s of session start.
- Successful enrollment clears the pending flag and exits the modal.
- 60s idle triggers a clean session logout.
- Daemon-down shows a graceful Skip button rather than hanging.
- No pending flag → no modal → session start cost is < 100ms (verify
with
journalctl --since=now -ffor the autostart unit).
**Step 2: Commit**
```bash
git add gui/FIRSTRUN-TESTING.md
git commit --no-gpg-sign -m "docs(gui): VM smoke recipe for the first-run modal"
Phase 10 Acceptance Gate
Verify before merging:
cargo build --workspace --releaseclean.cargo clippy --workspace --all-targets -- -D warningsclean.cargo test --workspace— gui test count up by 3 (the newWatchdogStatetests).cargo fmt --all -- --checkclean.target/release/authforge --first-runexits 0 in either of: (a) "no pending flag" path (immediate exit); (b) "daemon down" path (modal opens, Skip available — verified by hand only because no display server in CI).python3 -c "import configparser; ..."parsesgui/data/authforge-firstrun.desktopclean.- Manual VM smoke (deferred to Phase 14, recipe in
gui/FIRSTRUN-TESTING.md): four pass criteria all green.
Risks / known unknowns
| Risk | Mitigation |
|---|---|
gnome-session-quit is GNOME-only; on KDE / non-GNOME the watchdog logout is a no-op. |
The .desktop entry is OnlyShowIn=GNOME; so on non-GNOME the modal never autostarts. Acceptable v1. KDE port (out of scope) ships its own first-run path. |
| The user closes the modal via Alt-F4 / window manager kill before enrollment finishes. | The modal is decorated(false) — no window controls. Alt-F4 still works (Wayland) but the watchdog catches abandonment. If a user kills the modal, the pending flag stays set and they hit it again next login. |
--first-run is invoked manually (e.g. by a developer) on a system where the daemon isn't installed. |
Daemon-connect failure path renders the Skip button. Process exits 0. |
glib::source::timeout_add_local requires !Send callbacks — the watchdog Rc<RefCell<...>> is fine, but writing tests against the GTK main loop is not. |
WatchdogState is pure-logic and Send/Sync; the GTK glue is tested manually. |
| The modal grabs focus before GDM's password-change dialog has fully closed — visual glitch / focus race. | The autostart entry uses X-GNOME-Autostart-Phase=Initialization which runs before the user's normal autostart phase but after GDM hands the session to GNOME — that's the right slot. If a race manifests in VM smoke, fall back to phase=Application (slightly later). |
Wayland decorated(false) + fullscreened(true) is the right combination — older X11 needed keep_above too. |
We target Ubuntu 24.04 which is Wayland-by-default. X11 fallback is already covered by the autostart entry running per-session-type without explicit X-only targeting. |
Execution Handoff
Plan complete. Single lane, single agent. Suggested execution: open a fresh session in a worktree using superpowers:using-git-worktrees, then drive task-by-task with superpowers:executing-plans. Do not start until the prep lane has merged to main.
After this lane lands, the coordinator doc lists the parallel-safe order: any of (Phase 9 + 12 GUI), Phase 10, Phase 11 can merge in any order.