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