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

nordisk/gunnar-ui

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