Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

crates/monetize-ui-web/src/app.rs

//! The console's shell: the top bar (estate picker · Overview · You), the
//! session, and the routes. Since 2026-10-02 the ESTATE is the top level:
//! `/` is the Overview of every estate, `/e/<estate>/…` is one estate.

use leptos::prelude::*;
use leptos_router::components::{ParentRoute, Route, Router, Routes, A};
use leptos_router::path;
use serde::Deserialize;

use crate::pages;
use crate::ui::{Badge, Tone};

/// `/auth/status`, shared by every page through context.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Status {
    pub authenticated: bool,
    pub user: String,
    pub passkey_enabled: bool,
    /// True when nothing guards the console at all.
    pub open: bool,
    /// True while the break-glass token can still enrol the first admin.
    pub bootstrap: bool,
    /// True when the signed-in principal already holds an enabled passkey on this console:
    /// the passkeys page then offers no enrol form until "Add another key" is pressed.
    #[serde(default)]
    pub has_passkey: bool,
}

impl Status {
    /// There are no roles (owner 2026-11-03): a signed-in operator may do everything
    /// the console does (an open loopback console too).
    pub fn is_operator(&self) -> bool {
        self.open || self.authenticated
    }
}

#[derive(Clone, Copy)]
pub struct SessionContext(pub RwSignal<Option<Status>>);

/// `None` until `/auth/status` has answered. Pages must not guess in the meantime.
pub fn session() -> RwSignal<Option<Status>> {
    use_context::<SessionContext>()
        .expect("the shell provides the session")
        .0
}

/// To the login page, carrying why — `fmt::login_prompt` turns the word into
/// the line and the red bulb the page shows.
pub fn go_to_login_because(why: &str) {
    if let Some(w) = web_sys::window() {
        if w.location().pathname().unwrap_or_default() != "/login" {
            let _ = w.location().set_href(&format!("/login?why={why}"));
        }
    }
}

pub fn go_to_login() {
    if let Some(w) = web_sys::window() {
        if w.location().pathname().unwrap_or_default() != "/login" {
            let _ = w.location().set_href("/login");
        }
    }
}

#[component]
pub fn App() -> impl IntoView {
    let status = RwSignal::new(None::<Status>);
    provide_context(SessionContext(status));
    Effect::new(move |_| {
        leptos::task::spawn_local(async move {
            if let Ok(fresh) = crate::api::get::<Status>("/auth/status").await {
                if crate::fmt::leaves_for_login(
                    fresh.authenticated,
                    fresh.open,
                    fresh.passkey_enabled,
                ) {
                    go_to_login();
                }
                status.set(Some(fresh));
            }
        });
    });

    view! {
        <Router>
            <div class="app">
                // THE TOP BAR (owner 2026-10-02): the estate picker, Overview,
                // and You. The ESTATE is the top level; a product is only a
                // heading inside the picker.
                <header class="topbar">
                    <a class="brand" href="/" title="monetize — the operator console">
                        <img src="/monetize.webp" alt="" class="brand-mark"/>
                        <span>"monetize"</span>
                    </a>
                    <EstatePicker/>
                    <A href="/" attr:class="nav-link top" exact=true>"Overview"</A>
                    <A href="/accounts" attr:class="nav-link top">"Accounts"</A>
                    <A href="/guardian" attr:class="nav-link top">"Guardian"</A>
                    <A href="/test-buy" attr:class="nav-link top">"Test buy"</A>
                    <A href="/sites" attr:class="nav-link top">"Sites"</A>
                    <A href="/iron" attr:class="nav-link top">"Iron"</A>
                    <span class="spacer"></span>
                    <You/>
                </header>
                <CodeRedBanner/>
                <Routes fallback=|| view! { <main class="page" id="main"><div class="empty"><h3>"Page not found"</h3><p>"This address leads nowhere in this console. "<a href="/">"Overview"</a>" lists every estate."</p></div></main> }>
                    <Route path=path!("/") view=pages::overview::Overview/>
                    <Route path=path!("/accounts") view=pages::accounts::Accounts/>
                    <Route path=path!("/account") view=pages::accounts::AccountDetail/>
                    <Route path=path!("/guardian") view=pages::guardian::Guardian/>
                    <Route path=path!("/guardian/estate") view=pages::guardian::GuardianEstate/>
                    <Route path=path!("/test-buy") view=pages::test_buy::TestBuy/>
                    <Route path=path!("/sites") view=pages::sites::Sites/>
                    <Route path=path!("/iron") view=pages::iron::Iron/>
                    // The old Customers page is replaced by Accounts (5.4).
                    <Route path=path!("/customers") view=pages::accounts::Accounts/>
                    <Route path=path!("/login") view=|| view! { <main class="page" id="main"><pages::login::Login/></main> }/>
                    <Route path=path!("/invite") view=|| view! { <main class="page" id="main"><pages::invite::Invite/></main> }/>
                    <ParentRoute path=path!("/e/:estate") view=pages::shell::EstateShell>
                        <Route path=path!("") view=pages::shell::MapPage/>
                        <Route path=path!("map") view=pages::shell::MapPage/>
                        <Route path=path!("customers") view=pages::shell::CustomersPage/>
                        <Route path=path!("customer") view=pages::tenant::TenantDetail/>
                        <Route path=path!("money") view=pages::ledger::LedgerPage/>
                        <Route path=path!("tax") view=pages::tax::TaxPage/>
                        <Route path=path!("offer") view=pages::shell::OfferPage/>
                        <Route path=path!("events") view=pages::ledger::EventsPage/>
                        <Route path=path!("settings") view=pages::settings::SettingsPage/>
                    </ParentRoute>
                </Routes>
            </div>
        </Router>
    }
}

