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

//! Principals, keys and grants.
//!
//! gunnar authenticates the KEY, never the SSH username, and "gunnar-admin" is
//! not a role but a server-scoped admin grant. The page is laid out to match
//! that rather than to look like a user list.

use leptos::prelude::*;
use serde::Deserialize;

use crate::api::{Fail, Page};
use crate::fmt;
use crate::pages::{now_ms, resolved, when};
use crate::ui::{
    Badge, DisabledPanel, Empty, FailBox, Hint, Metric, PageHeader, Panel, Tone, Truncation, Unset,
};

#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Principal {
    pub id: String,
    pub name: String,
    pub kind: String,
    pub created_at_unix_ms: Option<i64>,
    /// When gunnar last issued this principal a console session. `None`: never.
    #[serde(default)]
    pub last_sign_in_unix_ms: Option<i64>,
    /// When it last changed something (a push, a repository, a setting, a
    /// key…). `None`: never.
    #[serde(default)]
    pub last_operation_unix_ms: Option<i64>,
}

/// A last-seen cell ([`crate::identities::seen_cell`]): "5 min ago" with the
/// exact UTC time on hover, or "never".
fn seen_td(unix_ms: Option<i64>, now: i64) -> impl IntoView {
    let (text, title) = crate::identities::seen_cell(unix_ms, now);
    view! { <td class="num" title=title>{text}</td> }
}

#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Key {
    pub fingerprint: String,
    pub algo: String,
    pub principal_id: String,
    pub comment: String,
    pub added_at_unix_ms: Option<i64>,
    pub last_used_unix_ms: Option<i64>,
    pub expires_at_unix_ms: Option<i64>,
}

#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Grant {
    pub principal_id: String,
    pub namespace: String,
    pub repo: String,
    pub level: String,
    pub scope: String,
    pub granted_at_unix_ms: Option<i64>,
}

