Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger-ui

src/invites.rs

//! Admin → **Invites**: the decisions behind the pane, host-tested.
//!
//! holger mints, lists and revokes single-use invite links on its passkey door
//! (`/-/auth/passkey/invites*`, an admin, stepped up for a mint); this pane
//! calls that door with the person's session and renders holger's answer. The
//! link is shown ONCE, in the answer to the mint, and is nowhere else — holger
//! keeps only its hash, so the list can say who an invite was for but never
//! show the link again.
//!
//! holger refuses with `{"error": "…"}`; the sentence is read out of it
//! ([`crate::session::refusal_words`]). A stale touch carries `"code":"stepup"` and is
//! answered in place ([`crate::step_up`], A28 M9).
#![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]

use serde::{Deserialize, Serialize};

pub const INVITES_PATH: &str = "/-/auth/passkey/invites";
pub const LIST_PATH: &str = "/-/auth/passkey/invites/list";
pub const REVOKE_PATH: &str = "/-/auth/passkey/invites/revoke";

/// The roles an invite can carry. Admin is not offered: the server refuses to
/// mint one, so a new admin is never one stolen link away.
pub const ROLES: [(&str, &str); 2] = [
    ("viewer", "Viewer — reads only"),
    ("operator", "Operator — reads and changes"),
];

/// `POST /auth/passkey/invites` body.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct MintRequest {
    pub role: String,
    /// The name the invitee will carry; `None` leaves it to them.
    #[serde(rename = "display", skip_serializing_if = "Option::is_none")]
    pub user: Option<String>,
}

impl MintRequest {
    /// A blank name is no name: the invitee then picks their own.
    pub fn new(role: &str, user: &str) -> Self {
        let user = user.trim();
        Self {
            role: role.to_owned(),
            user: (!user.is_empty()).then(|| user.to_owned()),
        }
    }
}

/// The answer to a mint — the only place the link ever appears.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct Minted {
    pub id: String,
    /// holger's secret; the link is built from it ([`invite_url`]).
    #[serde(rename = "invite")]
    pub secret: String,
    /// The link — filled in from the secret once holger has answered.
    #[serde(default)]
    pub url: String,
    #[serde(deserialize_with = "role_in_words")]
    pub role: String,
    #[serde(default, rename = "display", deserialize_with = "name_or_none")]
    pub user: Option<String>,
    pub expires: i64,
}

/// Pure: the link the invitee opens — this console's login page, on this origin.
pub fn invite_url(origin: &str, secret: &str) -> String {
    format!("{}{}?invite={secret}", origin.trim_end_matches('/'), crate::session::LOGIN_PAGE)
}

fn role_in_words<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
    let r = String::deserialize(d)?;
    Ok(crate::session::role_word(&r).to_owned())
}

fn name_or_none<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
    let n = Option::<String>::deserialize(d)?;
    Ok(n.filter(|n| !n.trim().is_empty()))
}

#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct InviteList {
    pub invites: Vec<InviteRow>,
}

/// One row of holger's invite list. Times are unix seconds.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct InviteRow {
    pub id: String,
    #[serde(deserialize_with = "role_in_words")]
    pub role: String,
    #[serde(default, rename = "display", deserialize_with = "name_or_none")]
    pub user: Option<String>,
    pub created: i64,
    pub expires: i64,
    pub created_by: String,
    #[serde(default)]
    pub used_by: Option<String>,
    #[serde(default)]
    pub used_at: Option<i64>,
    pub revoked: bool,
    /// The server's own verdict, taken over a clock this page does not own.
    pub open: bool,
}

impl InviteRow {
    /// What became of it, in the order the server decides it: a revoked or
    /// redeemed invite says so even after its expiry has also passed.
    /// The tone is a word (`good`/`warn`/`neutral`); the pane maps it.
    pub fn status(&self) -> (String, &'static str) {
        if self.revoked {
            ("revoked".into(), "neutral")
        } else if let Some(by) = &self.used_by {
            (format!("used by {by}"), "good")
        } else if self.open {
            ("open".into(), "warn")
        } else {
            ("expired".into(), "neutral")
        }
    }