#[derive(Debug, Clone, Deserialize)]
pub struct Health {
    pub serving: bool,
    #[serde(default)]
    pub products: Vec<String>,
    #[serde(default)]
    pub error: String,
    /// Paid-at-vendor cases a person still has to act on. `None` when
    /// monetize could not be asked — which the badge must not read as zero.
    #[serde(default)]
    pub paid_at_vendor_open: Option<u64>,
}

/// **The estate picker** — `[ gunnar.rs ▾ ]`, every estate this console
/// administers grouped under its product (a heading, nothing more). Picking
/// one opens the SAME section of that estate (Money of gunnar.rs → Money of
/// test2.gunnar.rs), with a full load so every page and the live feed speak
/// to the estate just picked.
#[component]
fn EstatePicker() -> impl IntoView {
    let location = leptos_router::hooks::use_location();
    let here = Memo::new(move |_| crate::estate::estate_in(&location.pathname.get()));
    let estates = LocalResource::new(|| async {
        crate::api::get::<crate::model::EstatesJson>("/api/estates").await
    });
    view! {
        <label class="estate-picker">
            <span class="vh">"Estate"</span>
            <select aria-label="Estate" on:change=move |ev| {
                let name = event_target_value(&ev);
                if name.is_empty() {
                    return;
                }
                let section = here.get_untracked().map(|(_, s)| s).unwrap_or_else(|| "map".to_owned());
                // A customer of one estate is not a customer of another.
                let section = if section == "customer" { "customers".to_owned() } else { section };
                if let Some(w) = web_sys::window() {
                    let _ = w.location().set_href(&crate::estate::estate_href(&name, &section));
                }
            }>
                {move || {
                    let current = here.get().map(|(e, _)| e).unwrap_or_default();
                    let groups = match estates.get() {
                        Some(Ok(all)) => crate::estate::picker_groups(&all.estates),
                        _ => Vec::new(),
                    };
                    let known = groups.iter().any(|g| g.estates.contains(&current));
                    view! {
                        <option value="" selected=current.is_empty() disabled=true>{if groups.is_empty() { "estates …" } else { "Pick an estate" }}</option>
                        // The estate in the address, before the list has answered.
                        {(!current.is_empty() && !known).then(|| view! { <option value=current.clone() selected=true>{current.clone()}</option> })}
                        {groups.into_iter().map(|g| {
                            let current = current.clone();
                            view! {
                                <optgroup label=g.heading.clone()>
                                    {g.estates.into_iter().map(|n| {
                                        let selected = n == current;
                                        view! { <option value=n.clone() selected=selected>{n.clone()}</option> }
                                    }).collect_view()}
                                </optgroup>
                            }
                        }).collect_view()}
                    }
                }}
            </select>
        </label>
    }
}

