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<_>>(),
})))
}