src/pages/admin_invites.rs
//! Admin → **Invites** pane: mint a single-use invite link, see every invite,
//! revoke an open one. The decisions are in [`crate::invites`], host-tested.
//!
//! The minted link is shown once, right here, with a copy button — the server
//! keeps only its hash, and this console has no mail relay to post it through
//! (an air-gapped appliance has none), so the admin hands it over themselves.
use leptos::prelude::*;
use leptos::task::spawn_local;
use crate::components::copy_button::CopyButton;
use crate::components::feedback::{Badge, ErrorText, Loading, Tone};
use crate::invites::{self, expires_words, InviteRow, MintRequest, Minted, Refused, ROLES};
fn now_secs() -> i64 {
(js_sys::Date::now() / 1_000.0) as i64
}
fn tone(word: &str) -> Tone {
match word {
"good" => Tone::Good,
"warn" => Tone::Warn,
_ => Tone::Neutral,
}
}
/// Unix seconds as the viewer's local date and time.
fn local(secs: i64) -> String {
let d = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(secs as f64 * 1_000.0));
format!(
"{}-{:02}-{:02} {:02}:{:02}",
d.get_full_year(),
d.get_month() + 1,
d.get_date(),
d.get_hours(),
d.get_minutes()
)
}
/// A stale touch is answered in place by [`invites`]' calls (A28 M9): the key is asked for a
/// fresh touch and the call is sent once more. What is left to show is a touch that did not
/// happen, or holger still refusing after one; pressing the button again asks again.
fn refusal_view(r: Refused) -> AnyView {
match r {
Refused::StepUp => view! { <p class="err" role="alert">{crate::step_up::NEEDS_TOUCH}</p> }.into_any(),
Refused::Touch(words) => view! { <p class="err" role="alert">{words}</p> }.into_any(),
Refused::Other(message) => view! { <ErrorText context="invites" message=message/> }.into_any(),
}
}
#[component]
pub fn InvitesPane(generation: RwSignal<u32>) -> impl IntoView {
let refresh = RwSignal::new(0u32);
let rows = LocalResource::new(move || {
let _ = (generation.get(), refresh.get());
invites::list()
});
let role = RwSignal::new(ROLES[0].0.to_owned());
let user = RwSignal::new(String::new());
let busy = RwSignal::new(false);
let minted: RwSignal<Option<Minted>> = RwSignal::new(None);
let failed: RwSignal<Option<Refused>> = RwSignal::new(None);
let mint = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
if busy.get_untracked() {
return;
}
busy.set(true);
failed.set(None);
minted.set(None);
let body = MintRequest::new(&role.get_untracked(), &user.get_untracked());
spawn_local(async move {
match invites::mint(&body).await {
Ok(m) => {
minted.set(Some(m));
user.set(String::new());
refresh.update(|n| *n += 1);
}
Err(r) => failed.set(Some(r)),
}
busy.set(false);
});
};
let revoke = move |id: String| {
failed.set(None);
spawn_local(async move {
match invites::revoke(&id).await {
Ok(()) => refresh.update(|n| *n += 1),
Err(r) => failed.set(Some(r)),
}
});
};
view! {
<div>
<div class="card">
<h3>"Invite someone"</h3>
<p class="muted">
"A single-use link that lets one person register a passkey with the role you pick. \
The link is shown once — copy it and send it to them yourself."
</p>
<form class="toolbar" on:submit=mint>
<label>
"Role "
<select
prop:value=move || role.get()
on:change=move |ev| role.set(event_target_value(&ev))
>
{ROLES.iter().map(|(token, label)| view! { <option value=*token>{*label}</option> }).collect_view()}
</select>
</label>
<input
type="text"
placeholder="username (optional — blank lets them choose)"
aria-label="Username for the invite (optional)"
prop:value=move || user.get()
on:input=move |ev| user.set(event_target_value(&ev))
/>
<button type="submit" disabled=move || busy.get()>
{move || if busy.get() { "creating…" } else { "Create link" }}
</button>
</form>
{move || failed.get().map(refusal_view)}
{move || minted.get().map(|m| {
let url = m.url.clone();
let who = m.user.clone().unwrap_or_else(|| "whoever opens it".into());
view! {
<div class="minted">
<p>
<Badge tone=Tone::Good label="link created"/>
{format!(" {} invite for {}, expires {} ({}).", m.role, who, local(m.expires), expires_words(m.expires, now_secs()))}
</p>
<p class="mono">{m.url.clone()}</p>
<CopyButton label="invite link" value=Signal::derive(move || url.clone())/>
<p class="muted">"This is the only time the link is shown."</p>
</div>
}
})}
</div>
<h3>"Invites"</h3>
{move || match rows.get() {
None => view! { <Loading what="invites"/> }.into_any(),
Some(Err(r)) => refusal_view(r),
Some(Ok(r)) if r.is_empty() => view! { <p class="count">"No invites yet."</p> }.into_any(),
Some(Ok(r)) => invites_table(r, revoke).into_any(),
}}
</div>
}
}
fn invites_table(rows: Vec<InviteRow>, revoke: impl Fn(String) + Copy + 'static) -> impl IntoView {
let now = now_secs();
view! {
<div class="table-wrap">
<table>
<thead><tr>
<th scope="col">"For"</th>
<th scope="col">"Role"</th>
<th scope="col">"Status"</th>
<th scope="col">"Created"</th>
<th scope="col">"Expires"</th>
<th scope="col">"By"</th>
<th scope="col" aria-label="Action"></th>
</tr></thead>
<tbody>
{rows.into_iter().map(|row| {
let (status, word) = row.status();
let expires = if row.open { expires_words(row.expires, now) } else { local(row.expires) };
let id = row.id.clone();
let open = row.open;
view! {
<tr>
<td>{row.for_whom()}</td>
<td>{row.role.clone()}</td>
<td><Badge tone=tone(word) label=status/></td>
<td class="mono">{local(row.created)}</td>
<td>{expires}</td>
<td>{row.created_by.clone()}</td>
<td>
{open.then(|| view! {
<button on:click=move |_| revoke(id.clone())>"Revoke"</button>
})}
</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
}
}