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-server/src/api/account_admin.rs

//! **The account admin's own surface** (owner, 2026-10-03): the namespace's
//! two settings and the account's employees.
//!
//! Every account's namespace and emitted events are private until its admin
//! says otherwise, and only the account admin may say so; an enterprise account
//! is one whose admin has ENROLLED employees — new users created by and bound
//! to the account, never an existing one. gunnar enforces all of it on its own
//! RPCs (`Repos.SetNamespaceSettings`, `Accounts.EnrollEmployee`,
//! `Accounts.ListEmployees`), and since 2026-10-04 (thin layer) this console
//! signs them AS THE PERSON ([`Actor`]), so gunnar is the one that says who
//! the account admin is. Nothing here asks the grant table first.
//!
//! Under `/api/account`, so `role_floor` lets any signed-in session reach the
//! handlers; gunnar then admits the account admin alone. A paying customer's
//! console session is a viewer's, and the account is theirs.

use axum::extract::{Path, Query, State};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};

use crate::api::repos::Namespace;
use crate::api::{Actor, ApiError};
use crate::app::AppState;
use crate::grpc::Fail;
use crate::pb;

/// `GET /api/account/namespaces/{namespace}/settings` — the account's two
/// settings and its employee count, to its admin alone (anybody else: 403, as
/// for the PATCH). What the `/<ns>` page's admin panel draws.
///
/// **gunnar says who the account admin is**: `Access.Check{namespace, ADMIN}`
/// asked AS THE PERSON — the same rule `Repos.SetNamespaceSettings` applies —
/// and the namespace is read from the person's own `ListNamespaces`.
pub async fn read_settings(actor: Actor, Path(namespace): Path<String>) -> Result<Json<Namespace>, ApiError> {
    let (allowed, reason) =
        crate::api::may(&actor.gunnar, &namespace, "", pb::GrantLevel::Admin).await?;
    if !allowed {
        return Err(ApiError(Fail {
            code: "permissiondenied".to_owned(),
            message: format!(
                "Only the account admin of {namespace} may see or change whether its namespace \
                 and events are public: {reason}"
            ),
        }));
    }
    let mut page = String::new();
    for _ in 0..64 {
        let answer = actor
            .gunnar
            .repos()
            .list_namespaces(pb::ListNamespacesRequest {
                page_token: page.clone(),
                ..Default::default()
            })
            .await
            .map_err(ApiError::from)?
            .into_inner();
        if let Some(ns) = answer.namespaces.into_iter().find(|ns| ns.name == namespace) {
            return Ok(Json(super::repos::namespace_out(ns)));
        }
        if answer.next_page_token.is_empty() {
            break;
        }
        page = answer.next_page_token;
    }
    Err(ApiError(Fail {
        code: "notfound".to_owned(),
        message: crate::api::absent::MESSAGE.to_owned(),
    }))
}

/// The body of `PATCH /api/account/namespaces/{namespace}/settings`.
#[derive(Debug, Deserialize)]
pub struct Settings {
    pub public: Option<bool>,
    pub events_public: Option<bool>,
}

/// `PATCH /api/account/namespaces/{namespace}/settings` — **is the namespace
/// public, are its events public.** The account admin's; an unset field is
/// left as it is.
///
/// **Signed AS THE PERSON** (thin layer, owner 2026-10-04): gunnar's
/// `Repos.SetNamespaceSettings` is the account admin's (Admin over the
/// namespace, the account gate honoured) and it decides — this console asks
/// nothing of its own grant table first.
pub async fn settings(
    State(state): State<AppState>,
    actor: Actor,
    Path(namespace): Path<String>,
    Json(body): Json<Settings>,
) -> Result<Json<Namespace>, ApiError> {
    if body.public.is_none() && body.events_public.is_none() {
        return Err(ApiError(Fail {
            code: "invalidargument".to_owned(),
            message: "nothing to set: send `public`, `events_public` or both".to_owned(),
        }));
    }
    let ns = actor
        .gunnar
        .repos()
        .set_namespace_settings(pb::SetNamespaceSettingsRequest {
            name: namespace.clone(),
            public: body.public,
            events_public: body.events_public,
        })
        .await
        .map_err(ApiError::from)?
        .into_inner()
        .namespace
        .unwrap_or_default();
    state.caches.invalidate_repos().await;
    tracing::info!(
        target: crate::auth::AUDIT_TARGET,
        event = "account.settings",
        user = %actor.who,
        account = %namespace,
        public = ns.public,
        events_public = ns.events_public,
        "the account admin changed the namespace's settings"
    );
    Ok(Json(super::repos::namespace_out(ns)))
}