    pub fn for_whom(&self) -> String {
        self.user.clone().unwrap_or_else(|| "(their choice)".into())
    }
}

/// Newest first — the one just minted is at the top.
pub fn newest_first(mut rows: Vec<InviteRow>) -> Vec<InviteRow> {
    rows.sort_by(|a, b| b.created.cmp(&a.created).then_with(|| a.id.cmp(&b.id)));
    rows
}

/// Why a mint, list or revoke was refused, as a sentence.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refused {
    /// The session is admin but its user-verified touch is too old: holger's
    /// machine-readable `"code":"stepup"` (A28 M9). [`send`] answers it in place
    /// with a fresh touch, so the pane sees it only if holger still refuses after one.
    StepUp,
    /// A fresh touch was asked for and did not happen; the words say why.
    Touch(String),
    Other(String),
}

pub fn refused(status: u16, body: &str) -> Refused {
    if crate::step_up::is_step_up(status, body) {
        return Refused::StepUp;
    }
    let words = crate::session::refusal_words(status, body);
    let body = if words == format!("HTTP {status}") { "" } else { words.as_str() };
    match status {
        401 => Refused::Other("your session has ended — log in again".into()),
        _ if !body.is_empty() => Refused::Other(body.to_owned()),
        _ => Refused::Other(format!("HTTP {status}")),
    }
}

/// How long until `expires`, in the largest unit that is not zero.
pub fn expires_words(expires: i64, now: i64) -> String {
    let left = expires - now;
    if left <= 0 {
        return "expired".into();
    }
    if left >= 86_400 {
        format!("in {} d", left / 86_400)
    } else if left >= 3_600 {
        format!("in {} h", left / 3_600)
    } else {
        format!("in {} min", (left / 60).max(1))
    }
}

/// One call to the invite door; holger's step-up refusal is answered in place
/// ([`crate::step_up::answer_in_place`]: a fresh touch, the same call once more).
#[cfg(target_arch = "wasm32")]
async fn send<T: serde::de::DeserializeOwned>(path: &str, body: &serde_json::Value) -> Result<T, Refused> {
    crate::step_up::answer_in_place(|| send_once(path, body), |e| *e == Refused::StepUp, crate::step_up::touch, |why| Refused::Touch(crate::step_up::touch_failed_words(&why))).await
}

#[cfg(target_arch = "wasm32")]
async fn send_once<T: serde::de::DeserializeOwned>(path: &str, body: &serde_json::Value) -> Result<T, Refused> {
    let (status, text) = crate::session::door(path, Some(body)).await.map_err(Refused::Other)?;
    if !(200..300).contains(&status) {
        return Err(refused(status, &text));
    }
    serde_json::from_str(&text).map_err(|e| Refused::Other(format!("holger answered something unreadable: {e}")))
}

#[cfg(target_arch = "wasm32")]
pub async fn list() -> Result<Vec<InviteRow>, Refused> {
    send::<InviteList>(LIST_PATH, &serde_json::json!({})).await.map(|l| newest_first(l.invites))
}

#[cfg(target_arch = "wasm32")]
pub async fn mint(body: &MintRequest) -> Result<Minted, Refused> {
    let ask = serde_json::to_value(body).map_err(|e| Refused::Other(e.to_string()))?;
    let mut m: Minted = send(INVITES_PATH, &ask).await?;
    let origin = web_sys::window().and_then(|w| w.location().origin().ok()).unwrap_or_default();
    m.url = invite_url(&origin, &m.secret);
    Ok(m)
}

