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/mine.rs

//! The buyer's pages: **My repositories** (`/mine`) and **My tokens** (`/tokens`). Every fact
//! on them is holger's answer, turned into lines by `crate::mine`; the forms each send one call
//! and show what holger answered. A metered console has a third page, `/plan`, in
//! `pages::plan` (A28 M13).

use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos_router::components::A;

use crate::components::copy_button::CopyButton;
use crate::components::feedback::{Badge, EmptyState, Loading, Tone};
use crate::mine::{self, Granted, GrpcDoor, Made, Minted, RefusalView, RepoRow, Snippet, TokenRow, SCOPES, TABS};

/// holger's refusal: its code and its words.
#[component]
pub(crate) fn Refusal(#[prop(into)] context: String, refusal: RefusalView) -> impl IntoView {
    view! {
        <div class="failbox" role="alert">
            <p class="fail-summary"><strong>{context}</strong></p>
            <p class="fail-detail mono">
                <span class="badge badge-bad">{refusal.code}</span>
                {refusal.text}
            </p>
        </div>
    }
}

/// **The Account entry's tabs** (lane HOL-UI-FIX): the person's session, then their own
/// repositories, plan and tokens (once a menu entry "Mine" of their own).
#[component]
pub(crate) fn MineTabs() -> impl IntoView {
    view! {
        <nav class="subnav" aria-label="Account">
            {TABS.iter().map(|(href, label)| view! { <A href=*href exact=true>{*label}</A> }).collect_view()}
        </nav>
    }
}

/// One snippet: where it goes, the text, a copy button.
#[component]
fn SnippetBox(snippet: Snippet, #[prop(into)] secret: Signal<String>) -> impl IntoView {
    let raw = snippet.text.clone();
    let text = Signal::derive(move || mine::with_token(&raw, &secret.get()));
    let label = format!("{} {}", snippet.client, snippet.file);
    view! {
        <div class="snippet">
            <p class="cardline"><span class="chip">{snippet.client.clone()}</span>" "<span class="mono">{snippet.file.clone()}</span></p>
            <pre class="mono">{move || text.get()}</pre>
            <CopyButton label=label value=text/>
        </div>
    }
}

/// **Who else may use it** (lane P1): the owner's grants, and one form to grant a named person
/// read, read and write, or nothing. holger decides who may grant; the console only asks.
#[component]
fn Grants(repository: String, grants: Vec<(String, String)>) -> impl IntoView {
    let shown = RwSignal::new(grants);
    let person = RwSignal::new(String::new());
    let access = RwSignal::new("read".to_owned());
    let busy = RwSignal::new(false);
    let said: RwSignal<Option<Granted>> = RwSignal::new(None);
    let send = move |ev: leptos::ev::SubmitEvent| {
        ev.prevent_default();
        if busy.get_untracked() {
            return;
        }
        busy.set(true);
        let (repo, who, what) = (repository.clone(), person.get_untracked(), access.get_untracked());
        spawn_local(async move {
            let g = mine::grant(&GrpcDoor, &repo, &who, &what).await;
            if let Granted::Done { grants, .. } = &g {
                shown.set(grants.clone());
                person.set(String::new());
            }
            said.set(Some(g));
            busy.set(false);
        });
    };
    view! {
        <details>
            <summary>"Who else may use it"</summary>
            {move || {
                let g = shown.get();
                if g.is_empty() {
                    view! { <p class="muted">"Nobody but its owner and this server's admins."</p> }.into_any()
                } else {
                    view! { <ul>{g.into_iter().map(|(p, a)| view! { <li>{p}" — "{if a == "write" { "read and write" } else { "read" }}</li> }).collect_view()}</ul> }.into_any()
                }
            }}
            <form class="toolbar" on:submit=send>
                <input type="text" placeholder="their e-mail or id on this server" aria-label="Person"
                    prop:value=move || person.get() on:input=move |ev| person.set(event_target_value(&ev))/>
                <select aria-label="Access" prop:value=move || access.get() on:change=move |ev| access.set(event_target_value(&ev))>
                    <option value="read">"read"</option>
                    <option value="write">"read and write"</option>
                    <option value="none">"nothing (revoke)"</option>
                </select>
                <button type="submit" disabled=move || busy.get()>{move || if busy.get() { "asking…" } else { "Grant" }}</button>
            </form>
            {move || said.get().map(|g| match g {
                Granted::Done { words, .. } => view! { <p role="status"><Badge tone=Tone::Good label="done"/>" " {words}</p> }.into_any(),
                Granted::Refused(r) => view! { <Refusal context="holger refused the grant" refusal=r/> }.into_any(),
            })}
        </details>
    }
}

