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/pages/invite.rs

//! **The invite page** (owner 2026-11-03): an invited operator registers their own
//! passkey. The one-time secret rides in the address's fragment (`/invite#<secret>`),
//! which no server or proxy logs; the page hands it to the console's server, and the
//! server to edda's identity core, which spends it once. There is no sign-up: this page
//! does nothing without a link an operator minted.

use leptos::prelude::*;
use serde_json::{json, Value};

use crate::api::Fail;
use crate::ui::{FailBox, PageHeader, Panel};

fn host() -> String {
    web_sys::window()
        .and_then(|w| w.location().hostname().ok())
        .unwrap_or_default()
}

/// The secret in the address's fragment, if any.
fn secret() -> String {
    let hash = web_sys::window()
        .and_then(|w| w.location().hash().ok())
        .unwrap_or_default();
    crate::fmt::invite_secret(&hash)
}

#[component]
pub fn Invite() -> impl IntoView {
    let busy = RwSignal::new(false);
    let failed = RwSignal::new(None::<Fail>);
    let label = RwSignal::new(String::new());
    let register = move |_| {
        busy.set(true);
        failed.set(None);
        let l = label.get_untracked().trim().to_owned();
        leptos::task::spawn_local(async move {
            let r: Result<(), Fail> = async {
                let options = crate::api::post::<_, Value>(
                    crate::fmt::INVITE_START,
                    &json!({ "secret": secret(), "label": l }),
                )
                .await?;
                // The browser's own refusal: the server was never asked, so no HTTP status.
                let cred = crate::passkey::create(&options)
                    .await
                    .map_err(|e| Fail::passkey_not_created(&host(), &e))?;
                crate::api::post::<_, Value>(
                    "/auth/passkey/register/finish",
                    &crate::fmt::passkey_register_body(&options, &cred, &l),
                )
                .await?;
                Ok(())
            }
            .await;
            match r {
                Ok(()) => {
                    if let Some(w) = web_sys::window() {
                        let _ = w.location().set_href("/");
                    }
                }
                Err(f) => failed.set(Some(f)),
            }
            busy.set(false);
        });
    };
    view! {
        <div class="login stack">
            <PageHeader title="Join this console" subtitle="An operator invited you."/>
            {if secret().is_empty() {
                view! { <Panel title="No invite"><div class="panel-body hint">"This page needs the link an operator gave you."</div></Panel> }.into_any()
            } else {
                view! {
                    <Panel title="Your passkey">
                        <div class="panel-body stack">
                            <p class="hint">"Register a passkey on this device (a security key, a phone or this computer). It signs you in from now on. The link works once."</p>
                            <div class="row" style="align-items:flex-end">
                                <div class="field"><label for="label">"Device label (optional)"</label><input id="label" type="text" placeholder="yubikey" prop:value=move || label.get() on:input=move |ev| label.set(event_target_value(&ev))/></div>
                                <button class="btn primary" id="invite-register" disabled=move || busy.get() on:click=register>"Register my passkey"</button>
                            </div>
                        </div>
                    </Panel>
                }.into_any()
            }}
            {move || failed.get().map(|f| view! { <FailBox fail=f/> })}
        </div>
    }
}

/// **Operators** (owner 2026-11-03), on the signed-in operator's Passkeys page: invite a
/// person by name (a one-time link, shown once, that registers their own passkey), and
/// the open invites, each withdrawn with one press. Every operator may do this: there are
/// no roles here.
#[component]
pub fn OperatorInvites() -> impl IntoView {
    let name = RwSignal::new(String::new());
    let hours = RwSignal::new(String::from("24"));
    let made = RwSignal::new(None::<Value>);
    let failed = RwSignal::new(None::<Fail>);
    let busy = RwSignal::new(false);
    let reread = RwSignal::new(0u32);
    let open = LocalResource::new(move || {
        reread.get();
        async move { crate::api::get::<Value>(crate::fmt::INVITES).await }
    });
    let invite = move |_| {
        busy.set(true);
        failed.set(None);
        made.set(None);
        let body = json!({ "name": name.get_untracked().trim(), "hours": hours.get_untracked().parse::<i64>().ok() });
        leptos::task::spawn_local(async move {
            match crate::api::post::<_, Value>(crate::fmt::INVITES, &body).await {
                Ok(v) => {
                    made.set(Some(v));
                    name.set(String::new());
                    reread.update(|n| *n = n.wrapping_add(1));
                }
                Err(f) => failed.set(Some(f)),
            }
            busy.set(false);
        });
    };
    let withdraw = move |id: String| {
        failed.set(None);
        leptos::task::spawn_local(async move {
            match crate::api::post::<_, Value>(crate::fmt::INVITES_WITHDRAW, &json!({ "id": id }))
                .await
            {
                Ok(_) => reread.update(|n| *n = n.wrapping_add(1)),
                Err(f) => failed.set(Some(f)),
            }
        });
    };
    view! {
        <Panel title="Operators">
            <div class="panel-body stack">
                <p class="hint">"Invite a person to operate this console. They get a one-time link that registers their own passkey. Every operator can do everything here."</p>
                <div class="row" style="align-items:flex-end">
                    <div class="field"><label for="invite-name">"Name"</label><input id="invite-name" type="text" placeholder="Henrik" prop:value=move || name.get() on:input=move |ev| name.set(event_target_value(&ev))/></div>
                    <div class="field"><label for="invite-hours">"The link works for"</label>
                        <select id="invite-hours" on:change=move |ev| hours.set(event_target_value(&ev))>
                            <option value="1">"1 hour"</option>
                            <option value="24" selected=true>"1 day"</option>
                            <option value="168">"7 days"</option>
                        </select>
                    </div>
                    <button class="btn" id="invite-operator" disabled=move || busy.get() || name.get().trim().is_empty() on:click=invite>"Invite an operator"</button>
                </div>
                {move || made.get().map(|v| view! {
                    <div class="notice" id="invite-made">
                        <p>{crate::fmt::invite_made_line(&v)}</p>
                        <p class="mono">{v["link"].as_str().unwrap_or_default().to_owned()}</p>
                    </div>
                })}
                <h3>"Open invites"</h3>
                {move || {
                    let read = open.get().and_then(|r| r.ok()).unwrap_or_default();
                    let rows: Vec<(String, String)> = read["invites"].as_array().map(|a| a.iter().map(|i| (i["id"].as_str().unwrap_or_default().to_owned(), crate::fmt::open_invite_line(i))).collect()).unwrap_or_default();
                    if rows.is_empty() {
                        view! { <p class="hint">"none"</p> }.into_any()
                    } else {
                        view! {
                            <ul class="mono">
                                {rows.into_iter().map(|(id, line)| view! {
                                    <li>{line}" "<button class="btn quiet" on:click=move |_| withdraw(id.clone())>"Revoke"</button></li>
                                }).collect_view()}
                            </ul>
                        }.into_any()
                    }
                }}
            </div>
        </Panel>
        {move || failed.get().map(|f| view! { <FailBox fail=f/> })}
    }
}