#[component]
pub fn Access() -> impl IntoView {
    let reload = RwSignal::new(0u32);
    let principals = LocalResource::new(move || {
        reload.track();
        async { crate::api::get::<Page<Principal>>("/api/principals").await }
    });
    let keys = LocalResource::new(move || {
        reload.track();
        async { crate::api::get::<Page<Key>>("/api/keys").await }
    });
    let grants = LocalResource::new(move || {
        reload.track();
        async { crate::api::get::<Page<Grant>>("/api/grants").await }
    });
    let session = crate::app::session();
    let is_admin = Memo::new(move |_| session.get().is_some_and(|s| s.is_admin()));
    // The box's own principals (`nav::is_system_kind`: everything that is not
    // a person) are hidden until an administrator asks for them.
    let show_system = RwSignal::new(false);

    view! {
        <PageHeader
            title="Identities"
        >
            <label class="toggle">
                <input type="checkbox" prop:checked=move || show_system.get() on:change=move |ev| show_system.set(event_target_checked(&ev))/>
                " Show system principals"
            </label>
        </PageHeader>
        // The Administration banner: this page acts on the box, not on the
        // reader. Same strip on every page in the group — see `pages::admin`.
        <crate::pages::admin::Banner/>

        <div class="stack">
            {move || is_admin.get().then(|| view! { <MachineRequests reload=reload/> })}

            <Panel title="Principals">
                {move || is_admin.get().then(|| view! {
                    <AddPrincipalForm reload=reload/>
                })}
                {move || resolved(principals.get(), None, |page: Page<Principal>| {
                    if page.items.is_empty() {
                        return view! {
                            <Empty
                                title="No principals"
                                detail="Gunnar has not been bootstrapped yet, or they have all \
                                        been removed."
                            />
                        }
                        .into_any();
                    }
                    let now = now_ms();
                    let total = page.total;
                    let shown = page.items.len();
                    let all = show_system.get();
                    let items: Vec<Principal> = page.items.into_iter().filter(|p| all || !crate::nav::is_system_kind(&p.kind)).collect();
                    view! {
                        <div class="table-wrap">
                            <table>
                                <caption class="vh">"Principals"</caption>
                                <thead>
                                    <tr>
                                        {crate::identities::PRINCIPAL_COLUMNS
                                            .into_iter()
                                            .map(|c| view! { <th scope="col">{c}</th> })
                                            .collect_view()}
                                    </tr>
                                </thead>
                                <tbody>
                                    {items
                                        .into_iter()
                                        .map(|p| view! {
                                            <tr>
                                                <td>{p.name.clone()}</td>
                                                <td><Badge tone=Tone::Plain label=p.kind.clone()/></td>
                                                <td class="oid">{p.id.clone()}</td>
                                                <td class="num">
                                                    {when(p.created_at_unix_ms, now)}
                                                </td>
                                                {seen_td(p.last_sign_in_unix_ms, now)}
                                                {seen_td(p.last_operation_unix_ms, now)}
                                            </tr>
                                        })
                                        .collect_view()}
                                </tbody>
                            </table>
                        </div>
                        {(shown < total).then(|| view! { <Truncation shown=shown total=total/> })}
                    }
                    .into_any()
                })}
            </Panel>

            <Panel title="Keys">
                {move || is_admin.get().then(|| view! {
                    <AddKeyForm principals=principals reload=reload/>
                })}
                {move || resolved(keys.get(), None, |page: Page<Key>| {
                    if page.items.is_empty() {
                        return view! {
                            <Empty
                                title="No keys"
                                detail="Without a registered key, nobody can clone or push."
                            />
                        }
                        .into_any();
                    }
                    let now = now_ms();
                    let total = page.total;
                    let shown = page.items.len();
                    view! {
                        <div class="table-wrap">
                            <table>
                                <caption class="vh">"Registered keys"</caption>
                                <thead>
                                    <tr>
                                        <th scope="col">"Fingerprint"</th>
                                        <th scope="col">"Algorithm"</th>
                                        <th scope="col">"Principal"</th>
                                        <th scope="col">"Added"</th>
                                        <th scope="col">"Last used"</th>
                                        <th scope="col">"Expires"</th>
                                    </tr>
                                </thead>
                                <tbody>
                                    {page.items
                                        .into_iter()
                                        .map(|key| view! {
                                            <tr>
                                                <td class="fp">{key.fingerprint.clone()}</td>
                                                <td class="mono">{key.algo.clone()}</td>
                                                <td class="oid">{key.principal_id.clone()}</td>
                                                <td class="num">{when(key.added_at_unix_ms, now)}</td>
                                                <td class="num">
                                                    {match key.last_used_unix_ms {
                                                        Some(at) => view! { {fmt::since(at, now)} }.into_any(),
                                                        // gunnar deliberately never writes this
                                                        // field, so an empty cell would read as
                                                        // "never used" when it means "not recorded".
                                                        None => view! {
                                                            <Unset why="Gunnar does not record when a key is used"/>
                                                        }
                                                        .into_any(),
                                                    }}
                                                </td>
                                                <td class="num">
                                                    {match key.expires_at_unix_ms {
                                                        Some(at) if at <= now => view! {
                                                            <Badge tone=Tone::Bad label="expired"/>
                                                        }
                                                        .into_any(),
                                                        Some(at) => view! { {fmt::since(at, now)} }.into_any(),
                                                        None => view! {
                                                            <Unset why="no expiry set"/>
                                                        }
                                                        .into_any(),
                                                    }}
                                                </td>
                                            </tr>
                                        })
                                        .collect_view()}
                                </tbody>
                            </table>
                        </div>
                        {(shown < total).then(|| view! { <Truncation shown=shown total=total/> })}
                    }
                    .into_any()
                })}
            </Panel>

            <Panel title="Grants">
                {move || is_admin.get().then(|| view! {
                    <AddGrantForm principals=principals reload=reload/>
                })}
                {move || resolved(grants.get(), None, |page: Page<Grant>| {
                    if page.items.is_empty() {
                        return view! {
                            <Empty
                                title="No grants"
                                detail="Only principals with a grant can read or write."
                            />
                        }
                        .into_any();
                    }
                    let now = now_ms();
                    let total = page.total;
                    let shown = page.items.len();
                    view! {
                        <div class="table-wrap">
                            <table>
                                <caption class="vh">"Grants"</caption>
                                <thead>
                                    <tr>
                                        <th scope="col">"Principal"</th>
                                        <th scope="col">"Scope"</th>
                                        <th scope="col">"Applies to"</th>
                                        <th scope="col">"Level"</th>
                                        <th scope="col">"Granted"</th>
                                        <th scope="col"><span class="vh">"Revoke"</span></th>
                                    </tr>
                                </thead>
                                <tbody>
                                    {page.items
                                        .into_iter()
                                        .map(|grant| {
                                            let target = match (grant.namespace.as_str(), grant.repo.as_str()) {
                                                ("", _) => "the whole server".to_owned(),
                                                (ns, "") => format!("{ns}/*"),
                                                (ns, repo) => format!("{ns}/{repo}"),
                                            };
                                            let revokable = grant.clone();
                                            let tone = match grant.level.as_str() {
                                                "admin" => Tone::Warn,
                                                _ => Tone::Plain,
                                            };
                                            view! {
                                                <tr>
                                                    <td class="oid">{grant.principal_id.clone()}</td>
                                                    <td>{grant.scope.clone()}</td>
                                                    <td class="mono">{target}</td>
                                                    <td><Badge tone=tone label=grant.level.clone()/></td>
                                                    <td class="num">
                                                        {when(grant.granted_at_unix_ms, now)}
                                                    </td>
                                                    <td>
                                                        {move || is_admin.get().then(|| view! { <RevokeGrant grant=revokable.clone() reload=reload/> })}
                                                    </td>
                                                </tr>
                                            }
                                        })
                                        .collect_view()}
                                </tbody>
                            </table>
                        </div>
                        {(shown < total).then(|| view! { <Truncation shown=shown total=total/> })}
                    }
                    .into_any()
                })}
            </Panel>

            <Lockouts is_admin=is_admin reload=reload/>

            <DisabledPanel
                title="Access tokens"
                because="No RPC manages Gunnar's smart-HTTP git tokens: use `gunnar token add|list|revoke` on the server."
            />
        </div>
    }
}