#[component]
fn RepoCard(row: RepoRow) -> impl IntoView {
    let tone = if row.access == "private" { Tone::Good } else { Tone::Neutral };
    let snippets = row.snippets.clone();
    let private = row.access == "private";
    let (name, grants) = (row.name.clone(), row.grants.clone());
    view! {
        <div class="card">
            <h3><a href=row.href.clone()>{row.name.clone()}</a>" " <Badge tone=tone label=row.access/>" " <span class="chip">{row.format.clone()}</span></h3>
            <p class="count">{format!("owner: {} · {}", row.owner, row.words)}</p>
            {private.then(|| view! { <Grants repository=name grants=grants/> })}
            {if snippets.is_empty() {
                view! { <EmptyState message="holger wrote no client setup for this repository"/> }.into_any()
            } else {
                view! {
                    <details>
                        <summary>"Client setup"</summary>
                        <p class="muted">"Put a token from My tokens where it says YOUR-TOKEN."</p>
                        {snippets.into_iter().map(|s| view! { <SnippetBox snippet=s secret=Signal::derive(String::new)/> }).collect_view()}
                    </details>
                }.into_any()
            }}
        </div>
    }
}

#[component]
pub fn MyRepositories() -> impl IntoView {
    crate::webio::set_title("My repositories");
    let refresh = RwSignal::new(0u32);
    let view_of = LocalResource::new(move || {
        let _ = refresh.get();
        async move { mine::load_mine(&GrpcDoor).await }
    });
    let name = RwSignal::new(String::new());
    let format = RwSignal::new(String::new());
    let busy = RwSignal::new(false);
    let made: RwSignal<Option<Made>> = RwSignal::new(None);

    let make = move |ev: leptos::ev::SubmitEvent| {
        ev.prevent_default();
        if busy.get_untracked() {
            return;
        }
        busy.set(true);
        made.set(None);
        let (n, f) = (name.get_untracked(), format.get_untracked());
        // Nothing picked yet: the first format holger offered, the one the list shows.
        let f = if f.is_empty() { view_of.get_untracked().and_then(|r| r.ok()).and_then(|v| v.formats.first().cloned()).unwrap_or_default() } else { f };
        spawn_local(async move {
            let m = mine::make(&GrpcDoor, &n, &f).await;
            if matches!(m, Made::Made { .. }) {
                name.set(String::new());
                refresh.update(|x| *x += 1);
            }
            made.set(Some(m));
            busy.set(false);
        });
    };

    view! {
        <section>
            <MineTabs/>
            <h2>"My repositories"</h2>
            {move || match view_of.get() {
                None => view! { <Loading what="your repositories"/> }.into_any(),
                Some(Err(r)) => view! { <Refusal context="holger refused your repositories" refusal=r/> }.into_any(),
                Some(Ok(v)) => {
                    let formats = v.formats.clone();
                    view! {
                        <p class="count">{v.words.clone()}</p>
                        {(!formats.is_empty()).then(|| view! {
                            <div class="card">
                                <h3>"New private repository"</h3>
                                <p class="muted">"Its reads need a credential of this server from the first request."</p>
                                <form class="toolbar" on:submit=make>
                                    <input
                                        type="text"
                                        placeholder="name (lower-case letters, digits, - _ .)"
                                        aria-label="Repository name"
                                        prop:value=move || name.get()
                                        on:input=move |ev| name.set(event_target_value(&ev))
                                    />
                                    <label>
                                        "Format "
                                        <select prop:value=move || format.get() on:change=move |ev| format.set(event_target_value(&ev))>
                                            {formats.iter().map(|f| view! { <option value=f.clone()>{f.clone()}</option> }).collect_view()}
                                        </select>
                                    </label>
                                    <button type="submit" disabled=move || busy.get()>{move || if busy.get() { "making…" } else { "Make it" }}</button>
                                </form>
                                {move || made.get().map(|m| match m {
                                    Made::Made { words, .. } => view! { <p role="status"><Badge tone=Tone::Good label="made"/>" " {words}</p> }.into_any(),
                                    Made::Refused(r) => view! { <Refusal context="holger refused the repository" refusal=r/> }.into_any(),
                                })}
                            </div>
                        })}
                        {if v.rows.is_empty() {
                            view! { <EmptyState message="holger lists no repository for you"/> }.into_any()
                        } else {
                            view! { <div class="cards">{v.rows.into_iter().map(|r| view! { <RepoCard row=r/> }).collect_view()}</div> }.into_any()
                        }}
                    }.into_any()
                }
            }}
        </section>
    }
}

