crates/gunnar-ui-web/src/pages/account.rs
//! **The account holder's own page**, and nobody else's.
//!
//! What is here is a person's own: their plan and disk (in a build that sells),
//! their own SSH keys, and the session they are sitting in. The box's passkeys,
//! its invites and the switch that signs everyone out are an administrator's,
//! and as of 2026-09-18 they are on `/admin` — see [`PasskeyAdmin`].
//!
//! That split is the point. An admin is also a customer: they buy room on the
//! same page a viewer does, and they should not have to read past six operator
//! instruments to reach it.
//!
//! Registering a key is not possible from here. The ceremony has to run on the
//! server's own origin, which is `/login` — so `/admin` manages what exists and
//! points at the page that can add more.
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use crate::api::Page;
use crate::app::session;
use crate::fmt;
/// **The subscription pane, present only in a build that sells.**
///
/// The `#[cfg]` lives on a function and not on the node inside `view!`, and
/// that is not a style choice: MEASURED 2026-09-17, a `#[cfg(feature =
/// "metered")]` attribute on a component node inside the macro is not honoured
/// — the reference survives the expansion and a community build fails with
/// `cannot find \`subscription\` in \`pages\``. The open build is a shipped
/// artifact, so that is a broken build and not a warning.
///
/// A function is ordinary Rust, where `cfg` means what it says. The money
/// vocabulary stays inside `pages::subscription`, which is what keeps this file
/// free of it and therefore scanned.
#[cfg(feature = "metered")]
fn subscription_pane() -> AnyView {
// What this account bought here first, then its plan (one shop, 2026-10-07).
view! { <crate::pages::subscription::AccountPane/> }.into_any()
}
/// Nothing at all — a community appliance does not contain the pane, so there
/// is no panel here to hide and no sentence to explain its absence.
#[cfg(not(feature = "metered"))]
fn subscription_pane() -> AnyView {
view! { <crate::pages::my_storage::OwnSpace/> }.into_any()
}
use crate::console_key::{self, ConsoleKey, Shown};
use crate::pages::access::Key;
use crate::pages::{now_ms, resolved};
use crate::ui::{Badge, Empty, FailBox, Hint, PageHeader, Panel, Tone, Unset};
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Credentials {
pub credentials: Vec<Credential>,
pub enabled_admins: usize,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Credential {
pub id: String,
pub label: String,
pub user: String,
pub role: String,
pub created: i64,
pub last_used: Option<i64>,
pub disabled: bool,
}
#[derive(Debug, Serialize)]
struct ById {
id: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Invites {
pub invites: Vec<Invite>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Invite {
pub id: String,
pub role: String,
pub user: Option<String>,
pub created: i64,
pub expires: i64,
pub created_by: String,
pub used_by: Option<String>,
pub revoked: bool,
pub open: bool,
}
#[derive(Debug, Serialize)]
struct NewInvite {
role: String,
user: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
struct MintedInvite {
url: String,
}
/// **The console's own key on this person's principal**, and the one panel on
/// this page that appears only when something is wrong.
///
/// # Why it is first, and why it is usually not there at all
///
/// The console reads gunnar AS the person sitting in front of it, with a key it
/// enrols on their principal ([`crate::console_key`] has the mechanism). gunnar
/// accepts a second key on a principal only when the principal vouches for it,
/// so everybody who registered an SSH key before their first console read is
/// refused — their reads fall back to the grantless guest and the panels below
/// come back empty or 403. That is why this sits ABOVE [`OwnKeys`]: a broken
/// credential is the reason the rest of the page has nothing on it, and an
/// explanation underneath the emptiness it explains is an explanation nobody
/// reaches.
///
/// It is also why an enrolled credential renders NOTHING — not a green tick,
/// not a fingerprint. That is the ordinary case on every visit by everybody,
/// and a permanent "all good" panel on the page's first line is a panel a
/// reader stops seeing by the second week. The one time it does appear it has
/// to be the only new thing on the page.
///
/// **Not behind `metered`.** What it repairs is a READ falling back to the
/// guest, which happens on a community appliance exactly as it does on a
/// hosted one.
/// The "This console needs your word" (vouch for the console's key) panel:
/// hidden since 2026-10-18 (owner, wide audience).
const SHOW_VOUCH_PANEL: bool = false;
#[component]
fn ConsoleCredential() -> impl IntoView {
let reload = RwSignal::new(0u32);
let credential = LocalResource::new(move || {
reload.track();
async { crate::api::get::<ConsoleKey>("/api/account/console-key").await }
});
let pasted = RwSignal::new(String::new());
let busy = RwSignal::new(false);
let fail = RwSignal::new(None::<crate::api::Fail>);
let send = move |_| {
if busy.get_untracked() {
return;
}
let attestation = pasted.get_untracked().trim().to_owned();
if attestation.is_empty() {
return;
}
busy.set(true);
fail.set(None);
leptos::task::spawn_local(async move {
// The CLI's output as TEXT, whole. The server parses it and checks
// that it names the key actually waiting; a form that asked for the
// five fields separately would be a form somebody fills in wrong at
// 2am.
let body = serde_json::json!({ "attestation": attestation });
match crate::api::post::<_, serde_json::Value>(
"/api/account/console-key/attestation",
&body,
)
.await
{
Ok(_) => {
pasted.set(String::new());
// Re-read rather than trust the answer: the re-read is what
// makes this panel vanish, and it vanishing is the only
// confirmation worth giving.
reload.update(|n| *n += 1);
// And the shell's queue, which was carrying a line about
// exactly this. Without it the banner outlives the problem
// by up to five minutes — measured 2026-09-18, on the first
// person who ever used this panel.
crate::app::nudge_actions();
}
Err(refusal) => fail.set(Some(refusal)),
}
busy.set(false);
});
};
view! {
{move || {
let Some(answer) = credential.get() else {
// Unresolved. Nothing — the console waits one round trip rather
// than guessing (see `crate::app::session`), and guessing wrong
// here means flashing "your console cannot read as you" at
// somebody whose console is perfectly fine.
return ().into_any();
};
// A refusal to the read ITSELF is not this panel's news. The route
// refuses exactly the callers it is meaningless for — no session,
// and the break-glass token, which is not a person — and `OwnKeys`
// below already declines to print that same refusal twice.
let Ok(key) = answer else {
return ().into_any();
};
match console_key::shown(&key) {
Shown::Nothing => ().into_any(),
Shown::Refused { refusal } => view! {
<Panel title="Gunnar cannot show you your own repositories right now">
<div class="panel-body">
<Hint
line="You are seeing only what is public. Gunnar's reason:"
more="Until this is fixed, every page here shows only what is public. \
Whoever runs this Gunnar can act on the reason below."
/>
<div class="failure" role="alert">
<h3>"Gunnar did not accept this site's sign-in for you"</h3>
<p class="detail">{refusal}</p>
</div>
</div>
</Panel>
}
.into_any(),
// HIDDEN (owner order 2026-10-18, wide audience): the "vouch
// for the console's key, paste back what the command printed"
// panel. Behind a flag; the server's answer is unchanged.
Shown::Waiting { .. } if !SHOW_VOUCH_PANEL => ().into_any(),
Shown::Waiting {
refusal,
command,
fingerprint,
} => view! {
<Panel title="This console needs your word">
<div class="panel-body">
<Hint
line="Vouch for the console's key with a key you already hold."
more="This console reads Gunnar as you, with a key of its own on your \
name. Gunnar will not accept a second key on a name that already \
has one unless you vouch for the new one with a key you already \
hold — and until you do, your reads are answered without your \
grants, which is why so much of the console is empty."
/>
<div class="failure" role="alert">
<h3>"Gunnar refused the console's key"</h3>
<p class="detail">{refusal}</p>
</div>
<p>
"Run this where your own SSH key is, as yourself. It signs the \
waiting key with one Gunnar already trusts:"
</p>
// One line, selectable whole: it carries an OpenSSH
// public key, and a paste that loses a character
// fails far away with a signature that does not
// verify.
<pre class="command-line">{command}</pre>
<p class="truncation">
{format!("The key it is about: {fingerprint}")}
</p>
<div class="field">
<label for="attestation">
"Paste back everything the command printed"
</label>
<textarea
id="attestation"
rows="6"
spellcheck="false"
prop:value=move || pasted.get()
on:input=move |ev| pasted.set(event_target_value(&ev))
></textarea>
</div>
<div class="row" style="margin-top:.75rem">
<button
class="btn"
disabled=move || busy.get()
on:click=send
>
{move || if busy.get() { "Sending …" } else { "Vouch for it" }}
</button>
</div>
{move || fail.get().map(|fail| view! { <FailBox fail=fail/> })}
</div>
</Panel>
}
.into_any(),
}
}}
}
}
/// **A person's own SSH keys, and the first thing this console asks a
/// brand-new account for.**
///
/// Somebody who has just registered a passkey can read the console and can do
/// nothing with git: gunnar authenticates the KEY over SSH and has never heard
/// of them. This panel is where they fix that themselves.
///
/// # Why it is not the Keys page's form
///
/// The Access page posts to `/api/keys`, which names a `principal_id` and is
/// admin-floored — correctly, because it hands out access to anybody's
/// namespace. A viewer pointed at it gets a refusal, which is what the first
/// version of this nudge did. It posts to `/api/account/keys` instead: the
/// server takes the name from the SESSION and there is no principal to pick,
/// which is why there is no picker here and why the form is a paste field and
/// a button.
///
/// # The nudge is the empty state
///
/// An empty list is what a fresh signup has, and that is where "Add your SSH
/// public key to push" is printed. One panel either way — a heading that
/// swapped with the list would mean two panels, and swapping panels remounts
/// the input and eats whatever was half-typed into it.
#[component]
fn OwnKeys() -> impl IntoView {
let status = session();
let reload = RwSignal::new(0u32);
let keys = LocalResource::new(move || {
reload.track();
async { crate::api::get::<Page<Key>>("/api/account/keys").await }
});
let line = RwSignal::new(String::new());
let busy = RwSignal::new(false);
let fail = RwSignal::new(None::<crate::api::Fail>);
let add = move |_| {
if busy.get_untracked() {
return;
}
let pasted = line.get_untracked().trim().to_owned();
if pasted.is_empty() {
return;
}
busy.set(true);
fail.set(None);
leptos::task::spawn_local(async move {
// `openssh_public_key` and nothing else. The route REFUSES a body
// that carries `user` or `principal_id`, so sending one to be
// helpful would be a 400 rather than a courtesy.
let body = serde_json::json!({ "openssh_public_key": pasted });
match crate::api::post::<_, serde_json::Value>("/api/account/keys", &body).await {
Ok(_) => {
line.set(String::new());
reload.update(|n| *n += 1);
// "You cannot push yet" is an item in the queue, and this
// is the act that answers it.
crate::app::nudge_actions();
}
Err(refusal) => fail.set(Some(refusal)),
}
busy.set(false);
});
};
let remove = move |fingerprint: String| {
if busy.get_untracked() {
return;
}
busy.set(true);
fail.set(None);
leptos::task::spawn_local(async move {
// The fingerprint goes in the query string, encoded: it is
// `SHA256:` and base64, which carries `+`, `/` and `=`.
let url = format!(
"/api/account/keys?fingerprint={}",
crate::api::segment(&fingerprint)
);
match crate::api::delete::<serde_json::Value>(&url).await {
Ok(_) => reload.update(|n| *n += 1),
Err(refusal) => fail.set(Some(refusal)),
}
busy.set(false);
});
};
// Nothing at all for a session that is not one: an anonymous or open
// console has no name to hold keys against, and the server says so rather
// than guessing. No point printing its refusal on this page.
let signed_in = move || status.get().is_some_and(|s| s.authenticated);
view! {
<Show when=signed_in>
// `#keys`: You ▾ → "Keys & passkeys" lands here.
<span id="keys"></span>
<Panel title="SSH keys">
<div class="panel-body">
<Hint
line="The keys registered against your own name, and only yours."
more="Gunnar authenticates the key, never the user name. These are the keys \
registered against your own name, and only yours. Encrypted repositories \
are wrapped to your one encryption key, which is quantum-safe unless you \
chose a classic one: see Security keys for encryption below."
/>
<div class="form-row">
<div class="field" style="flex:1;min-width:18rem">
<label for="own-key">"OpenSSH public key"</label>
<input
id="own-key"
type="text"
placeholder="ssh-ed25519 AAAA… comment"
prop:value=move || line.get()
on:input=move |ev| line.set(event_target_value(&ev))
/>
</div>
<button
class="btn"
style="align-self:flex-end"
disabled=move || busy.get()
on:click=add
>
{move || if busy.get() { "Adding …" } else { "Add key" }}
</button>
</div>
{move || fail.get().map(|fail| view! { <FailBox fail=fail/> })}
</div>
{move || {
let value = keys.get();
resolved(value, None, move |page: Page<Key>| {
if page.items.is_empty() {
// The nudge itself, and it lives HERE rather than
// in the panel's title because `Panel` takes a
// `String` and not a signal: a reactive heading
// would mean two panels, and swapping panels as
// the list changes remounts the input and eats
// whatever was half-typed in it.
return view! {
<Empty
title="Add your SSH public key to push"
detail="Paste the contents of ~/.ssh/id_ed25519.pub above. \
Until then, clone and push over SSH are refused."
/>
}
.into_any();
}
let now = now_ms();
view! {
<div class="table-wrap">
<table>
<caption class="vh">"Your registered SSH keys"</caption>
<thead>
<tr>
<th scope="col">"Fingerprint"</th>
<th scope="col">"Type"</th>
<th scope="col">"Comment"</th>
<th scope="col">"Registered"</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{page.items
.into_iter()
.map(|key| {
let fingerprint = key.fingerprint.clone();
view! {
<tr>
<td class="mono">{key.fingerprint.clone()}</td>
<td class="mono">{key.algo.clone()}</td>
<td>{key.comment.clone()}</td>
<td class="num">
{match key.added_at_unix_ms {
Some(at) => view! { {fmt::since(at, now)} }.into_any(),
None => view! { <Unset why="not recorded"/> }.into_any(),
}}
</td>
<td>
<button
class="btn danger"
disabled=move || busy.get()
on:click=move |_| remove(fingerprint.clone())
>
"Remove"
</button>
</td>
</tr>
}
})
.collect_view()}
</tbody>
</table>
</div>
}
.into_any()
})
}}
</Panel>
</Show>
}
}
/// **Security keys for encryption** (CRYPT-YUBI, owner 2026-10-26): the
/// person's ONE encryption key and its unlock methods. Enrolling a security
/// key is two touches (the credential, then its PRF secret from a get); the
/// first one MAKES the encryption key, here in this tab. Every wrap is made
/// here; Gunnar stores ciphertext. Adding or removing a method touches no
/// repository. At least two methods are urged: a spare key or a printed code.
#[component]
fn EncryptionKeys() -> impl IntoView {
use crate::sealed::{self, EncryptionKey};
let status = session();
let reload = RwSignal::new(0u32);
let key = LocalResource::new(move || {
reload.track();
async { crate::api::get::<EncryptionKey>(sealed::KEY_ROUTE).await }
});
let label = RwSignal::new(String::new());
// The person's ONE key is quantum-safe unless they choose classic here, once (CRYPT-PQ-DEFAULT).
let classic = RwSignal::new(false);
let code = RwSignal::new(String::new());
let shown_code = RwSignal::new(None::<String>);
let busy = RwSignal::new(false);
let fail = RwSignal::new(None::<String>);
let open_here = RwSignal::new(sealed::is_open());
let run = move |work: std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>>>>| {
if busy.get_untracked() {
return;
}
busy.set(true);
fail.set(None);
leptos::task::spawn_local(async move {
if let Err(why) = work.await {
fail.set(Some(why));
}
open_here.set(sealed::is_open());
reload.update(|n| *n += 1);
busy.set(false);
});
};
let enrol = move |_| {
let user = status.get_untracked().map(|s| s.user).unwrap_or_default();
let name = label.get_untracked();
let classic = classic.get_untracked();
run(Box::pin(async move {
sealed::enrol_security_key(&user, &name, classic).await?;
label.set(String::new());
Ok(())
}))
};
let unlock = move |_| {
let typed = code.get_untracked();
run(Box::pin(async move {
sealed::ensure_open(Some(typed)).await?;
code.set(String::new());
Ok(())
}))
};
let make_code = move |_| {
run(Box::pin(async move {
let (made, _) = sealed::add_recovery_code().await?;
shown_code.set(Some(made));
Ok(())
}))
};
let remove = move |id: String| {
run(Box::pin(async move { sealed::remove_method(&id).await.map(|_| ()) }))
};
let signed_in = move || status.get().is_some_and(|s| s.authenticated);
view! {
<Show when=signed_in>
<span id="encryption-keys"></span>
<Panel title="Security keys for encryption">
<div class="panel-body encryption-keys" data-open=move || open_here.get().to_string()>
<Hint
line="One encryption key opens every encrypted repository you are a member of."
more="Your encryption key is quantum-safe (ML-KEM-768 + X25519), and so is every repository \
wrapped to it: a copy taken today stays closed after quantum computers arrive. Your \
security keys and a printed recovery code open it in this browser; your SSH key opens \
it in the Gunnar CLI. Gunnar keeps it only as ciphertext, one copy per way in, and each \
way in says whether its own copy is quantum-safe. Adding or removing a way in touches no \
repository: the repositories are wrapped to the encryption key, never to a security key."
/>
{move || key.get().and_then(|k| k.ok()).filter(|k| k.exists && k.wants_a_spare()).map(|_| view! {
<p class="warn spare-urge">
"Add a second way in: a spare security key or a printed recovery code."
</p>
})}
{move || {
let held = key.get().and_then(|k| k.ok()).is_some_and(|k| k.exists);
(held && !open_here.get()).then(|| view! {
<div class="form-row">
<button class="btn enc-open" disabled=move || busy.get() on:click=unlock>
"Open your encryption key"
</button>
<div class="field">
<label for="enc-code">"Or your recovery code"</label>
<input id="enc-code" type="password" autocomplete="off" spellcheck="false" autocapitalize="off"
prop:value=move || code.get()
on:input=move |ev| code.set(event_target_value(&ev))/>
</div>
</div>
})
}}
<p class="muted enc-pin">
"This browser remembers your encryption key's fingerprint and refuses another one. "
<button class="btn btn-small enc-forget-pin" on:click=move |_| { sealed::forget_pin(); reload.update(|n| *n += 1); }>
"Forget this browser's pinned key"
</button>
</p>
{move || key.get().and_then(|k| k.ok()).filter(|k| k.exists).map(|k| view! {
<p class="muted enc-kind">{sealed::key_kind_words(&k)}</p>
})}
{move || key.get().and_then(|k| k.ok()).filter(|k| !k.exists).map(|_| view! {
<div class="field enc-classic-choice">
<label for="enc-classic"
title="A person has one encryption key. A classic key (X25519) can only be a member of classic repositories, and a ring wrapped to it opens once quantum computers break X25519. Choose it only to join repositories whose members have classic keys.">
<input id="enc-classic" type="checkbox"
prop:checked=move || classic.get()
on:change=move |ev| classic.set(event_target_checked(&ev))/>
" Make a classic key instead (classic repositories only)"
</label>
</div>
})}
<div class="form-row">
<div class="field">
<label for="enc-label">"Name the security key"</label>
<input id="enc-label" type="text" placeholder="YubiKey 5C, keyring"
prop:value=move || label.get()
on:input=move |ev| label.set(event_target_value(&ev))/>
</div>
<button class="btn enc-enrol" style="align-self:flex-end" disabled=move || busy.get() on:click=enrol>
{move || if busy.get() { "Touch your key …" } else { "Add a security key" }}
</button>
<button class="btn enc-code-make" style="align-self:flex-end"
disabled=move || busy.get() || !open_here.get() on:click=make_code>
"Make a recovery code"
</button>
</div>
{move || shown_code.get().map(|c| view! {
<div class="recovery-shown">
<p>"Your recovery code. Write it down now: it is not shown again."</p>
<pre class="mono recovery-code">{c}</pre>
</div>
})}
{move || fail.get().map(|why| view! { <p class="fail enc-why">{why}</p> })}
</div>
{move || match key.get() {
None => view! { <crate::ui::Loading/> }.into_any(),
Some(Err(f)) => view! { <FailBox fail=f/> }.into_any(),
Some(Ok(k)) if !k.exists => view! {
<Empty title="No encryption key yet"
detail="Add a security key: two touches make your encryption key and its first way in."/>
}.into_any(),
Some(Ok(k)) => view! {
<div class="table-wrap">
<table class="enc-methods">
<caption class="vh">"Ways to open your encryption key"</caption>
<tbody>
{k.methods.clone().into_iter().map(|m| {
let id = m.id.clone();
let strength = sealed::wrap_strength(&k, &m);
let why = sealed::wrap_strength_why(&m);
view! {
<tr data-kind=m.kind.clone()>
<td>{sealed::method_line(&m)}</td>
<td class="enc-strength" title=why>{strength.unwrap_or_default()}</td>
<td class="num">{fmt::since(m.added_ms as i64, now_ms())}</td>
<td>
<button class="btn danger" disabled=move || busy.get()
on:click=move |_| remove(id.clone())>"Remove"</button>
</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
}.into_any(),
}}
</Panel>
</Show>
}
}
/// **The passkeys, invites and sessions of the whole box** — an administrator's
/// three panels, and not the account holder's.
///
/// They lived on `/account` until 2026-09-18, inside one
/// `<Show when=is_admin>`. That page is the CUSTOMER's — their plan, their
/// disk, their own SSH keys, their own session — and an admin who opened it
/// saw a page where "my keys" sat two panels above "sign out everyone". The
/// complaint that moved them, in the owner's words: *"im an admin but must be
/// treated as a ordinary user that has right to buy too."*
///
/// **Nothing about who may see them changed in the move.** They are rendered by
/// [`crate::pages::admin::Admin`], which is gated exactly as the `<Show>` here
/// was, and every route they call is floored by the server regardless. The
/// panels are relocated, not re-permissioned.
#[component]
pub fn PasskeyAdmin() -> impl IntoView {
let reload = RwSignal::new(0u32);
let failed = RwSignal::new(None::<crate::api::Fail>);
let credentials = LocalResource::new(move || {
reload.get();
async { crate::api::get::<Credentials>("/auth/passkey/credentials").await }
});
let invites = LocalResource::new(move || {
reload.get();
async { crate::api::get::<Invites>("/auth/passkey/invites").await }
});
let invite_role = RwSignal::new("viewer".to_owned());
let invite_user = RwSignal::new(String::new());
let minted_url = RwSignal::new(None::<String>);
view! {
<Panel title="Passkeys">
{move || {
let value = credentials.get();
resolved(value, None, move |list: Credentials| {
if list.credentials.is_empty() {
return view! {
<Empty
title="No passkeys registered"
detail="Register the first one at /login with the admin \
token. Always register a second one as a backup."
/>
}
.into_any();
}
let now = now_ms();
let last_admin = list.enabled_admins <= 1;
view! {
<div class="table-wrap">
<table>
<caption class="vh">"Registered passkeys"</caption>
<thead>
<tr>
<th scope="col">"Label"</th>
<th scope="col">"User"</th>
<th scope="col">"Role"</th>
<th scope="col">"Registered"</th>
<th scope="col">"Last used"</th>
<th scope="col">"Status"</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{list.credentials
.into_iter()
.map(|cred| {
let id = cred.id.clone();
let is_last_admin = last_admin && cred.role == "admin"
&& !cred.disabled;
view! {
<tr>
<td>{cred.label.clone()}</td>
<td class="mono">{cred.user.clone()}</td>
<td>
<Badge
tone=if cred.role == "admin" { Tone::Warn } else { Tone::Plain }
label=cred.role.clone()
/>
</td>
<td class="num">{fmt::since(cred.created * 1000, now)}</td>
<td class="num">
{match cred.last_used {
Some(at) => view! { {fmt::since(at * 1000, now)} }.into_any(),
None => view! { <Unset why="never used"/> }.into_any(),
}}
</td>
<td>
{if cred.disabled {
view! { <Badge tone=Tone::Bad label="revoked"/> }.into_any()
} else {
view! { <Badge tone=Tone::Pass label="active"/> }.into_any()
}}
</td>
<td>
{(!cred.disabled).then(|| view! {
<button
class="btn danger"
disabled=is_last_admin
title=if is_last_admin {
"This is the last active admin key. \
Register another one before revoking it."
} else {
"Revoke the key"
}
on:click=move |_| {
let body = ById { id: id.clone() };
leptos::task::spawn_local(async move {
match crate::api::post::<_, serde_json::Value>(
"/auth/passkey/credentials/revoke",
&body,
)
.await
{
Ok(_) => reload.update(|n| *n += 1),
Err(fail) => failed.set(Some(fail)),
}
});
}
>
"Revoke"
</button>
})}
</td>
</tr>
}
})
.collect_view()}
</tbody>
</table>
</div>
<Hint
line="New keys are registered at /login."
more="The ceremony has to run on the server's own origin, not from \
inside the console."
/>
}
.into_any()
})
}}
{move || failed.get().map(|fail| view! {
<div class="panel-body"><FailBox fail=fail/></div>
})}
</Panel>
<Panel title="Invites">
<div class="panel-body">
<Hint
line="A single-use link that registers one passkey for the person named. It can never grant admin."
more="Gunnar keeps the invite: the account is made for a new name, the role \
is a server grant on it, and the link is good for seven days. Hand it \
over any channel; the link is the credential. An invite can never \
grant admin."
/>
<div class="row" style="margin-top:.75rem; align-items:flex-end; gap:.6rem; flex-wrap:wrap">
<label>
"Role"
<select
on:change=move |ev| invite_role.set(event_target_value(&ev))
prop:value=move || invite_role.get()
>
<option value="viewer">"viewer"</option>
<option value="operator">"operator"</option>
</select>
</label>
<label>
"User"
<input
type="text"
prop:value=move || invite_user.get()
on:input=move |ev| invite_user.set(event_target_value(&ev))
/>
</label>
<button
class="btn"
on:click=move |_| {
let body = NewInvite {
role: invite_role.get_untracked(),
user: invite_user.get_untracked(),
};
leptos::task::spawn_local(async move {
match crate::api::post::<_, MintedInvite>(
"/auth/passkey/invites",
&body,
)
.await
{
Ok(minted) => {
minted_url.set(Some(minted.url));
invite_user.set(String::new());
reload.update(|n| *n += 1);
}
Err(fail) => failed.set(Some(fail)),
}
});
}
>
"Mint invite"
</button>
</div>
{move || minted_url.get().map(|url| view! {
<div style="margin-top:.75rem">
<p>
"Copy it now — the link is shown once and expires in seven days:"
</p>
<input
type="text"
readonly
class="mono"
prop:value=url
on:focus=move |ev| {
use wasm_bindgen::JsCast as _;
if let Some(input) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
input.select();
}
}
/>
</div>
})}
</div>
{move || {
let value = invites.get();
resolved(value, None, move |list: Invites| {
if list.invites.is_empty() {
return view! {
<Empty
title="No invites minted"
detail="Spent and expired invites stay listed as the \
audit trail."
/>
}
.into_any();
}
let now = now_ms();
view! {
<div class="table-wrap">
<table>
<caption class="vh">"Invites"</caption>
<thead>
<tr>
<th scope="col">"Role"</th>
<th scope="col">"User"</th>
<th scope="col">"Minted"</th>
<th scope="col">"By"</th>
<th scope="col">"Status"</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{list.invites
.into_iter()
.rev()
.map(|invite| {
let id = invite.id.clone();
let status = if let Some(by) = &invite.used_by {
view! { <Badge tone=Tone::Pass label=format!("redeemed by {by}")/> }.into_any()
} else if invite.revoked {
view! { <Badge tone=Tone::Bad label="revoked"/> }.into_any()
} else if invite.open {
view! { <Badge tone=Tone::Warn label="open"/> }.into_any()
} else {
view! { <Badge tone=Tone::Plain label="expired"/> }.into_any()
};
view! {
<tr>
<td>{invite.role.clone()}</td>
<td class="mono">
{match &invite.user {
Some(user) => view! { {user.clone()} }.into_any(),
None => view! { <Unset why="their choice"/> }.into_any(),
}}
</td>
<td class="num">{fmt::since(invite.created * 1000, now)}</td>
<td class="mono">{invite.created_by.clone()}</td>
<td>{status}</td>
<td>
{invite.open.then(|| view! {
<button
class="btn danger"
on:click=move |_| {
let body = ById { id: id.clone() };
leptos::task::spawn_local(async move {
match crate::api::post::<_, serde_json::Value>(
"/auth/passkey/invites/revoke",
&body,
)
.await
{
Ok(_) => reload.update(|n| *n += 1),
Err(fail) => failed.set(Some(fail)),
}
});
}
>
"Revoke"
</button>
})}
</td>
</tr>
}
})
.collect_view()}
</tbody>
</table>
</div>
}
.into_any()
})
}}
</Panel>
<Panel title="All sessions">
<div class="panel-body">
<p>
"Raises the epoch, so every issued cookie stops being valid. Open \
live streams notice within twenty seconds."
</p>
<div class="row" style="margin-top:.75rem">
<button
class="btn danger"
on:click=move |_| {
leptos::task::spawn_local(async move {
match crate::api::post_empty::<serde_json::Value>(
"/auth/sessions/revoke-all",
)
.await
{
Ok(_) => {
if let Some(window) = web_sys::window() {
let _ = window.location().set_href("/login");
}
}
Err(fail) => failed.set(Some(fail)),
}
});
}
>
"Sign out everyone"
</button>
</div>
</div>
</Panel> }
}
/// **The queue, listed.**
///
/// Draws nothing at all when there is nothing to do — not "you are all set",
/// which is a panel that appears on every visit to say the same thing and
/// therefore teaches the reader to skip the place where something real will
/// eventually appear.
///
/// Every item is rendered from the SERVER's words. The console composes no
/// sentence of its own here, which is what lets a deployment that sells and one
/// that does not carry the same panel: the items that mention a period or room
/// simply are not sent by a console that has none.
#[component]
fn Todo() -> impl IntoView {
use crate::actions::{listed, Actions, Urgency};
let answer = LocalResource::new(|| async { crate::api::get::<Actions>("/api/account/actions").await });
view! {
{move || {
// A refusal here draws nothing. `Me` refuses exactly the callers
// this panel is meaningless for, and the page below it already
// says what it can.
let Some(Ok(answer)) = answer.get() else { return ().into_any() };
let items = listed(&answer);
if items.is_empty() {
return ().into_any();
}
view! {
<Panel title="Actions">
<div class="panel-body">
<ul class="todo">
{items
.into_iter()
.map(|item| {
let urgent = item.urgency == Urgency::Urgent;
let cta = if item.cta.is_empty() {
"Open".to_owned()
} else {
item.cta.clone()
};
view! {
<li class=if urgent { "todo-item urgent" } else { "todo-item" }>
<div>
<strong>{item.title}</strong>
{(!item.detail.is_empty())
.then(|| view! { <p class="hint">{item.detail}</p> })}
</div>
<a class="btn quiet" href=item.href>{cta}</a>
</li>
}
})
.collect_view()}
</ul>
</div>
</Panel>
}
.into_any()
}}
}
}
#[component]
pub fn Account() -> impl IntoView {
let status = session();
view! {
<PageHeader title="Account">
// The account admin's audit log (2026-10-05); gunnar admits the
// account's admins alone (an appointed one too, round 5.3.1) — so a
// member is not shown the door.
{move || status.get().filter(|s| s.shows_audit_log()).map(|_| view! {
<a class="btn" id="account-audit-log" href="/admin/audit">"Audit log"</a>
})}
</PageHeader>
// **The customer's own plan and disk**, and the two things they can do
// about either. Its own module, and only in a build that sells: a
// community appliance does not contain `pages::subscription` at all, so
// this is not a panel that hides itself — there is nothing here to
// hide. Every string it renders lives in that file, which is why this
// one stays free of money vocabulary and stays scanned.
//
// **A member buys nothing** (2026-10-07): the account's space, the
// line naming whom to ask, and no plan of his own. Nothing is drawn
// before the session is known: neither door is drawn on a guess.
{move || match status.get() {
None => ().into_any(),
Some(s) if s.is_member() => view! { <crate::pages::my_storage::MemberSpace/> }.into_any(),
// A SOLD (provided) Gunnar (one shop, 2026-10-07): no plan of
// its own here; the line saying where, and the room.
Some(s) if !s.sells_here() => view! { <crate::pages::my_storage::ElsewhereSpace/> }.into_any(),
Some(_) => subscription_pane(),
}}
// The whole box's disks are Administration → Storage (owner,
// 2026-10-02: personal → Account, machine → Administration).
<div class="stack">
// **Actions, in full.** The shell carries one line of this on
// every page; here is the list behind it, because a headline that
// cannot be expanded is a nag rather than a queue.
<Todo/>
// Then the credential, and usually absent. One gunnar has refused
// is why every panel under it would be empty, so the explanation
// goes above the emptiness rather than under it.
<ConsoleCredential/>
<OwnKeys/>
<EncryptionKeys/>
<Panel title="Session">
<div class="panel-body">
{move || match status.get() {
None => view! { <p>"Checking …"</p> }.into_any(),
Some(status) if status.open => view! {
<div class="failure" role="alert">
<h3>"The console is unlocked"</h3>
<p class="detail" title="That is only defensible on loopback.">
"GUNNAR_UI_WEBAUTHN_RP_ID is not set: anyone who can reach the port can control Gunnar."
</p>
</div>
}
.into_any(),
Some(status) if status.authenticated => view! {
<div class="row">
<Badge tone=Tone::Pass label="signed in"/>
<span class="mono">{status.user.clone()}</span>
// The console role is shown only where it means
// something to the person: an operator's or an
// administrator's. Every ordinary person is a
// "viewer", which reads as a demotion (owner
// order 2026-10-18: hidden).
{crate::nav::role_badge(status.role.as_deref()).map(|role| view! {
<Badge tone=Tone::Plain label=role/>
})}
</div>
}
.into_any(),
Some(_) => view! {
<div class="row">
<Badge tone=Tone::Warn label="not signed in"/>
<a class="btn" href="/login">"Sign in"</a>
</div>
}
.into_any(),
}}
</div>
</Panel>
</div>
}
}