/// ★ UW-19: **an admin revokes one grant** — armed, then confirmed; gunnar's
/// `Access.Revoke` decides, and its refusal is shown verbatim.
#[component]
fn RevokeGrant(grant: Grant, reload: RwSignal<u32>) -> impl IntoView {
    let armed = RwSignal::new(false);
    let busy = RwSignal::new(false);
    let fail = RwSignal::new(None::<Fail>);
    let body = serde_json::json!({ "principal_id": grant.principal_id, "namespace": grant.namespace, "repo": grant.repo });
    view! {
        {move || if armed.get() {
            let body = body.clone();
            view! {
                <button class="btn danger" disabled=move || busy.get() on:click=move |_| submit("/api/grants/revoke", body.clone(), busy, fail, reload, move || armed.set(false))>"Confirm: revoke"</button>
                <button class="btn quiet" on:click=move |_| armed.set(false)>"Cancel"</button>
            }.into_any()
        } else {
            view! { <button class="btn quiet" on:click=move |_| armed.set(true)>"Revoke…"</button> }.into_any()
        }}
        {move || fail.get().map(|f| view! { <FailBox fail=f/> })}
    }
}

/// One POST with a busy flag and the refusal shown verbatim.
fn submit(
    url: &'static str,
    body: serde_json::Value,
    busy: RwSignal<bool>,
    fail: RwSignal<Option<Fail>>,
    reload: RwSignal<u32>,
    clear: impl Fn() + 'static,
) {
    if busy.get_untracked() {
        return;
    }
    busy.set(true);
    fail.set(None);
    leptos::task::spawn_local(async move {
        match crate::api::post::<_, serde_json::Value>(url, &body).await {
            Ok(_) => {
                clear();
                reload.update(|n| *n += 1);
            }
            Err(refusal) => fail.set(Some(refusal)),
        }
        busy.set(false);
    });
}

/// The `<select>` of existing principals every form below shares.
fn principal_select(
    id: &'static str,
    principals: LocalResource<Result<Page<Principal>, Fail>>,
    chosen: RwSignal<String>,
) -> impl IntoView {
    view! {
        <select id=id on:change=move |ev| chosen.set(event_target_value(&ev))>
            <option value="">"— principal —"</option>
            {move || {
                principals.get().and_then(|r| r.ok()).map(|page| {
                    page.items
                        .into_iter()
                        .map(|p| {
                            let label = p.name.clone();
                            view! { <option value=p.id.clone()>{label}</option> }
                        })
                        .collect_view()
                })
            }}
        </select>
    }
}

/// Register an SSH public key for a principal — what turns a person with a
/// keypair into someone who can clone and push.
///
#[component]
fn AddKeyForm(
    principals: LocalResource<Result<Page<Principal>, Fail>>,
    reload: RwSignal<u32>,
) -> impl IntoView {
    let principal = RwSignal::new(String::new());
    let key_line = RwSignal::new(String::new());
    let busy = RwSignal::new(false);
    let fail = RwSignal::new(None::<Fail>);
    let add = move |_| {
        let body = serde_json::json!({
            "principal_id": principal.get_untracked(),
            "openssh_public_key": key_line.get_untracked().trim(),
        });
        submit("/api/keys", body, busy, fail, reload, move || {
            key_line.set(String::new())
        });
    };
    view! {
        <div class="panel-body">
            <div class="form-row">
                <div class="field" style="min-width:10rem">
                    <label for="key-principal">"Principal"</label>
                    {principal_select("key-principal", principals, principal)}
                </div>
                <div class="field" style="flex:2;min-width:18rem">
                    <label for="key-line">"OpenSSH public key"</label>
                    <input
                        id="key-line"
                        type="text"
                        placeholder="ssh-ed25519 AAAA… comment"
                        prop:value=move || key_line.get()
                        on:input=move |ev| key_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>
    }
}

/// Create a principal. gunnar also mints the account namespace of the same
/// name, and refuses the whole call if that namespace already exists — the
/// helper text says so because the refusal will.
#[component]
fn AddPrincipalForm(reload: RwSignal<u32>) -> impl IntoView {
    let name = RwSignal::new(String::new());
    let kind = RwSignal::new("user".to_owned());
    let busy = RwSignal::new(false);
    let fail = RwSignal::new(None::<Fail>);
    let add = move |_| {
        let body = serde_json::json!({
            "name": name.get_untracked().trim(),
            "kind": kind.get_untracked(),
        });
        submit("/api/principals", body, busy, fail, reload, move || {
            name.set(String::new())
        });
    };
    view! {
        <div class="panel-body">
            <div class="form-row">
                <div class="field" style="flex:1;min-width:12rem">
                    <label for="principal-name">"Name"</label>
                    <input
                        id="principal-name"
                        type="text"
                        placeholder=crate::identities::HINT_NEW_PRINCIPAL
                        prop:value=move || name.get()
                        on:input=move |ev| name.set(event_target_value(&ev))
                    />
                </div>
                <div class="field">
                    <label for="principal-kind">"Kind"</label>
                    <select
                        id="principal-kind"
                        on:change=move |ev| kind.set(event_target_value(&ev))
                    >
                        <option value="user">"user"</option>
                        <option value="machine">"machine"</option>
                        <option value="deploy">"deploy"</option>
                    </select>
                </div>
                <button
                    class="btn"
                    style="align-self:flex-end"
                    disabled=move || busy.get()
                    on:click=add
                >
                    {move || if busy.get() { "Adding …" } else { "Add principal" }}
                </button>
            </div>
            <Hint
                line="The name must be free as a principal and as a namespace. Add a key next."
                more="Creating a principal also creates its account namespace, so the name must be \
                      free on both counts. Add a key next, or the principal cannot log in."
            />
            {move || fail.get().map(|fail| view! { <FailBox fail=fail/> })}
        </div>
    }
}

