crates/gunnar-ui-web/src/pages/namespace.rs
//! **`/<ns>` — a namespace as the reader may see it** (owner, 2026-10-03).
//!
//! The server answers `GET /api/namespaces/{ns}/page` as the reader: a visitor
//! (the guest) or an outsider is shown a private namespace's PUBLIC
//! repositories and nothing else — not the private ones, not a count — and a
//! namespace it may not see (private, nothing public in it) answers exactly as
//! one that does not exist, which this page draws as "This page does not
//! exist" through the same gate a repository page uses ([`crate::gate`]).
use leptos::prelude::*;
use serde::Deserialize;
use crate::gate::{gate, Gate};
use crate::pages::NotHere;
use crate::ui::{FailBox, Loading};
/// `GET /api/namespaces/{ns}/page`.
#[derive(Debug, Clone, Deserialize)]
pub struct Shown {
pub name: String,
/// The namespace itself is public (its account admin said so).
pub public: bool,
pub repos: Vec<Row>,
}
/// One repository on the page.
#[derive(Debug, Clone, Deserialize)]
pub struct Row {
pub namespace: String,
pub name: String,
pub visibility: String,
#[serde(default)]
pub description: String,
}
#[component]
pub fn NamespacePage() -> impl IntoView {
let params = leptos_router::hooks::use_params_map();
let namespace = Memo::new(move |_| params.read().get("namespace").unwrap_or_default());
let page = LocalResource::new(move || {
let url = format!(
"/api/namespaces/{}/page",
crate::api::segment(&namespace.get())
);
async move { crate::api::get::<Shown>(&url).await }
});
move || match gate(&page.get()) {
Gate::Wait => view! { <Loading/> }.into_any(),
Gate::Absent => view! { <NotHere/> }.into_any(),
Gate::Show => match page.get() {
Some(Ok(shown)) => {
let empty = shown.repos.is_empty();
view! {
<section class="namespace-page">
<h2 class="mono">{shown.name.clone()}</h2>
{empty.then(|| view! {
<p class="hint">"Nothing public here yet."</p>
})}
<AccountAdminPanel namespace=shown.name.clone()/>
<ul class="repo-list">
{shown
.repos
.into_iter()
.map(|row| {
let href = format!(
"/{}/{}",
crate::api::segment(&row.namespace),
crate::api::segment(&row.name)
);
view! {
<li>
<a href=href class="mono">{row.name.clone()}</a>
{(row.visibility != "public").then(|| view! {
<span class="badge">{row.visibility.clone()}</span>
})}
{(!row.description.is_empty()).then(|| view! {
<span class="hint">{row.description.clone()}</span>
})}
</li>
}
})
.collect_view()}
</ul>
</section>
}
.into_any()
}
Some(Err(fail)) => view! { <FailBox fail=fail/> }.into_any(),
None => view! { <Loading/> }.into_any(),
},
}
}
/// `GET /api/account/namespaces/{ns}/settings` — only ever answered to the
/// account's admin.
#[derive(Debug, Clone, Deserialize)]
struct Settings {
public: bool,
events_public: bool,
employees: u32,
}
/// What "Add users" came to, as the server said it (`POST
/// /api/account/employees`): `added` with the users that exist now, or a
/// state to follow with the link the server sent.
#[derive(Debug, Clone, Default, Deserialize)]
struct Added {
#[serde(default)]
state: String,
/// The vendor's page to follow, when the server sends one.
#[serde(default)]
checkout_url: String,
#[serde(default)]
principal: Option<Named>,
#[serde(default)]
seats: u32,
#[serde(default)]
seats_open: u32,
#[serde(default)]
room_bytes: u64,
/// The new user's one-time invite link.
#[serde(default)]
url: String,
/// Whom the invite was mailed to, as the server said it.
#[serde(default)]
mailed: String,
}
/// `GET /api/account/employees?account=` — the seats, as gunnar holds them.
#[derive(Debug, Clone, Default, Deserialize)]
struct Seats {
#[serde(default)]
seats: u32,
#[serde(default)]
seats_open: u32,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct Named {
id: String,
}
/// **The dialog's starting values** (owner, 2026-10-04): one user, 10 GB of
/// room each. What is sent is what is typed; the server holds the rules.
const START_COUNT: u32 = 1;
const START_ROOM_GB: u64 = 10;
/// Where a link the server answered with is followed: the vendor's page in a
/// console that sells room. A console that sells nothing never gets one.
#[cfg(feature = "metered")]
fn follow(url: &str) -> bool {
crate::pages::subscription::navigate_to(url)
}
#[cfg(not(feature = "metered"))]
fn follow(_url: &str) -> bool {
false
}
/// ★ **The account admin's panel** (owner, 2026-10-03): the namespace's two
/// settings — both private until set — and enrolling NEW employees. Drawn only
/// when the server answers the settings read, which it does for the account's
/// admin alone; for anybody else this is nothing at all.
#[component]
fn AccountAdminPanel(namespace: String) -> impl IntoView {
let ns = StoredValue::new(namespace);
let settings = LocalResource::new(move || {
let url = format!(
"/api/account/namespaces/{}/settings",
crate::api::segment(&ns.get_value())
);
async move { crate::api::get::<Settings>(&url).await }
});
let failed = RwSignal::new(None::<crate::api::Fail>);
let added = RwSignal::new(None::<Added>);
let count = RwSignal::new(START_COUNT.to_string());
let room = RwSignal::new(START_ROOM_GB.to_string());
let employee = RwSignal::new(String::new());
let email = RwSignal::new(String::new());
let seat_name = RwSignal::new(String::new());
let seat_email = RwSignal::new(String::new());
let busy = RwSignal::new(false);
let seats = LocalResource::new(move || {
let url = format!("/api/account/employees?account={}", crate::auth::percent_encode(&ns.get_value()));
async move { crate::api::get::<Seats>(&url).await }
});
// The name is asked only for ONE user (owner): seats are named later.
let one = move || count.get().trim().parse::<u32>().map_or(true, |n| n <= 1);
let set = move |public: Option<bool>, events_public: Option<bool>| {
let url = format!(
"/api/account/namespaces/{}/settings",
crate::api::segment(&ns.get_value())
);
leptos::task::spawn_local(async move {
let body = serde_json::json!({ "public": public, "events_public": events_public });
match crate::api::patch::<_, serde_json::Value>(&url, &body).await {
Ok(_) => {
failed.set(None);
settings.refetch();
}
Err(fail) => failed.set(Some(fail)),
}
});
};
let add_users = move |_| {
let users = count.get_untracked().trim().parse::<u32>().unwrap_or(0);
let room_gib = room.get_untracked().trim().parse::<u64>().unwrap_or(0);
let name = if users <= 1 { employee.get_untracked().trim().to_owned() } else { String::new() };
let mail = if users <= 1 { email.get_untracked().trim().to_owned() } else { String::new() };
let body = serde_json::json!({ "account": ns.get_value(), "count": users, "room_gib": room_gib, "name": name, "email": mail });
busy.set(true);
leptos::task::spawn_local(async move {
match crate::api::post::<_, Added>("/api/account/employees", &body).await {
Ok(done) => {
failed.set(None);
// A link to follow (the vendor's page) is followed at once.
if !done.checkout_url.is_empty() && follow(&done.checkout_url) {
return;
}
employee.set(String::new());
email.set(String::new());
added.set(Some(done));
settings.refetch();
seats.refetch();
}
Err(fail) => failed.set(Some(fail)),
}
busy.set(false);
});
};
// A bought seat, filled with a NEW named user: one call, gunnar's.
let fill_seat = move |_| {
let body = serde_json::json!({
"account": ns.get_value(),
"from_seat": true,
"name": seat_name.get_untracked().trim(),
"email": seat_email.get_untracked().trim(),
});
busy.set(true);
leptos::task::spawn_local(async move {
match crate::api::post::<_, Added>("/api/account/employees", &body).await {
Ok(done) => {
failed.set(None);
seat_name.set(String::new());
seat_email.set(String::new());
added.set(Some(done));
settings.refetch();
seats.refetch();
}
Err(fail) => failed.set(Some(fail)),
}
busy.set(false);
});
};
move || {
let Some(Ok(now)) = settings.get() else {
return ().into_any();
};
// Plain words (owner order 2026-10-18): who can see it, not
// "Namespace: public" / "Events: public".
let word = |public: bool| if public { "everyone" } else { "only people you let in" };
view! {
<div class="panel account-admin">
<h3>"Your account"</h3>
<p>
"This account's page is visible to: " <b id="ns-public">{word(now.public)}</b>
" " <button class="btn" id="ns-public-toggle"
on:click=move |_| set(Some(!now.public), None)>
{if now.public { "Make private" } else { "Make public" }}
</button>
</p>
<p>
"Its activity is visible to: " <b id="ns-events">{word(now.events_public)}</b>
" " <button class="btn" id="ns-events-toggle"
on:click=move |_| set(None, Some(!now.events_public))>
{if now.events_public { "Make private" } else { "Make public" }}
</button>
</p>
<p class="hint">
{if now.employees > 0 {
format!("{} people use this account. Only you can make something public.", now.employees)
} else {
"You can add people to this account; each gets their own sign-in. Only you can make something public.".to_owned()
}}
</p>
<p>
<a id="ns-identity-provider" href=format!("/admin/identity-provider?account={}", crate::auth::percent_encode(&ns.get_value()))>
"Identity provider (single sign-on, SCIM) →"</a>
</p>
<div class="add-users" id="add-users">
<h4>"Add users"</h4>
<div class="form-row">
<label class="field">"Number of users"
<input id="users-count" type="number" min="1" max="10000" step="1"
prop:value=move || count.get()
on:input=move |ev| count.set(event_target_value(&ev))/>
</label>
<label class="field">"Room per user (GB)"
<input id="users-room" type="number" min="10" step="1"
prop:value=move || room.get()
on:input=move |ev| room.set(event_target_value(&ev))/>
</label>
{move || one().then(|| view! {
<label class="field">"Name"
<input id="users-name" placeholder="the new user's name"
prop:value=move || employee.get()
on:input=move |ev| employee.set(event_target_value(&ev))/>
</label>
<label class="field">"Email (optional)"
<input id="users-email" type="email" placeholder="where their invite is sent"
prop:value=move || email.get()
on:input=move |ev| email.set(event_target_value(&ev))/>
</label>
})}
</div>
<p>
<button class="btn primary" id="users-add" on:click=add_users
disabled=move || busy.get()>"Add users"</button>
</p>
{move || added.get().map(|done| {
let gib = done.room_bytes >> 30;
let said = match &done.principal {
Some(p) => format!("{} is added, with {gib} GB of room.", p.id),
None if done.seats > 0 => format!("Room for {} people, {} not taken yet, with {gib} GB each.", done.seats, done.seats_open),
None => format!("Not added yet: {}.", done.state),
};
view! {
<p class="hint" id="users-added" data-state=done.state.clone()>{said}</p>
{(!done.url.is_empty()).then(|| view! {
<p class="hint">"Send this one-time link to the new user: "
<span class="mono" id="employee-invite">{done.url.clone()}</span></p>
})}
{(!done.mailed.is_empty()).then(|| view! {
<p class="hint" id="employee-invite-mailed">{done.mailed.clone()}</p>
})}
}
})}
{move || match seats.get() {
Some(Ok(held)) if held.seats_open > 0 => view! {
<div class="fill-seat" id="fill-seat">
<p>{format!("{} of {} places are not taken yet.", held.seats_open, held.seats)}</p>
<div class="form-row">
<label class="field">"Name"
<input id="seat-name" placeholder="the new user's name"
prop:value=move || seat_name.get()
on:input=move |ev| seat_name.set(event_target_value(&ev))/>
</label>
<label class="field">"Email (optional)"
<input id="seat-email" type="email" placeholder="where their invite is sent"
prop:value=move || seat_email.get()
on:input=move |ev| seat_email.set(event_target_value(&ev))/>
</label>
</div>
<p><button class="btn" id="seat-fill" on:click=fill_seat
disabled=move || busy.get()>"Invite someone"</button></p>
</div>
}.into_any(),
_ => ().into_any(),
}}
</div>
{move || failed.get().map(|fail| view! { <FailBox fail=fail/> })}
</div>
}
.into_any()
}
}