Merge branch 'feature/phase-9-and-12-gui'

This commit is contained in:
michael
2026-04-27 12:03:49 -07:00
7 changed files with 627 additions and 3 deletions

View File

@@ -36,10 +36,10 @@
| 6 | PAM module: `pam_authforge_pending.so` (C) | 3 days | ✅ **Code complete** (2026-04-27) — `pamtester` smoke recipe in `pam/TESTING.md` |
| 7 | CLI: `authforgectl` | 4 days | ✅ **Done** (2026-04-27) — 14 subcommands |
| 8 | GUI: app shell + Security Keys tab | 68 days | ✅ **Code complete** (2026-04-27) — 4 unit tests; manual smoke recipe in `gui/TESTING.md` |
| 9 | GUI: Policy tab + lockout-warning UX | 4 days | **Spec'd** — open now (plan at [2026-04-27-phase-9-and-12-gui.md](2026-04-27-phase-9-and-12-gui.md); bundles Phase 12 GUI tab) |
| 9 | GUI: Policy tab + lockout-warning UX | 4 days | **Code complete** (2026-04-27) — bundles Phase 12 GUI tab; 3 unit tests in `policy_form` |
| 10 | First-login flow: autostart entry + fullscreen modal | 4 days | **Spec'd** — open now (Phase 6 ✓ + Phase 8 ✓) |
| 11 | TOTP support (PAM module + GUI tab) — feature flag | 5 days | **Spec'd** — open now (Phase 2 ✓ + Phase 4 ✓ + Phase 8 ✓ for the GUI tab) |
| 12 | Recovery flow (codes + emergency unlock) | 4 days | ✅ **Code complete** (2026-04-27) — backend + PAM landed; GUI tab folded into the Phase 9 lane plan |
| 12 | Recovery flow (codes + emergency unlock) | 4 days | ✅ **Code complete** (2026-04-27) — backend + PAM + GUI tab all landed |
| 13 | Debian packaging finalization (postinst, debconf, purge) | 4 days | **Spec'd** — sequential tail; needs all binaries built |
| 14 | Launchpad PPA build setup | 2 days | **Spec'd** — sequential tail after 13; **VM smoke gate for all "Code complete" phases** |
| 15 | `authforge-gnome-integration` (Users panel shortcut) | 3 days | ✅ **Done** (2026-04-27) — parallel subagent (gnome lane) |
@@ -277,6 +277,29 @@ Backend + PAM landed via the plan at [2026-04-27-phase-12-recovery-backend.md](2
- `pamtester` recovery walkthrough — recipe lives in `pam/TESTING.md` § Smoke test 4.
- End-to-end recovery flow against a real PAM stack: admin issues code via CLI → user logs in entering it at the password prompt → recovery file deleted → pending(re_enroll=true) flag present → re-enrollment modal triggers on next login.
### Phase 9 + Phase 12 GUI closeout notes (2026-04-27)
Landed via the plan at [2026-04-27-phase-9-and-12-gui.md](2026-04-27-phase-9-and-12-gui.md). Built atop the Phase 8 GUI shell. Task 1 (AppContext refactor) was pre-landed by the prep lane (commit `a927d96`); the remaining 8 tasks ship as 8 commits on `feature/phase-9-and-12-gui`.
**Done in Phase 9:**
- `gui/src/app_context.rs``AppContext` struct shared across pages (foreseen by Phase 8 Task 7; pre-landed by prep lane).
- `gui/src/policy_form.rs` — pure mode-mapping + violation-grouping helpers. 3 unit tests.
- `gui/src/policy_page.rs` — Policy tab with three master stacks (gdm-password / sudo / sshd) as `AdwComboRow`, inline `AdwBanner` for lockout simulation results, "Force apply" action, `AdwExpanderRow` for non-master stacks, live refresh via `PolicyChanged` subscription.
**Done in Phase 12 GUI:**
- `gui/src/recovery_page.rs` — list / generate / revoke. One-shot copy dialog (`adw::AlertDialog`) for freshly issued codes; the daemon stores Argon2id hashes only, so plaintext is unrecoverable after the dialog closes.
- `gui/src/bus.rs` extended with `get_policy`, `set_policy`, `generate_recovery_code`, `list_recovery_codes`, `revoke_recovery_code`, `subscribe_policy_changed`.
**Test count:** gui 4 → 7 (+3 from `policy_form`). Workspace total: 7 cli + 13 common + 60 daemon + 7 gui = **87 tests**.
**Plan deviations:**
- `add_titled_with_icon` from the plan replaced with `add_titled` to match the existing `KeysPage` registration in `main.rs` (icons aren't surfaced by the current `ViewSwitcher`).
- `policy_form.rs` carries a module-level `#![allow(dead_code)]` so the staged-but-not-yet-wired symbols pass the `-D warnings` gate at the Task 3 commit; consistent with the same pattern in `bus.rs` / `error.rs` from Phase 8.
- `dialog.present(Some(&parent))` from the plan corrected to `dialog.present(&parent)``adw::Dialog::present` in libadwaita 0.6.0 takes `&impl IsA<Widget>` not `Option<…>`.
**Deferred until Phase 14 VM smoke:**
- Manual Policy + Recovery walkthroughs in `gui/TESTING.md` against a real GNOME session with a Yubikey.
---
# Phase 0: Repository Scaffolding (FULLY DETAILED)

View File

@@ -45,3 +45,22 @@ Walk-through:
6. Kill the daemon (`pkill authforged`), click Enroll → toast appears
with the disconnected message; Retry on the page reconnects after
restart.
## Policy tab smoke
1. Daemon running (same setup as Keys tab smoke).
2. Open the GUI → Policy tab. Three rows: gdm-password, sudo, sshd. All start at "Disabled" on a clean system.
3. Set sudo to "Required" → Apply. With no enrolled keys you'll see an inline banner "Would lock users out — sudo: <user>: no fido2 enrolled" and a "Force apply" button.
4. Click "Force apply" → toast "Policy applied".
5. Switch to Keys tab → enroll a Yubikey → switch back. Apply again with sudo=Required → toast "Policy applied" without the banner.
6. In another terminal: `authforgectl policy show` should reflect the change.
7. Run `authforgectl policy set sshd disabled --methods fido2` externally → Policy tab refreshes within ~1s (PolicyChanged signal).
8. Expand "Advanced" — any non-master stack the daemon discovered shows here with the same Disabled/Optional/Required combo.
## Recovery tab smoke
1. Open Recovery tab. Empty list with hint text.
2. Type "alice" in the username row, click "Generate code". Modal opens showing the 8-digit code with a Copy button. Click Copy → paste somewhere to verify clipboard worked.
3. Close the modal. List now shows alice with "expires in 7 day(s)".
4. Click "Revoke" on alice's row → row vanishes, toast confirms.
5. Generate again, then run `authforgectl recovery list` externally — should show alice with the same expiry.

View File

@@ -4,7 +4,8 @@
//! `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, PendingStatus};
use authforge_common::policy::Policy;
use authforge_common::types::{Credential, PendingStatus, PolicyApplyResult, RecoveryCodeSummary};
const BUS_NAME: &str = "io.dangerousthings.AuthForge";
const OBJECT_PATH: &str = "/io/dangerousthings/AuthForge";
@@ -56,6 +57,35 @@ impl Daemon {
pub async fn clear_pending_flag(&self, user: &str) -> zbus::Result<()> {
self.proxy.call("ClearPendingFlag", &(user,)).await
}
pub async fn get_policy(&self) -> zbus::Result<Policy> {
self.proxy.call("GetPolicy", &()).await
}
/// `force=false` runs the daemon's lockout simulator first; on
/// violations, returns `PolicyApplyResult { applied: false, violations }`
/// without writing.
pub async fn set_policy(&self, p: &Policy, force: bool) -> zbus::Result<PolicyApplyResult> {
self.proxy.call("SetPolicy", &(p, force)).await
}
pub async fn generate_recovery_code(&self, user: &str) -> zbus::Result<String> {
self.proxy.call("GenerateRecoveryCode", &(user,)).await
}
pub async fn list_recovery_codes(&self) -> zbus::Result<Vec<RecoveryCodeSummary>> {
self.proxy.call("ListRecoveryCodes", &()).await
}
pub async fn revoke_recovery_code(&self, user: &str) -> zbus::Result<bool> {
self.proxy.call("RevokeRecoveryCode", &(user,)).await
}
pub async fn subscribe_policy_changed(
&self,
) -> zbus::Result<zbus::proxy::SignalStream<'static>> {
self.proxy.receive_signal("PolicyChanged").await
}
}
#[allow(dead_code)] // wired through keys_page.rs in Task 4.

View File

@@ -7,6 +7,9 @@ mod enroll_dialog;
mod error;
mod firstrun;
mod keys_page;
mod policy_form;
mod policy_page;
mod recovery_page;
const APP_ID: &str = "io.dangerousthings.AuthForge";
@@ -59,6 +62,12 @@ fn main() -> glib::ExitCode {
let keys = keys_page::KeysPage::new(ctx.clone());
stack.add_titled(&keys.root, Some("keys"), "Keys");
let policy = policy_page::PolicyPage::new(ctx.clone());
stack.add_titled(&policy.root, Some("policy"), "Policy");
let recovery = recovery_page::RecoveryPage::new(ctx.clone());
stack.add_titled(&recovery.root, Some("recovery"), "Recovery");
// 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);

92
gui/src/policy_form.rs Normal file
View File

@@ -0,0 +1,92 @@
//! Pure helpers backing the Policy tab. Mode <-> combo index, violation
//! grouping. Widget code calls these; nothing here touches GTK.
//!
//! `dead_code` allowances mirror `bus.rs` — these helpers are wired through
//! `policy_page.rs` in Task 4; without the allow, this commit's `-D warnings`
//! gate would reject the staged-but-unused symbols.
#![allow(dead_code)]
use authforge_common::types::{Mode, Violation};
use std::collections::BTreeMap;
pub(crate) const MODE_LABELS: &[(&str, Mode)] = &[
("Disabled", Mode::Disabled),
("Optional", Mode::Optional),
("Required", Mode::Required),
];
pub(crate) fn mode_to_combo_index(m: Mode) -> u32 {
match m {
Mode::Disabled => 0,
Mode::Optional => 1,
Mode::Required => 2,
}
}
pub(crate) fn combo_index_to_mode(idx: u32) -> Mode {
match idx {
0 => Mode::Disabled,
1 => Mode::Optional,
_ => Mode::Required,
}
}
/// Group violations by stack, joining reasons with "; ". Used to render the
/// inline AdwBanner content when `SetPolicy(force=false)` returns violations.
pub(crate) fn group_violations_by_stack(vs: &[Violation]) -> BTreeMap<String, String> {
let mut by_stack: BTreeMap<String, Vec<String>> = BTreeMap::new();
for v in vs {
by_stack
.entry(v.stack.clone())
.or_default()
.push(format!("{}: {}", v.user, v.reason));
}
by_stack
.into_iter()
.map(|(k, vs)| (k, vs.join("; ")))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mode_combo_roundtrips() {
for m in [Mode::Disabled, Mode::Optional, Mode::Required] {
assert_eq!(combo_index_to_mode(mode_to_combo_index(m)), m);
}
}
#[test]
fn out_of_range_index_clamps_to_required() {
assert_eq!(combo_index_to_mode(99), Mode::Required);
}
#[test]
fn violations_group_by_stack() {
let vs = vec![
Violation {
user: "alice".into(),
stack: "sudo".into(),
reason: "no fido2".into(),
},
Violation {
user: "bob".into(),
stack: "sudo".into(),
reason: "no fido2".into(),
},
Violation {
user: "alice".into(),
stack: "sshd".into(),
reason: "no fido2".into(),
},
];
let g = group_violations_by_stack(&vs);
assert_eq!(g.len(), 2);
assert!(g.get("sudo").unwrap().contains("alice"));
assert!(g.get("sudo").unwrap().contains("bob"));
assert_eq!(g.get("sshd").unwrap(), "alice: no fido2");
}
}

249
gui/src/policy_page.rs Normal file
View File

@@ -0,0 +1,249 @@
//! "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;
});
// Live-refresh subscriber: external `authforgectl policy set` invocations
// bypass the GUI; without subscribing the page would show stale state.
let p = page.clone();
glib::MainContext::default().spawn_local(async move {
let daemon = match p.ctx.daemon.borrow().clone() {
Some(d) => d,
None => return,
};
let mut stream = match daemon.subscribe_policy_changed().await {
Ok(s) => s,
Err(_) => return,
};
use futures_util::StreamExt;
while stream.next().await.is_some() {
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);
// Advanced expander — non-master stacks the daemon discovered. Toggling
// any of these triggers polkit at SetPolicy time; no special GUI plumbing.
let advanced_group = adw::PreferencesGroup::new();
let expander = adw::ExpanderRow::builder()
.title("Advanced")
.subtitle("Per-stack overrides for any PAM stack the daemon knows about.")
.build();
let mut others: Vec<&String> = current
.stacks
.keys()
.filter(|k| !MASTER_STACKS.contains(&k.as_str()))
.collect();
others.sort();
for stack_name in others {
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.as_str())
.model(&model)
.selected(mode_to_combo_index(mode))
.build();
let me = self.clone();
let s = stack_name.clone();
combo.connect_selected_notify(move |c| {
let new_mode = combo_index_to_mode(c.selected());
me.working
.borrow_mut()
.stacks
.entry(s.clone())
.or_insert(StackPolicy {
mode: Mode::Disabled,
methods: vec![Method::Fido2],
})
.mode = new_mode;
});
expander.add_row(&combo);
}
advanced_group.add(&expander);
pref_page.add(&advanced_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(
&gtk::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());
}
}