/// Give a principal access. Empty namespace grants server-wide; a namespace
/// with an empty repo grants across the namespace.
#[component]
fn AddGrantForm(
    principals: LocalResource<Result<Page<Principal>, Fail>>,
    reload: RwSignal<u32>,
) -> impl IntoView {
    let principal = RwSignal::new(String::new());
    let namespace = RwSignal::new(String::new());
    let repo = RwSignal::new(String::new());
    let level = RwSignal::new("read".to_owned());
    let busy = RwSignal::new(false);
    let fail = RwSignal::new(None::<Fail>);
    let add = move |_| {
        let body = serde_json::json!({
            "principal_id": principal.get_untracked(),
            "namespace": namespace.get_untracked().trim(),
            "repo": repo.get_untracked().trim(),
            "level": level.get_untracked(),
        });
        submit("/api/grants", body, busy, fail, reload, || {});
    };
    view! {
        <div class="panel-body">
            <div class="form-row">
                <div class="field" style="min-width:10rem">
                    <label for="grant-principal">"Principal"</label>
                    {principal_select("grant-principal", principals, principal)}
                </div>
                <div class="field">
                    <label for="grant-namespace">"Namespace"</label>
                    <input
                        id="grant-namespace"
                        type="text"
                        placeholder="empty = whole server"
                        prop:value=move || namespace.get()
                        on:input=move |ev| namespace.set(event_target_value(&ev))
                    />
                </div>
                <div class="field">
                    <label for="grant-repo">"Repository"</label>
                    <input
                        id="grant-repo"
                        type="text"
                        placeholder="empty = whole namespace"
                        prop:value=move || repo.get()
                        on:input=move |ev| repo.set(event_target_value(&ev))
                    />
                </div>
                <div class="field">
                    <label for="grant-level">"Level"</label>
                    <select id="grant-level" on:change=move |ev| level.set(event_target_value(&ev))>
                        <option value="read">"read"</option>
                        <option value="write">"write"</option>
                        <option value="admin">"admin"</option>
                    </select>
                </div>
                <button
                    class="btn"
                    style="align-self:flex-end"
                    disabled=move || busy.get()
                    on:click=add
                >
                    {move || if busy.get() { "Granting …" } else { "Grant" }}
                </button>
            </div>
            {move || fail.get().map(|fail| view! { <FailBox fail=fail/> })}
        </div>
    }
}

// ───────────────────────────────────────────────────────────────────────────
// Lockouts: the account gate, and the hard purge
// ───────────────────────────────────────────────────────────────────────────

/// One account lockout, as `/api/accounts/gates` reports it.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Lockout {
    pub account: String,
    /// `administrative` | `purge` | `unknown`.
    pub reason: String,
    pub holder: String,
    pub actor: String,
    pub note: String,
    pub at_unix_ms: Option<i64>,
    /// `None` = no deadline, i.e. in force until somebody lifts it. `Some` is a
    /// purge's lease.
    pub expires_unix_ms: Option<i64>,
}

/// One account purge, as `/api/accounts/{account}/purge` reports it.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct PurgeJob {
    pub job_id: String,
    pub account: String,
    /// `queued` | `running` | `done` | `failed` | `unknown`.
    pub state: String,
    pub queue_position: u32,
    pub stores_found: u32,
    pub stores_purged: u32,
    pub bytes_reclaimed: u64,
    pub principal_removed: bool,
    pub gate_released: bool,
    pub error: Option<String>,
    pub failures: Vec<String>,
    pub enqueued_at_unix_ms: Option<i64>,
    pub started_at_unix_ms: Option<i64>,
    pub finished_at_unix_ms: Option<i64>,
}

impl PurgeJob {
    /// Whether this job is still moving, and therefore whether to keep polling.
    /// `unknown` counts as settled, exactly as the gc panel's does: a state
    /// this build does not recognise must not make the page poll for ever.
    fn in_flight(&self) -> bool {
        self.state == "queued" || self.state == "running"
    }
}

/// What `POST /api/accounts/{account}/purge` answers.
#[derive(Debug, Clone, Deserialize, PartialEq)]
struct Purging {
    job: PurgeJob,
    gate: Option<Lockout>,
}

