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

nordisk/gunnar-ui

crates/gunnar-ui-web/src/enterprise.rs

//! **The owner's enterprise list, as the console draws it** (2026-10-05) —
//! the decisions worth testing, on the host side of the wasm boundary: how an
//! audit entry reads in one line, what the chain line says, how a step-up is
//! carried, and the totals' words. Nothing here decides who may see what:
//! gunnar does, and a refusal is shown verbatim.

use serde::Deserialize;

/// One audit entry, as `GET /api/account/audit` sends it.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct Entry {
    pub seq: u64,
    pub at: i64,
    pub kind: String,
    pub actor: String,
    pub role: String,
    pub target: String,
    pub detail: String,
    pub outcome: String,
    pub hash: String,
}

/// Whether the account's chain holds.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct Chain {
    pub holds: bool,
    pub entries: u64,
    pub tip: String,
    pub anchor: Option<u64>,
    pub broken_at: Option<u64>,
    pub what: String,
}

/// One page of the log.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct Page {
    pub account: String,
    pub entries: Vec<Entry>,
    pub next_before: Option<u64>,
    pub chain: Option<Chain>,
    pub retention_days: u32,
    pub written: u64,
}

/// **The chain's one line**: holds or broken, how many entries, the tip a
/// careful admin writes down.
pub fn chain_line(chain: &Chain, retention_days: u32) -> String {
    let tip: String = chain.tip.chars().take(12).collect();
    if chain.holds {
        format!(
            "Chain holds · {} entr{} · tip {tip}… · kept {} days",
            chain.entries,
            if chain.entries == 1 { "y" } else { "ies" },
            retention_days
        )
    } else {
        format!(
            "Chain BROKEN at entry {}: {}",
            chain.broken_at.map(|s| s.to_string()).unwrap_or_else(|| "?".into()),
            chain.what
        )
    }
}

/// **Who did it, as the row says it**: the actor and what they are to the
/// account (`bob (member)`), or `gunnar` for the system. The account's own
/// admin is said in full — `acme (account admin)` — so it is never read as the
/// box's operator (2026-10-07).
pub fn who(e: &Entry) -> String {
    match (e.actor.as_str(), e.role.as_str()) {
        ("", _) => "Gunnar".to_owned(),
        (actor, "") => actor.to_owned(),
        (actor, "admin") => format!("{actor} (account admin)"),
        (actor, role) => format!("{actor} ({role})"),
    }
}

/// **The words of a kind**, for the row: `repo.created` → `repo created`.
pub fn kind_words(kind: &str) -> String {
    kind.replace(['.', '_'], " ")
}

/// The export's address for the same filter.
pub fn export_href(account: &str, format: &str, query: &str) -> String {
    let mut href = format!("/api/account/audit/export?format={format}&account={}", crate::auth::percent_encode(account));
    if !query.is_empty() {
        href.push('&');
        href.push_str(query);
    }
    href
}

/// The page's filter as a query string (empty fields left out).
pub fn filter_query(kinds: &str, actor: &str, text: &str) -> String {
    let mut parts = Vec::new();
    for (k, v) in [("kinds", kinds), ("actor", actor), ("text", text)] {
        let v = v.trim();
        if !v.is_empty() {
            parts.push(format!("{k}={}", crate::auth::percent_encode(v)));
        }
    }
    parts.join("&")
}

/// One look the operator asked for, as the console sends it.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct Look {
    pub id: String,
    pub code: String,
    pub account: String,
    pub operator: String,
    pub reason: String,
    pub minutes: u32,
    pub requested: i64,
    pub request_expires: i64,
    pub state: String,
    pub until: i64,
    pub decided_by: String,
}

/// `{ "requests": [...] }`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct Looks {
    pub requests: Vec<Look>,
}

/// **The look's one line**: `Pending: op asks for 1 h — code ABC-123`.
pub fn look_line(l: &Look) -> String {
    let length = if l.minutes % 60 == 0 { format!("{} h", l.minutes / 60) } else { format!("{} min", l.minutes) };
    match l.state.as_str() {
        "pending" => format!("Pending: {} asks for {length} — code {}", l.operator, l.code),
        "approved" => format!("Approved: {} reads until {} — code {}", l.operator, crate::fmt::date(l.until), l.code),
        other => format!("{}: {} — code {}", capitalised(other), l.operator, l.code),
    }
}

fn capitalised(word: &str) -> String {
    let mut c = word.chars();
    match c.next() {
        Some(first) => first.to_uppercase().chain(c).collect(),
        None => String::new(),
    }
}

/// One name the filter stopped.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct Review {
    pub id: String,
    pub kind: String,
    pub name: String,
    pub actor: String,
    pub verdict: String,
    pub state: String,
    pub at: i64,
    pub decided_by: String,
}

/// `{ "pending": n, "reviews": [...] }`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct Reviews {
    pub pending: u32,
    pub reviews: Vec<Review>,
}

