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/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()
    }
}