/// **Who is locked out, and the two buttons that change it.**
///
/// On the Identities page rather than a page of its own, because the thing
/// being locked out is one of the principals in the table above it and a
/// separate address would put the list and the lockout two clicks apart.
///
/// # Why there is no read-only lockout
///
/// The panel says so on screen, and it is not a simplification. In gunnar a
/// read CREATES things — a fetch explodes objects into the read cache, the
/// first read of an unindexed pack writes an index — so "let them keep cloning"
/// is a reader putting bytes back into an account something else is emptying.
/// An operator who reads "completely" and wonders why gets the reason in the
/// same paragraph rather than in a commit message.
#[component]
fn Lockouts(is_admin: Memo<bool>, reload: RwSignal<u32>) -> impl IntoView {
    let gates = LocalResource::new(move || {
        reload.track();
        async { crate::api::get::<Vec<Lockout>>("/api/accounts/gates").await }
    });
    view! {
        <Panel title="Lockouts">
            <div class="panel-body">
                <Hint
                    line="A locked account is locked completely: every level, on both doors."
                    more="Not read-only — in Gunnar a fetch explodes objects into the read \
                          cache and the first read of an unindexed pack writes one, so a reader would \
                          put bytes back into an account something else is emptying. Nobody else can \
                          reach into the account either, including a stranger cloning a public \
                          repository out of it."
                />
            </div>
            {move || is_admin.get().then(|| view! { <GateForm reload=reload/> })}
            {move || resolved(gates.get(), None, move |rows: Vec<Lockout>| {
                if rows.is_empty() {
                    return view! {
                        <Empty
                            title="Nobody is locked out"
                            detail="Expired purge leases are not listed: a lease that has run out \
                                    is not a lockout in force."
                        />
                    }
                    .into_any();
                }
                let now = now_ms();
                view! {
                    <div class="table-wrap">
                        <table>
                            <caption class="vh">"Locked accounts"</caption>
                            <thead>
                                <tr>
                                    <th scope="col">"Account"</th>
                                    <th scope="col">"Why"</th>
                                    <th scope="col">"Held by"</th>
                                    <th scope="col">"Expires"</th>
                                    <th scope="col">"Since"</th>
                                    <th scope="col"></th>
                                </tr>
                            </thead>
                            <tbody>
                                {rows
                                    .into_iter()
                                    .map(|row| {
                                        let account = row.account.clone();
                                        // A purge lease is the ordinary state of
                                        // an account being emptied; an
                                        // administrative lockout is somebody's
                                        // decision, and the one an operator is
                                        // more likely to have forgotten about.
                                        let tone = if row.reason == "purge" {
                                            Tone::Plain
                                        } else {
                                            Tone::Warn
                                        };
                                        view! {
                                            <tr>
                                                <td class="oid">{row.account.clone()}</td>
                                                <td>
                                                    <Badge tone=tone label=row.reason.clone()/>
                                                    {(!row.note.trim().is_empty()).then(|| view! {
                                                        <div class="hint">{row.note.clone()}</div>
                                                    })}
                                                </td>
                                                <td class="oid">{row.holder.clone()}</td>
                                                <td class="num">
                                                    // No deadline is the honest word for it and
                                                    // `Unset` is how this console spells that
                                                    // everywhere else. Rendering 0 as an instant
                                                    // in 1970 would show a standing suspension as
                                                    // long expired.
                                                    {match row.expires_unix_ms {
                                                        Some(at) => when(Some(at), now).into_any(),
                                                        None => view! { <Unset why="no deadline: in force until it is lifted"/> }
                                                            .into_any(),
                                                    }}
                                                </td>
                                                <td class="num">{when(row.at_unix_ms, now)}</td>
                                                <td class="row-actions">
                                                    {is_admin.get().then(|| view! {
                                                        <UngateButton
                                                            account=account.clone()
                                                            reload=reload
                                                        />
                                                    })}
                                                </td>
                                            </tr>
                                        }
                                    })
                                    .collect_view()}
                            </tbody>
                        </table>
                    </div>
                }
                .into_any()
            })}
            {move || is_admin.get().then(|| view! { <PurgePanel reload=reload/> })}
        </Panel>
    }
}