/// **You ▾** — who is logged in, the passkeys, and the way out.
#[component]
fn You() -> impl IntoView {
    let status = session();
    view! {
        <details class="you">
            <summary class="nav-link top">"You ▾"</summary>
            <div class="you-menu">
                {move || match status.get() {
                    None => view! { <span class="badge plain">"…"</span> }.into_any(),
                    Some(s) if s.open => view! { <Badge tone=Tone::Warn label="unlocked console"/> }.into_any(),
                    Some(s) if s.authenticated => view! { <span class="badge plain">{s.user.clone()}</span> }.into_any(),
                    Some(_) => view! { <span class="hint">"not logged in"</span> }.into_any(),
                }}
                <a class="nav-link" href="/login">"Passkeys"</a>
                {move || match status.get() {
                    Some(s) if s.authenticated && !s.open => view! {
                        <button class="btn quiet" on:click=move |_| {
                            leptos::task::spawn_local(async move {
                                let _ = crate::api::post::<_, serde_json::Value>("/auth/logout", &serde_json::json!({})).await;
                                go_to_login();
                            });
                        }>"Log out"</button>
                    }.into_any(),
                    Some(s) if !s.open => view! { <a class="btn quiet" href="/login">"Log in"</a> }.into_any(),
                    _ => ().into_any(),
                }}
            </div>
        </details>
    }
}

/// **CODE RED, on every page** (owner 2026-10-18): what an automatic path left down — a red
/// blinking banner under the top bar, read every [`crate::code_red::REREAD_MS`] while signed in;
/// an admin's Ack takes one off (monetize keeps it until its path succeeds).
#[component]
fn CodeRedBanner() -> impl IntoView {
    let reread = RwSignal::new(0u32);
    let reds = RwSignal::new(crate::code_red::CodeRedsJson::default());
    let failed = RwSignal::new(None::<String>);
    Effect::new(move |_| {
        reread.get();
        let signed_in = session().get().is_some_and(|s| s.authenticated || s.open);
        if signed_in {
            leptos::task::spawn_local(async move {
                if let Ok(r) =
                    crate::api::get::<crate::code_red::CodeRedsJson>(crate::code_red::CODE_RED_API)
                        .await
                {
                    reds.set(r);
                }
            });
        }
        set_timeout(
            move || reread.update(|n| *n = n.wrapping_add(1)),
            std::time::Duration::from_millis(u64::from(crate::code_red::REREAD_MS)),
        );
    });
    let admin = move || session().get().map(|s| s.is_operator()).unwrap_or(false);
    view! {
        {move || {
            let lines = crate::code_red::banner_lines(&reds.get());
            (!lines.is_empty()).then(|| view! {
                <div class="code-red" role="alert" aria-live="assertive">
                    {lines.into_iter().map(|(key, line, next)| {
                        let ack = move |_| {
                            let key = key.clone();
                            leptos::task::spawn_local(async move {
                                match crate::api::post::<_, crate::code_red::CodeRedsJson>(&crate::code_red::ack_path(&key), &serde_json::json!({})).await {
                                    Ok(r) => reds.set(r),
                                    Err(f) => failed.set(Some(f.message)),
                                }
                            });
                        };
                        view! {
                            <div class="code-red-line">
                                <strong class="code-red-what">{line}</strong>
                                {(!next.is_empty()).then(|| view! { <span class="code-red-next">{format!("Next: {next}")}</span> })}
                                {admin().then(|| view! { <button class="btn code-red-ack" on:click=ack>"Ack"</button> })}
                            </div>
                        }
                    }).collect_view()}
                    {move || failed.get().map(|f| view! { <div class="code-red-failed">{f}</div> })}
                </div>
            })
        }}
    }
}