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

nordisk/holger-ui

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