/// Lock a user out. A note is required, and the form says why.
#[component]
fn GateForm(reload: RwSignal<u32>) -> impl IntoView {
    let account = RwSignal::new(String::new());
    let note = RwSignal::new(String::new());
    let busy = RwSignal::new(false);
    let fail = RwSignal::new(None::<Fail>);
    let lock = move |_| {
        if busy.get_untracked() {
            return;
        }
        let name = account.get_untracked().trim().to_owned();
        if name.is_empty() {
            return;
        }
        busy.set(true);
        fail.set(None);
        let body = serde_json::json!({ "note": note.get_untracked().trim() });
        let url = format!("/api/accounts/{}/gate", crate::api::segment(&name));
        leptos::task::spawn_local(async move {
            match crate::api::post::<_, serde_json::Value>(&url, &body).await {
                Ok(_) => {
                    account.set(String::new());
                    note.set(String::new());
                    reload.update(|n| *n += 1);
                }
                Err(refusal) => fail.set(Some(refusal)),
            }
            busy.set(false);
        });
    };
    view! {
        <div class="panel-body">
            <div class="form-row">
                <div class="field" style="min-width:10rem">
                    <label for="gate-account">"Account"</label>
                    <input
                        id="gate-account"
                        type="text"
                        placeholder=crate::identities::HINT_LOCK_OUT
                        prop:value=move || account.get()
                        on:input=move |ev| account.set(event_target_value(&ev))
                    />
                </div>
                <div class="field" style="flex:1;min-width:14rem">
                    <label for="gate-note">"Reason"</label>
                    <input
                        id="gate-note"
                        type="text"
                        placeholder="what the user will be told"
                        prop:value=move || note.get()
                        on:input=move |ev| note.set(event_target_value(&ev))
                    />
                </div>
                <button
                    class="btn danger"
                    style="align-self:flex-end"
                    disabled=move || busy.get()
                    on:click=lock
                >
                    {move || if busy.get() { "Locking …" } else { "Lock out" }}
                </button>
            </div>
            <Hint
                line="The reason is required: the user sees it in every refusal. Reversible with one click."
                more="The reason is what the user sees in every refusal from that moment on — a \
                      lockout nobody can explain is a support ticket. Nothing is deleted: this is \
                      reversible with one click."
            />
            {move || fail.get().map(|fail| view! { <FailBox fail=fail/> })}
        </div>
    }
}

/// Lift one lockout. Not `danger`: this is the button that gives access back.
#[component]
fn UngateButton(account: String, reload: RwSignal<u32>) -> impl IntoView {
    let busy = RwSignal::new(false);
    let click = move |_| {
        if busy.get_untracked() {
            return;
        }
        busy.set(true);
        let url = format!("/api/accounts/{}/gate", crate::api::segment(&account));
        leptos::task::spawn_local(async move {
            let _ = crate::api::delete::<serde_json::Value>(&url).await;
            reload.update(|n| *n += 1);
            busy.set(false);
        });
    };
    view! {
        <button class="btn quiet" disabled=move || busy.get() on:click=click>
            {move || if busy.get() { "Unlocking …" } else { "Unlock" }}
        </button>
    }
}