202
gui/src/recovery_page.rs Normal file
View File

@@ -0,0 +1,202 @@
//! "Recovery" preferences page. Generate / list / revoke recovery codes.
//!
//! Generated codes are shown in a one-shot copy dialog because the daemon
//! stores Argon2id hashes only — there is no path to recover the plaintext
//! after this dialog closes.
use crate::app_context::AppContext;
use crate::error::user_message;
use adw::prelude::*;
use authforge_common::types::RecoveryCodeSummary;
use gtk::glib;
use std::rc::Rc;
use std::time::{SystemTime, UNIX_EPOCH};
pub(crate) struct RecoveryPage {
pub root: adw::Bin,
ctx: AppContext,
}
impl RecoveryPage {
pub fn new(ctx: AppContext) -> Rc<Self> {
let page = Rc::new(Self {
root: adw::Bin::new(),
ctx,
});
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.list_recovery_codes().await {
Ok(entries) => self.render_list(&entries),
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_list(self: &Rc<Self>, entries: &[RecoveryCodeSummary]) {
let pref = adw::PreferencesPage::new();
let group = adw::PreferencesGroup::builder()
.title("Active recovery codes")
.description(if entries.is_empty() {
"No active recovery codes. Use the form below to issue one."
} else {
"Each code is one-shot and Argon2id-hashed at rest. The 8-digit plaintext is shown once at generation."
})
.build();
for e in entries {
let row = adw::ActionRow::builder()
.title(&e.user)
.subtitle(format!("expires {}", expires_relative(e.expires_unix)))
.build();
let revoke = gtk::Button::builder()
.label("Revoke")
.css_classes(["destructive-action"])
.valign(gtk::Align::Center)
.build();
let me = self.clone();
let user = e.user.clone();
revoke.connect_clicked(move |_| {
let me = me.clone();
let user = user.clone();
glib::MainContext::default().spawn_local(async move {
me.do_revoke(&user).await;
});
});
row.add_suffix(&revoke);
group.add(&row);
}
pref.add(&group);
let issue_group = adw::PreferencesGroup::new();
let user_entry = adw::EntryRow::builder().title("Username").build();
let me = self.clone();
let entry_clone = user_entry.clone();
let issue_btn = gtk::Button::builder()
.label("Generate code")
.css_classes(["pill", "suggested-action"])
.halign(gtk::Align::End)
.valign(gtk::Align::Center)
.build();
issue_btn.connect_clicked(move |_| {
let me = me.clone();
let user = entry_clone.text().to_string();
if user.is_empty() {
me.show_toast("Enter a username first.");
return;
}
glib::MainContext::default().spawn_local(async move {
me.do_generate(&user).await;
});
});
issue_group.add(&user_entry);
let issue_row = adw::ActionRow::new();
issue_row.add_suffix(&issue_btn);
issue_group.add(&issue_row);
pref.add(&issue_group);
self.root.set_child(Some(&pref));
}
async fn do_generate(self: &Rc<Self>, user: &str) {
let daemon = match self.ctx.daemon.borrow().clone() {
Some(d) => d,
None => return,
};
match daemon.generate_recovery_code(user).await {
Ok(code) => {
self.show_code_dialog(user, &code);
self.refresh().await;
}
Err(e) => self.show_toast(&user_message(&e)),
}
}
async fn do_revoke(self: &Rc<Self>, user: &str) {
let daemon = match self.ctx.daemon.borrow().clone() {
Some(d) => d,
None => return,
};
match daemon.revoke_recovery_code(user).await {
Ok(true) => {
self.show_toast(&format!("Revoked {user}'s recovery code."));
self.refresh().await;
}
Ok(false) => self.show_toast(&format!("No recovery code for {user}.")),
Err(e) => self.show_toast(&user_message(&e)),
}
}
fn show_code_dialog(self: &Rc<Self>, user: &str, code: &str) {
let dialog = adw::AlertDialog::builder()
.heading(format!("Recovery code for {user}"))
.body("This code is shown only once. Copy it now — it cannot be recovered later.")
.build();
dialog.add_response("copy", "Copy");
dialog.add_response("close", "Close");
dialog.set_default_response(Some("copy"));
dialog.set_close_response("close");
let label = gtk::Label::builder()
.label(code)
.css_classes(["title-1"])
.selectable(true)
.build();
dialog.set_extra_child(Some(&label));
let code_owned = code.to_string();
dialog.connect_response(None, move |d, resp| {
if resp == "copy" {
if let Some(disp) = gtk::gdk::Display::default() {
disp.clipboard().set_text(&code_owned);
}
}
d.close();
});
dialog.present(&self.ctx.parent_window);
}
fn show_toast(self: &Rc<Self>, msg: &str) {
self.ctx
.toast_overlay
.add_toast(adw::Toast::builder().title(msg).timeout(5).build());
}
}
fn expires_relative(unix: u64) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if unix <= now {
return "expired".to_string();
}
let delta = unix - now;
if delta >= 24 * 3600 {
format!("in {} day(s)", delta / (24 * 3600))
} else if delta >= 3600 {
format!("in {} hour(s)", delta / 3600)
} else {
format!("in {} min", delta / 60)
}
}