/// **The operator's totals** (`GET /api/accounts/aggregates`).
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct Totals {
    pub accounts: u64,
    pub enterprise_accounts: u64,
    pub members: u64,
    pub repos: u64,
    pub public_repos: u64,
    pub stored_bytes: u64,
    pub room_held_bytes: u64,
    pub looks_live: u64,
    pub looks_pending: u64,
    pub health: String,
    pub health_alarms: u32,
    pub operator_repos: u64,
}

/// **The totals as label/value pairs**, in the order the page draws them.
pub fn totals_rows(t: &Totals) -> Vec<(&'static str, String)> {
    vec![
        ("Accounts", crate::fmt::count(t.accounts)),
        ("With employees", crate::fmt::count(t.enterprise_accounts)),
        ("Members", crate::fmt::count(t.members)),
        ("Repositories", format!("{} ({} public)", crate::fmt::count(t.repos), crate::fmt::count(t.public_repos))),
        ("Storage used", crate::fmt::bytes(t.stored_bytes)),
        ("Room held", if t.room_held_bytes == 0 { "—".to_owned() } else { crate::fmt::bytes(t.room_held_bytes) }),
        ("Looks", format!("{} live, {} asked", t.looks_live, t.looks_pending)),
        ("Health", if t.health == "ok" { "ok".to_owned() } else { format!("{} alarm(s)", t.health_alarms) }),
    ]
}

const URL_SAFE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";

/// base64url without padding — how WebAuthn's binary fields travel as JSON.
pub fn b64url(bytes: &[u8]) -> String {
    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
    for chunk in bytes.chunks(3) {
        let n = (u32::from(chunk[0]) << 16)
            | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
            | u32::from(*chunk.get(2).unwrap_or(&0));
        for i in 0..chunk.len() + 1 {
            out.push(URL_SAFE[((n >> (18 - 6 * i)) & 63) as usize] as char);
        }
    }
    out
}

/// The inverse of [`b64url`]; `None` for anything else.
pub fn from_b64url(text: &str) -> Option<Vec<u8>> {
    let mut out = Vec::with_capacity(text.len() * 3 / 4);
    let (mut acc, mut bits) = (0u32, 0u32);
    for c in text.trim_end_matches('=').bytes() {
        let v = match c {
            b'A'..=b'Z' => c - b'A',
            b'a'..=b'z' => c - b'a' + 26,
            b'0'..=b'9' => c - b'0' + 52,
            b'-' | b'+' => 62,
            b'_' | b'/' => 63,
            _ => return None,
        };
        acc = (acc << 6) | u32::from(v);
        bits += 6;
        if bits >= 8 {
            bits -= 8;
            out.push((acc >> bits) as u8);
        }
    }
    Some(out)
}

/// What the authenticator answered, raw.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Touch {
    pub raw_id: Vec<u8>,
    pub client_data_json: Vec<u8>,
    pub authenticator_data: Vec<u8>,
    pub signature: Vec<u8>,
}

/// **An approval's body**: the request's code, the admin's length (0 = what
/// was asked) and the touch, as the login page sends an assertion. Pure.
pub fn approval_body(code: &str, minutes: u32, touch: &Touch) -> serde_json::Value {
    serde_json::json!({
        "code": code,
        "minutes": minutes,
        "id": b64url(&touch.raw_id),
        "response": {
            "clientDataJSON": b64url(&touch.client_data_json),
            "authenticatorData": b64url(&touch.authenticator_data),
            "signature": b64url(&touch.signature),
        },
    })
}

