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");
}
}