/// **The hard purge, and the poller that watches it.**
///
/// Modelled on the repo page's gc panel — one path, POST to start and GET to
/// follow, one poller behind a guard, the button re-enabled once the job
/// settles so a failure can be retried. Three things differ, and each is
/// because this verb destroys:
///
/// * the account's own name has to be typed back. Not a `window.confirm`, which
///   this console uses nowhere; and the check is repeated in the BFF, because a
///   guard that lived only here would protect the button rather than the verb.
/// * the LOCKOUT is rendered before the job. gunnar takes it before it answers,
///   so it is a fact while the job is still a promise.
/// * a settled job that left the account locked says so loudly. That is the one
///   outcome an operator has to act on now: the data is gone and the customer
///   cannot use what is left.
#[component]
fn PurgePanel(reload: RwSignal<u32>) -> impl IntoView {
    let account = RwSignal::new(String::new());
    let reason = RwSignal::new(String::new());
    let confirm = RwSignal::new(String::new());
    let remove_principal = RwSignal::new(false);
    let busy = RwSignal::new(false);
    let polling = RwSignal::new(false);
    let job = RwSignal::new(None::<PurgeJob>);
    let gate = RwSignal::new(None::<Lockout>);
    let fail = RwSignal::new(None::<Fail>);

    // One poller at a time. Without the guard, pressing while a poll is running
    // starts a second loop and the two race to write `job`.
    let start_polling = move |url: String| {
        if polling.get_untracked() {
            return;
        }
        polling.set(true);
        leptos::task::spawn_local(async move {
            loop {
                gloo_timers::future::TimeoutFuture::new(2_000).await;
                match crate::api::get::<PurgeStatus>(&url).await {
                    Ok(latest) => {
                        let keep = latest.job.as_ref().is_some_and(PurgeJob::in_flight);
                        gate.set(latest.gate);
                        job.set(latest.job);
                        if !keep {
                            // The account list and the lockout table both moved.
                            reload.update(|n| *n += 1);
                            break;
                        }
                    }
                    // Stop on the first refusal rather than hammering a server
                    // that is already saying no.
                    Err(refusal) => {
                        fail.set(Some(refusal));
                        break;
                    }
                }
            }
            polling.set(false);
        });
    };

    let start = move |_| {
        if busy.get_untracked() {
            return;
        }
        let name = account.get_untracked().trim().to_owned();
        if name.is_empty() {
            return;
        }
        busy.set(true);
        fail.set(None);
        let body = serde_json::json!({
            "reason": reason.get_untracked().trim(),
            "confirm": confirm.get_untracked().trim(),
            "remove_principal": remove_principal.get_untracked(),
        });
        let url = format!("/api/accounts/{}/purge", crate::api::segment(&name));
        leptos::task::spawn_local(async move {
            match crate::api::post::<_, Purging>(&url, &body).await {
                Ok(started) => {
                    let keep = started.job.in_flight();
                    gate.set(started.gate);
                    job.set(Some(started.job));
                    confirm.set(String::new());
                    // The lockout is already in force, so the tables above are
                    // stale the instant this returns.
                    reload.update(|n| *n += 1);
                    if keep {
                        start_polling(url);
                    }
                }
                Err(refusal) => fail.set(Some(refusal)),
            }
            busy.set(false);
        });
    };

    // Disabled while the request or the job is in flight, and while the typed
    // name does not match. A settled job leaves it pressable — that is how a
    // partial purge is retried.
    let disabled = move || {
        busy.get()
            || job.get().is_some_and(|j| j.in_flight())
            || !crate::identities::may_purge(&account.get(), &confirm.get())
    };

    view! {
        <div class="panel-body">
            <h3>"Purge an account"</h3>
            <p>
                "Deletes "<strong>"everything"</strong>" the user owns. No retention window, no undelete."
            </p>
            <Hint
                line="The user is locked out while it runs. One account is purged at a time."
                more="Everything: every repository, every soft-deleted one still holding bytes, every \
                      orphan store with no catalog row, and the namespaces — including nested ones \
                      under the account's name. There is no retention window and no undelete \
                      afterwards. The user is locked out before this returns, and let back in when \
                      it is over — including when it fails. One account is purged at a time for the \
                      whole box."
            />
            <div class="form-row">
                <div class="field" style="min-width:10rem">
                    <label for="purge-account">"Account"</label>
                    <input
                        id="purge-account"
                        type="text"
                        placeholder=crate::identities::HINT_PURGE
                        prop:value=move || account.get()
                        on:input=move |ev| account.set(event_target_value(&ev))
                    />
                </div>
                <div class="field" style="flex:1;min-width:12rem">
                    <label for="purge-reason">"Reason"</label>
                    <input
                        id="purge-reason"
                        type="text"
                        placeholder="what the user will be told"
                        prop:value=move || reason.get()
                        on:input=move |ev| reason.set(event_target_value(&ev))
                    />
                </div>
                <div class="field" style="min-width:10rem">
                    <label for="purge-confirm">{crate::identities::HINT_PURGE_CONFIRM}</label>
                    <input
                        id="purge-confirm"
                        type="text"
                        placeholder=crate::identities::HINT_PURGE_CONFIRM
                        prop:value=move || confirm.get()
                        on:input=move |ev| confirm.set(event_target_value(&ev))
                    />
                </div>
                <button
                    class="btn danger"
                    style="align-self:flex-end"
                    disabled=disabled
                    on:click=start
                >
                    {move || if busy.get() { "Starting …" } else { "Purge account" }}
                </button>
            </div>
            <div class="field">
                <label for="purge-remove-principal">
                    <input
                        id="purge-remove-principal"
                        type="checkbox"
                        prop:checked=move || remove_principal.get()
                        on:change=move |ev| remove_principal.set(event_target_checked(&ev))
                    />
                    " Remove the user too — keys, grants, tokens and group memberships"
                </label>
                <Hint
                    line="Without this the empty account survives, still locked."
                    more="Without this the account shell survives, still locked, and Unlock hands the \
                          user an empty account back. With it the name is free for somebody else."
                />
            </div>

            // The lockout FIRST: it is already true when the response exists,
            // while the job below is still a promise.
            {move || gate.get().map(|gate| view! {
                <p class="truncation">
                    <Badge tone=Tone::Warn label="locked"/>
                    " "{gate.account.clone()}" — "{gate.note.clone()}
                </p>
            })}
            {move || job.get().map(|j| {
                let tone = match j.state.as_str() {
                    "failed" => Tone::Bad,
                    "done" => Tone::Pass,
                    _ => Tone::Live,
                };
                let stuck = !j.in_flight() && !j.gate_released;
                view! {
                    <dl class="metrics">
                        <Metric label="State" value=j.state.clone()/>
                        {(j.state == "queued" && j.queue_position > 0).then(|| view! {
                            <Metric
                                label="Ahead in the queue"
                                value=j.queue_position.to_string()
                            />
                        })}
                        <Metric
                            label="Stores"
                            value=format!("{} of {}", j.stores_purged, j.stores_found)
                        />
                        <Metric label="Reclaimed" value=fmt::bytes(j.bytes_reclaimed)/>
                        <Metric
                            label="Lockout"
                            value=if j.gate_released { "released" } else { "in force" }
                        />
                    </dl>
                    <p class="truncation"><Badge tone=tone label=j.state.clone()/></p>
                    {j.principal_removed.then(|| view! {
                        <p class="truncation">
                            "The user, its keys, grants and memberships were removed too."
                        </p>
                    })}
                    {j.error.clone().map(|e| view! { <p class="truncation">{e}</p> })}
                    // The recovery list, never truncated: everything here is
                    // still on the disk and still on the bill, and a shortened
                    // list is one an operator has to go and re-derive.
                    {(!j.failures.is_empty()).then(|| view! {
                        <ul class="truncation">
                            {j.failures
                                .clone()
                                .into_iter()
                                .map(|f| view! { <li>{f}</li> })
                                .collect_view()}
                        </ul>
                    })}
                    {stuck.then(|| view! {
                        <p class="failure" title="An account that is empty and still shut is one the customer cannot use.">
                            "This account is still locked. If nobody else suspended it, press Unlock in the table above."
                        </p>
                    })}
                }
            })}
            {move || fail.get().map(|fail| view! { <FailBox fail=fail/> })}
        </div>
    }
}