#[cfg(target_arch = "wasm32")]
pub async fn revoke(id: &str) -> Result<(), Refused> {
    send::<serde_json::Value>(REVOKE_PATH, &serde_json::json!({ "id": id })).await.map(|_| ())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn row(created: i64) -> InviteRow {
        InviteRow {
            id: format!("id{created}"),
            role: "viewer".into(),
            user: None,
            created,
            expires: created + 3_600,
            created_by: "bob".into(),
            used_by: None,
            used_at: None,
            revoked: false,
            open: true,
        }
    }

    #[test]
    fn a_blank_name_is_left_to_the_invitee() {
        assert_eq!(MintRequest::new("viewer", "  ").user, None);
        assert_eq!(
            MintRequest::new("viewer", " anna ").user.as_deref(),
            Some("anna")
        );
        let wire = serde_json::to_string(&MintRequest::new("operator", "")).unwrap();
        assert_eq!(wire, r#"{"role":"operator"}"#);
        let named = serde_json::to_string(&MintRequest::new("viewer", "anna")).unwrap();
        assert_eq!(named, r#"{"role":"viewer","display":"anna"}"#, "holger reads the name as `display`");
    }

    #[test]
    fn admin_is_never_offered() {
        assert!(ROLES.iter().all(|(r, _)| *r != "admin"));
    }

    #[test]
    fn revoked_and_used_win_over_expired() {
        let mut r = row(0);
        r.open = false;
        assert_eq!(r.status().0, "expired");
        r.used_by = Some("anna".into());
        assert_eq!(r.status().0, "used by anna");
        r.revoked = true;
        assert_eq!(r.status().0, "revoked");
        assert_eq!(row(0).status().0, "open");
    }

    #[test]
    fn newest_is_first() {
        let ids: Vec<_> = newest_first(vec![row(1), row(3), row(2)])
            .into_iter()
            .map(|r| r.created)
            .collect();
        assert_eq!(ids, [3, 2, 1]);
    }

    /// A28 M9: holger's step-up refusal is recognised by its machine-readable code
    /// (`"code":"stepup"`), never by the first words of its sentence.
    #[test]
    fn the_step_up_refusal_is_recognised_and_others_keep_their_sentence() {
        let step = "log in again with your key first: this needs a user-verified session from the last 10 minutes";
        assert_eq!(refused(403, &serde_json::json!({ "error": step, "code": "stepup" }).to_string()), Refused::StepUp, "holger's coded refusal");
        assert_eq!(refused(403, r#"{"error":"a fresh touch is needed","code":"stepup"}"#), Refused::StepUp, "whatever its words");
        assert_eq!(refused(403, &serde_json::json!({ "error": step }).to_string()), Refused::Other(step.into()), "no code, no step-up: the words are shown");
        assert_eq!(
            refused(403, "this needs an admin passkey"),
            Refused::Other("this needs an admin passkey".into())
        );
        assert_eq!(
            refused(400, "that name is reserved"),
            Refused::Other("that name is reserved".into())
        );
        assert_eq!(refused(500, ""), Refused::Other("HTTP 500".into()));
    }

    #[test]
    fn expiry_in_the_largest_unit() {
        assert_eq!(expires_words(100, 200), "expired");
        assert_eq!(expires_words(230, 200), "in 1 min");
        assert_eq!(expires_words(200 + 7_200, 200), "in 2 h");
        assert_eq!(expires_words(200 + 3 * 86_400, 200), "in 3 d");
    }

    #[test]
    fn the_wire_rows_parse() {
        // holger's own shape: `display` (empty = the invitee's choice), its role words.
        let json = r#"{"invites":[{"id":"x","display":"","groups":["holger-readers"],"role":"reader","created":1,"expires":2,
            "created_by":"bob","used_by":null,"used_at":null,"revoked":false,"open":true}]}"#;
        let l: InviteList = serde_json::from_str(json).unwrap();
        assert_eq!(l.invites[0].for_whom(), "(their choice)");
        assert_eq!(l.invites[0].role, "viewer");
        let m: Minted = serde_json::from_str(r#"{"invite":"s3cret","id":"x","expires":9,"display":"anna","groups":[],"role":"writer"}"#).unwrap();
        assert_eq!((m.role.as_str(), m.user.as_deref()), ("operator", Some("anna")));
        assert_eq!(invite_url("https://holger.example/", &m.secret), "https://holger.example/login?invite=s3cret");
    }
}