/// The body of `POST /api/account/employees` — the "Add users" dialog
/// (owner, 2026-10-04): the number of users (1 when unset), the room per user
/// in GiB (the server's default when unset) and, for ONE user, the name.
/// Sent as typed; the rules are the server's.
#[derive(Debug, Clone, Deserialize)]
pub struct Enroll {
    pub account: String,
    #[serde(default)]
    pub name: String,
    #[serde(default)]
    pub count: u32,
    #[serde(default)]
    pub room_gib: u64,
    /// Where the ONE named user's invite is mailed; empty: the admin hands
    /// the link over.
    #[serde(default)]
    pub email: String,
    /// Fill one of the account's bought seats with this NEW named user.
    #[serde(default)]
    pub from_seat: bool,
}

/// **"Add users" over the shared buy flow** (monetize-site-buy `relay::add_users`, A32-25):
/// the door starts monetize's `Purchase.Start{users}` under the order ticket this console mints
/// as the person ([`crate::auth::buy_site::GunnarSite`]); its `{code, message}` refusal is this
/// console's [`Fail`].
#[cfg(feature = "metered")]
async fn users_through_the_shop(state: &AppState, headers: axum::http::HeaderMap, account: &str, body: &Enroll, name: &str) -> Result<Value, ApiError> {
    let relay = crate::auth::buy_site::relay(state).ok_or_else(|| ApiError(Fail { code: "unavailable".to_owned(), message: "this console sells no room".to_owned() }))?;
    let order = monetize_site_buy::relay::UsersOrder { account: account.to_owned(), count: body.count, room_gib: body.room_gib, name: name.to_owned(), email: body.email.clone() };
    let a = monetize_site_buy::relay::add_users(&relay, &monetize_site_buy::Ask::new(headers, ""), &order).await;
    if a.status.is_success() {
        return Ok(a.body);
    }
    let word = |k: &str| a.body.get(k).and_then(Value::as_str).map(str::to_owned);
    Err(ApiError(Fail { code: word("code").unwrap_or_else(|| "unavailable".to_owned()), message: word("message").or_else(|| word("error")).unwrap_or_default() }))
}