#[component]
pub fn MyTokens() -> impl IntoView {
    crate::webio::set_title("My tokens");
    let refresh = RwSignal::new(0u32);
    let rows = LocalResource::new(move || {
        let _ = refresh.get();
        async move { mine::load_tokens(&GrpcDoor).await }
    });
    let label = RwSignal::new(String::new());
    let repos = RwSignal::new(String::new());
    let scope = RwSignal::new(SCOPES[0].0);
    let days = RwSignal::new(String::from("90"));
    let busy = RwSignal::new(false);
    let minted: RwSignal<Option<Minted>> = RwSignal::new(None);
    let revoke_refused: RwSignal<Option<RefusalView>> = RwSignal::new(None);

    let mint = move |ev: leptos::ev::SubmitEvent| {
        ev.prevent_default();
        if busy.get_untracked() {
            return;
        }
        busy.set(true);
        minted.set(None);
        let (l, r, s, d) = (label.get_untracked(), repos.get_untracked(), scope.get_untracked(), days.get_untracked().trim().parse::<u32>().unwrap_or(0));
        spawn_local(async move {
            let m = mine::mint(&GrpcDoor, crate::step_up::touch, &l, &r, s, d).await;
            if matches!(m, Minted::Minted { .. }) {
                label.set(String::new());
                refresh.update(|x| *x += 1);
            }
            minted.set(Some(m));
            busy.set(false);
        });
    };
    let revoke = move |id: String| {
        revoke_refused.set(None);
        spawn_local(async move {
            match mine::revoke(&GrpcDoor, &id).await {
                Ok(_) => refresh.update(|x| *x += 1),
                Err(r) => revoke_refused.set(Some(r)),
            }
        });
    };

    view! {
        <section>
            <MineTabs/>
            <h2>"My tokens"</h2>
            <p class="count">"What cargo, pip, npm and maven carry instead of your passkey. holger keeps only a token's hash: its secret is shown once, when it is made."</p>
            <div class="card">
                <h3>"New token"</h3>
                <form class="toolbar" on:submit=mint>
                    <input type="text" placeholder="what it is for (laptop cargo, ci publish)" aria-label="Token label"
                        prop:value=move || label.get() on:input=move |ev| label.set(event_target_value(&ev))/>
                    <input type="text" placeholder="repositories, comma separated" aria-label="Repositories"
                        prop:value=move || repos.get() on:input=move |ev| repos.set(event_target_value(&ev))/>
                    <label>
                        "Scope "
                        <select on:change=move |ev| scope.set(event_target_value(&ev).parse().unwrap_or(SCOPES[0].0))>
                            {SCOPES.iter().map(|(v, l)| view! { <option value=v.to_string()>{*l}</option> }).collect_view()}
                        </select>
                    </label>
                    <label>
                        "Days "
                        <input type="number" min="1" max="366" aria-label="Days until it expires"
                            prop:value=move || days.get() on:input=move |ev| days.set(event_target_value(&ev))/>
                    </label>
                    <button type="submit" disabled=move || busy.get()>{move || if busy.get() { "making…" } else { "Make token" }}</button>
                </form>
                {move || minted.get().map(|m| match m {
                    Minted::Minted { secret, words, .. } => {
                        let s = secret.clone();
                        view! {
                            <div class="minted">
                                <p role="status"><Badge tone=Tone::Good label="token made"/>" " {words}</p>
                                <p class="mono">{secret.clone()}</p>
                                <CopyButton label="token" value=Signal::derive(move || s.clone())/>
                                <p class="muted">"This is the only time the token is shown. My repositories' client setup takes it where it says YOUR-TOKEN."</p>
                            </div>
                        }.into_any()
                    }
                    Minted::Refused(r) => view! { <Refusal context="holger refused the token" refusal=r/> }.into_any(),
                })}
            </div>
            <h3>"Tokens"</h3>
            {move || revoke_refused.get().map(|r| view! { <Refusal context="holger refused the revoke" refusal=r/> })}
            {move || match rows.get() {
                None => view! { <Loading what="your tokens"/> }.into_any(),
                Some(Err(r)) => view! { <Refusal context="holger refused your tokens" refusal=r/> }.into_any(),
                Some(Ok(r)) if r.is_empty() => view! { <EmptyState message="no tokens yet"/> }.into_any(),
                Some(Ok(r)) => tokens_table(r, revoke).into_any(),
            }}
        </section>
    }
}

fn tokens_table(rows: Vec<TokenRow>, revoke: impl Fn(String) + Copy + 'static) -> impl IntoView {
    view! {
        <div class="tablewrap">
            <table>
                <thead><tr>
                    <th scope="col">"Label"</th>
                    <th scope="col">"Owner"</th>
                    <th scope="col">"Repositories"</th>
                    <th scope="col">"Scope"</th>
                    <th scope="col">"State"</th>
                    <th scope="col">"Made"</th>
                    <th scope="col">"Expires"</th>
                    <th scope="col">"Last used"</th>
                    <th scope="col" aria-label="Action"></th>
                </tr></thead>
                <tbody>
                    {rows.into_iter().map(|t| {
                        let id = t.id.clone();
                        view! {
                            <tr>
                                <td>{t.label.clone()}</td>
                                <td>{t.owner.clone()}</td>
                                <td class="wrap">{t.repositories.clone()}</td>
                                <td>{t.scope}</td>
                                <td><Badge tone=t.tone label=t.state.clone()/></td>
                                <td class="mono">{t.created.clone()}</td>
                                <td class="mono">{t.expires.clone()}</td>
                                <td class="mono">{t.last_used.clone()}</td>
                                <td>{t.revocable.then(|| view! { <button on:click=move |_| revoke(id.clone())>"Revoke"</button> })}</td>
                            </tr>
                        }
                    }).collect_view()}
                </tbody>
            </table>
        </div>
    }
}