/// What `GET /api/accounts/{account}/purge` answers: the job and the lockout,
/// because neither says on its own whether the customer can use their account.
#[derive(Debug, Clone, Deserialize, PartialEq)]
struct PurgeStatus {
    job: Option<PurgeJob>,
    gate: Option<Lockout>,
}


/// **Machines asking to enrol** (2026-10-05): every pending request gunnar
/// holds — "Pending: installer kit on dwarf requests admin — code ABC-123" —
/// with Approve (a fresh, user-verified touch of YOUR key, relayed to gunnar,
/// which verifies it and makes the kit's machine principal) and Refuse. The
/// list and both buttons are gunnar's to answer; a refusal is shown verbatim.
/// Nothing is drawn while nobody asks.
#[component]
fn MachineRequests(reload: RwSignal<u32>) -> impl IntoView {
    let asked = RwSignal::new(0u32);
    let requests = LocalResource::new(move || {
        reload.track();
        asked.track();
        async { crate::api::get::<crate::machine_enrol::Requests>("/auth/machine/requests").await }
    });
    let busy = RwSignal::new(false);
    let fail = RwSignal::new(None::<Fail>);
    // The role each request is given on approval, by request id; the asked
    // one until the admin picks another.
    let given = RwSignal::new(std::collections::HashMap::<String, String>::new());
    let decide = move |id: String, code: String, approve: bool| {
        if busy.get_untracked() {
            return;
        }
        busy.set(true);
        fail.set(None);
        leptos::task::spawn_local(async move {
            let path = format!("/auth/machine/requests/{}/{}", crate::api::segment(&id), if approve { "approve" } else { "refuse" });
            let done: Result<serde_json::Value, Fail> = if approve {
                async {
                    let options = crate::api::get::<serde_json::Value>("/auth/machine/step-up").await?;
                    let touch = crate::machine_enrol::touch(&options).await.map_err(|message| Fail {
                        status: 0,
                        code: String::new(),
                        message,
                    })?;
                    let ceremony = options.get("ceremony_id").and_then(|c| c.as_str()).unwrap_or_default().to_owned();
                    let role = given.get_untracked().get(&id).cloned().unwrap_or_default();
                    crate::api::post::<_, serde_json::Value>(&path, &crate::machine_enrol::approval_body(&ceremony, &code, &role, &touch)).await
                }
                .await
            } else {
                crate::api::post_empty::<serde_json::Value>(&path).await
            };
            match done {
                Ok(_) => asked.update(|n| *n += 1),
                Err(refusal) => fail.set(Some(refusal)),
            }
            busy.set(false);
        });
    };
    view! {
        {move || {
            let rows = match requests.get() {
                Some(Ok(page)) => page.requests,
                _ => Vec::new(),
            };
            (!rows.is_empty() || fail.get().is_some()).then(|| view! {
                <Panel title="Machines asking to enrol">
                    <div class="panel-body">
                        <Hint
                            line="An ops kit asked for a credential. Compare the code with the one the kit printed, then approve with your key."
                            more="Approve asks YOUR key for a fresh touch (and its PIN); Gunnar checks it, and only then makes the kit its own machine principal with the role you give it (the one it asked for, unless you pick another). Refuse stores nothing. A request lives 15 minutes."
                        />
                    </div>
                    {move || fail.get().map(|f| view! { <FailBox fail=f/> })}
                    <ul class="machine-requests">
                        {rows
                            .into_iter()
                            .map(|r| {
                                let (id, code) = (r.id.clone(), r.code.clone());
                                let (id2, code2) = (r.id.clone(), r.code.clone());
                                let (id3, asked) = (r.id.clone(), r.role.clone());
                                view! {
                                    <li class="machine-request" data-request=r.id.clone() data-code=r.code.clone()>
                                        <span class="machine-request-line">{crate::machine_enrol::pending_line(&r)}</span>
                                        " "
                                        <label class="vh" for=format!("give-{}", r.id)>"Role to give"</label>
                                        <select id=format!("give-{}", r.id) class="machine-role" on:change=move |ev| {
                                            let picked = event_target_value(&ev);
                                            given.update(|m| { m.insert(id3.clone(), picked); });
                                        }>
                                            {crate::machine_enrol::GIVABLE_ROLES.iter().map(|role| {
                                                let selected = *role == asked;
                                                view! { <option value=*role selected=selected>{*role}</option> }
                                            }).collect_view()}
                                        </select>
                                        " "
                                        <button class="approve" disabled=move || busy.get() on:click=move |_| decide(id.clone(), code.clone(), true)>"Approve"</button>
                                        " "
                                        <button class="refuse" disabled=move || busy.get() on:click=move |_| decide(id2.clone(), code2.clone(), false)>"Refuse"</button>
                                        <div class="hint">{crate::machine_enrol::detail_line(&r)}</div>
                                    </li>
                                }
                            })
                            .collect_view()}
                    </ul>
                </Panel>
            })
        }}
    }
}