/// `POST /api/account/employees` — **"Add users"**: one press, one server
/// call (owner, 2026-10-04).
///
/// Where this console sells room (metered, monetize behind it), the call is
/// monetize's `Purchase.Start` with `users`, through the shared buy flow's door
/// (monetize-site-buy `relay::add_users`, A32-25; this console mints the order ticket):
/// monetize asks gunnar whether the users fit in the account's unused room,
/// grants them at once if they do, and otherwise sells exactly the rest
/// through one checkout — or refuses naming what is missing. Anywhere else it
/// is gunnar's `Accounts.EnrollEmployee` with the room, which grants from
/// what the account already has or refuses naming what is missing. Either way
/// gunnar creates the user (an existing one is refused — the owner's "they
/// might have repos already") and this console renders the answer.
///
/// ONE named user gets gunnar's invite (`Access.CreateInvite`): a pending
/// passkey enrolment on exactly that principal — its link is `url`, as before;
/// the vendor's link to follow, when there is one, is `checkout_url`. Seats name nobody yet, so
/// there is nobody to invite; a seat is filled later by
/// `EnrollEmployee{from_seat}`, which an invite or an import calls.
///
/// **Signed AS THE PERSON** (thin layer, owner 2026-10-04): gunnar decides who
/// the account admin is, for the enrolment, the order ticket and the invite.
pub async fn enroll(
    State(state): State<AppState>,
    actor: Actor,
    headers: axum::http::HeaderMap,
    Json(body): Json<Enroll>,
) -> Result<Json<Value>, ApiError> {
    // The request's headers carry the session the shared door mints the order ticket for;
    // a console that sells nothing reads none.
    #[cfg(not(feature = "metered"))]
    let _ = &headers;
    let account = body.account.trim().to_owned();
    let one = body.from_seat || body.count <= 1;
    let name = if one { checked_name(&actor, &account, &body.name)? } else { String::new() };
    let origin = state.webauthn.as_ref().map(|w| w.origin().to_owned()).unwrap_or_default();
    // A seat claim buys nothing: it is gunnar's alone, wherever the console
    // sells room.
    // ★ On a SOLD (provided) Gunnar nothing is bought (one shop, owner
    // 2026-10-07): its users are enrolled at gunnar from the account's room.
    #[cfg(feature = "metered")]
    if state.buys_here() && !body.from_seat {
        let _ = &origin;
        let answer = users_through_the_shop(&state, headers, &account, &body, &name).await?;
        if answer.get("principal").is_some_and(|p| !p.is_null()) {
            state.caches.invalidate_access().await;
            forget_standing(&state, &actor);
        }
        return Ok(Json(answer));
    }
    // ONE gunnar call: the user (or the seats, or the seat's user) and, for a
    // NEW named user, their invite.
    let added = actor
        .gunnar
        .accounts()
        .enroll_employee(pb::EnrollEmployeeRequest {
            account: account.clone(),
            id: name.clone(),
            name: name.clone(),
            public_key: String::new(),
            room_bytes: if body.from_seat { 0 } else { body.room_gib.saturating_mul(1 << 30) },
            count: if body.from_seat { 0 } else { body.count },
            from_seat: body.from_seat,
            invite: one,
            ..Default::default()
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    let url = match (&added.principal, added.invite_secret.is_empty(), origin.is_empty()) {
        (Some(principal), false, false) => crate::auth::routes::invite_url(&origin, &added.invite_secret, &principal.id),
        _ => String::new(),
    };
    let mailed = mail_invite(&actor, &body.email, &url).await;
    state.caches.invalidate_access().await;
    forget_standing(&state, &actor);
    tracing::info!(
        target: crate::auth::AUDIT_TARGET,
        event = "account.users_added",
        user = %actor.who,
        account = %account,
        employee = %added.principal.as_ref().map(|p| p.id.as_str()).unwrap_or(""),
        seats = added.seats,
        from_seat = body.from_seat,
        room_bytes = added.room_bytes,
        invite = %added.invite_id,
        mail = %mailed,
        "the account admin added users"
    );
    Ok(Json(json!({
        "state": "added",
        "principal": added.principal.as_ref().map(|p| json!({ "id": p.id, "name": p.name })),
        "seats": added.seats,
        "seats_open": added.seats_open,
        "room_bytes": added.room_bytes,
        "url": url,
        "invite": added.invite_id,
        "expires": added.invite_expires_at_unix_ms / 1000,
        "mailed": mailed,
    })))
}

/// **What this console believed about the account admin is stale now**
/// (2026-10-07): an account with a member is an `admin` one at gunnar, no
/// longer an `individual` — forget the role and standing it was told, so the
/// next `/auth/status` asks gunnar again rather than for the TTL.
fn forget_standing(state: &AppState, actor: &Actor) {
    if let Some(webauthn) = state.webauthn.as_deref() {
        webauthn.forget_role(&actor.who);
    }
}

/// **The invite mail**, through the console's own mailer — the same path the
/// admin's invite takes (`auth::routes::invite_create`). Sent after the
/// invite exists and only ever ADDS to the answer: the link is on the page
/// whatever the relay says. `""` when no address was given or no link made.
async fn mail_invite(actor: &Actor, to: &str, url: &str) -> String {
    let to = to.trim();
    if to.is_empty() || url.is_empty() {
        return String::new();
    }
    let delivery = match crate::auth::mail::Mailer::from_env() {
        Err(why) => crate::auth::mail::Delivery::Failed { to: to.to_owned(), why },
        Ok(None) => crate::auth::mail::Delivery::NotConfigured,
        Ok(Some(mailer)) => mailer.send_invite(to, url, crate::role::Role::Viewer.as_token(), &actor.who).await,
    };
    delivery.sentence()
}

/// The ONE user's name, as this console's sign-in takes names — and a name
/// this console signs its own calls under is refused BEFORE gunnar is asked
/// (owner, 2026-10-04): the invite would refuse it only after the principal
/// had been made.
fn checked_name(actor: &Actor, account: &str, raw: &str) -> Result<String, ApiError> {
    let name = crate::auth::normalize_user(raw).map_err(|why| {
        ApiError(Fail {
            code: "invalidargument".to_owned(),
            message: why,
        })
    })?;
    if crate::auth::is_reserved_user(&name) {
        tracing::info!(
            target: crate::auth::AUDIT_TARGET,
            event = "account.employee_refused",
            user = %actor.who,
            account = %account,
            employee = %name,
            reason = "reserved name",
            "an employee enrolment was refused before gunnar was asked"
        );
        return Err(ApiError(Fail {
            code: "invalidargument".to_owned(),
            message: format!(
                "{name:?} is a name this console signs its own calls under, so nobody may be \
                 enrolled as it; pick another"
            ),
        }));
    }
    Ok(name)
}

/// `?account=` for `GET /api/account/employees`.
#[derive(Debug, Deserialize)]
pub struct Of {
    pub account: String,
}

/// `GET /api/account/employees?account=` — the account admin's, and gunnar
/// says so: `Accounts.ListEmployees` is asked AS THE PERSON.
pub async fn employees(actor: Actor, Query(of): Query<Of>) -> Result<Json<Value>, ApiError> {
    let employees = actor
        .gunnar
        .accounts()
        .list_employees(pb::ListEmployeesRequest {
            account: of.account.clone(),
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    Ok(Json(json!({
        "account": of.account,
        "seats": employees.seats,
        "seats_open": employees.seats_open,
        "employees": employees
            .employees
            .into_iter()
            .map(|p| json!({ "id": p.id, "name": p.name }))
            .collect::<Vec<_>>(),
    })))
}