/// **`navigator.credentials.get`** with the options the console sent
/// (`GET /api/account/step-up`): the challenge and every allowed id decoded
/// from base64url, the rest as sent. The browser asks the person's key. The
/// same ceremony as the kit enrolment's approval (`machine_enrol::touch`,
/// 2026-10-05), kept here until the two lines meet.
#[cfg(target_arch = "wasm32")]
pub async fn touch(options: &serde_json::Value) -> Result<Touch, String> {
    use js_sys::{Array, Function, Object, Promise, Reflect, Uint8Array};
    use wasm_bindgen::{JsCast, JsValue};

    // Never the browser's own message: its error's NAME, said in plain
    // words (`crate::refusal::passkey_error_words`).
    fn err(e: JsValue) -> String {
        let name = Reflect::get(&e, &JsValue::from_str("name")).ok().and_then(|m| m.as_string()).unwrap_or_default();
        crate::refusal::passkey_error_words(&name).to_owned()
    }
    fn set(o: &Object, k: &str, v: &JsValue) -> Result<(), String> {
        Reflect::set(o, &JsValue::from_str(k), v).map(|_| ()).map_err(err)
    }
    fn bytes(of: &JsValue, k: &str) -> Result<Vec<u8>, String> {
        let buf = Reflect::get(of, &JsValue::from_str(k)).map_err(err)?;
        Ok(Uint8Array::new(&buf).to_vec())
    }

    let pk = options.get("publicKey").ok_or("the console sent no publicKey options")?;
    let challenge = pk
        .get("challenge")
        .and_then(|c| c.as_str())
        .and_then(from_b64url)
        .ok_or("the console sent no challenge")?;
    let public_key = Object::new();
    set(&public_key, "challenge", &Uint8Array::from(&challenge[..]).into())?;
    if let Some(rp) = pk.get("rpId").and_then(|v| v.as_str()) {
        set(&public_key, "rpId", &JsValue::from_str(rp))?;
    }
    if let Some(t) = pk.get("timeout").and_then(|v| v.as_f64()) {
        set(&public_key, "timeout", &JsValue::from_f64(t))?;
    }
    if let Some(uv) = pk.get("userVerification").and_then(|v| v.as_str()) {
        set(&public_key, "userVerification", &JsValue::from_str(uv))?;
    }
    let allow = Array::new();
    for c in pk.get("allowCredentials").and_then(|a| a.as_array()).into_iter().flatten() {
        let Some(id) = c.get("id").and_then(|i| i.as_str()).and_then(from_b64url) else { continue };
        let d = Object::new();
        set(&d, "type", &JsValue::from_str("public-key"))?;
        set(&d, "id", &Uint8Array::from(&id[..]).into())?;
        allow.push(&d);
    }
    set(&public_key, "allowCredentials", &allow.into())?;
    let request = Object::new();
    set(&request, "publicKey", &public_key.into())?;

    let navigator = web_sys::window().ok_or("no window")?.navigator();
    let credentials = Reflect::get(navigator.as_ref(), &JsValue::from_str("credentials")).map_err(err)?;
    let get: Function = Reflect::get(&credentials, &JsValue::from_str("get"))
        .map_err(err)?
        .dyn_into()
        .map_err(|_| "this browser does not support passkeys".to_owned())?;
    let promise: Promise = get.call1(&credentials, &request).map_err(err)?.dyn_into().map_err(err)?;
    let answered = wasm_bindgen_futures::JsFuture::from(promise).await.map_err(err)?;
    let response = Reflect::get(&answered, &JsValue::from_str("response")).map_err(err)?;
    Ok(Touch {
        raw_id: bytes(&answered, "rawId")?,
        client_data_json: bytes(&response, "clientDataJSON")?,
        authenticator_data: bytes(&response, "authenticatorData")?,
        signature: bytes(&response, "signature")?,
    })
}

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

    #[test]
    fn the_chain_line_says_holds_or_broken() {
        let c = Chain { holds: true, entries: 3, tip: "abcdef0123456789".into(), ..Chain::default() };
        assert_eq!(chain_line(&c, 365), "Chain holds · 3 entries · tip abcdef012345… · kept 365 days");
        let broken = Chain { holds: false, broken_at: Some(7), what: "entry 7 was changed after it was written".into(), ..c };
        assert_eq!(chain_line(&broken, 365), "Chain BROKEN at entry 7: entry 7 was changed after it was written");
    }

    #[test]
    fn a_row_names_the_actor_and_what_they_are_to_the_account() {
        let e = Entry { actor: "bob".into(), role: "member".into(), ..Entry::default() };
        assert_eq!(who(&e), "bob (member)");
        let admin = Entry { actor: "acme".into(), role: "admin".into(), ..Entry::default() };
        assert_eq!(who(&admin), "acme (account admin)", "the account's admin, said in full");
        assert_eq!(who(&Entry::default()), "Gunnar");
        assert_eq!(kind_words("operator_access.approved"), "operator access approved");
    }

    #[test]
    fn the_export_keeps_the_filter() {
        assert_eq!(filter_query("visibility, login", "", " acme/site "), "kinds=visibility%2C%20login&text=acme/site");
        assert_eq!(
            export_href("acme", "csv", "actor=bob"),
            "/api/account/audit/export?format=csv&account=acme&actor=bob"
        );
    }

    #[test]
    fn a_look_reads_in_one_line() {
        let l = Look { operator: "op".into(), code: "ABC-123".into(), minutes: 60, state: "pending".into(), ..Look::default() };
        assert_eq!(look_line(&l), "Pending: op asks for 1 h — code ABC-123");
        let ended = Look { state: "revoked".into(), ..l };
        assert_eq!(look_line(&ended), "Revoked: op — code ABC-123");
    }

    #[test]
    fn the_approval_carries_the_code_and_the_touch() {
        let t = Touch { raw_id: vec![1, 2], client_data_json: b"{}".to_vec(), authenticator_data: vec![9], signature: vec![7, 7] };
        let body = approval_body("ABC-123", 30, &t);
        assert_eq!(body["code"], "ABC-123");
        assert_eq!(body["minutes"], 30);
        assert_eq!(body["id"], "AQI");
        assert_eq!(body["response"]["clientDataJSON"], "e30");
        for n in 0..20u8 {
            let bytes: Vec<u8> = (0..n).map(|i| i.wrapping_mul(37)).collect();
            assert_eq!(from_b64url(&b64url(&bytes)).as_deref(), Some(&bytes[..]));
        }
    }

    #[test]
    fn the_totals_name_no_account() {
        let rows = totals_rows(&Totals { accounts: 2, repos: 3, public_repos: 1, health: "ok".into(), ..Totals::default() });
        assert_eq!(rows[0], ("Accounts", "2".to_owned()));
        assert_eq!(rows[3].1, "3 (1 public)");
        assert_eq!(rows.last().unwrap().1, "ok");
    }
}