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