crates/gunnar-ui-web/src/pages/audit_log.rs
//! **Administration → Audit log** (owner's enterprise list, 2026-10-05): the
//! account admin's view of everything security-relevant in the account — the
//! log itself (filtered, paged, exported), the operator's looks to approve
//! with a fresh touch of the admin's own key, and the names the filter held.
//!
//! Every row is gunnar's (`/api/account/audit`, `/operator-access`,
//! `/names`), read AS THE PERSON; gunnar admits the account's admins alone,
//! and anybody else — the operator too — is shown gunnar's refusal. The
//! account is the reader's own unless the page was opened for another one
//! (`?account=`).
use leptos::prelude::*;
use crate::enterprise::{chain_line, export_href, filter_query, kind_words, look_line, who, Looks, Page, Reviews};
use crate::ui::{FailBox, Hint, Loading, PageHeader, Panel};
#[derive(Debug, Clone, Default, serde::Deserialize)]
struct Room {
namespace: String,
}
/// `?account=` on the page's own address, if any.
fn asked_account() -> Option<String> {
let search = web_sys::window()?.location().search().ok()?;
search
.trim_start_matches('?')
.split('&')
.filter_map(|p| p.split_once('='))
.find(|(k, _)| *k == "account")
.map(|(_, v)| v.to_owned())
.filter(|v| !v.is_empty())
}
#[component]
pub fn AuditLog() -> impl IntoView {
let account = LocalResource::new(|| async {
match asked_account() {
Some(a) => Ok(a),
None => crate::api::get::<Room>("/api/account/room").await.map(|r| r.namespace),
}
});
view! {
<PageHeader title="Audit log"/>
{move || match account.get() {
None => view! { <Loading/> }.into_any(),
Some(Err(fail)) => view! { <FailBox fail=fail/> }.into_any(),
Some(Ok(a)) => {
let (tag, log, looks, names) = (a.clone(), a.clone(), a.clone(), a);
view! {
<div class="stack" id="audit" data-account=tag>
<Log account=log/>
<OperatorLooks account=looks/>
<HeldNames account=names/>
</div>
}.into_any()
}
}}
}
}
#[component]
fn Log(account: String) -> impl IntoView {
let acct = StoredValue::new(account);
let kinds = RwSignal::new(String::new());
let actor = RwSignal::new(String::new());
let text = RwSignal::new(String::new());
let asked = RwSignal::new(String::new());
let before = RwSignal::new(None::<u64>);
let page = LocalResource::new(move || {
let query = asked.get();
let older = before.get();
let mut url = format!("/api/account/audit?account={}", crate::auth::percent_encode(&acct.get_value()));
if !query.is_empty() {
url.push('&');
url.push_str(&query);
}
if let Some(seq) = older {
url.push_str(&format!("&before={seq}"));
}
async move { crate::api::get::<Page>(&url).await }
});
let apply = move |_| {
before.set(None);
asked.set(filter_query(&kinds.get_untracked(), &actor.get_untracked(), &text.get_untracked()));
};
view! {
<Panel title="Events">
<Hint line="Everything security-relevant in your account, newest first."
more="Logins, passkeys and keys, invites, members, public or private, repositories, branch protection, the operator's looks, room, names the filter stopped. Each entry names the one before it (a hash chain), so an edited or removed entry shows. Only your account's admins read this; the operator does not."/>
<div class="form-row">
<label class="field">"Kinds"
<input id="audit-kinds" placeholder="login, visibility" prop:value=move || kinds.get()
on:input=move |ev| kinds.set(event_target_value(&ev))/>
</label>
<label class="field">"Who"
<input id="audit-actor" placeholder="bob" prop:value=move || actor.get()
on:input=move |ev| actor.set(event_target_value(&ev))/>
</label>
<label class="field">"Text"
<input id="audit-text" placeholder="acme/site" prop:value=move || text.get()
on:input=move |ev| text.set(event_target_value(&ev))/>
</label>
<button class="btn" id="audit-apply" on:click=apply>"Show"</button>
<a class="btn" id="audit-export-csv" download=""
href=move || export_href(&acct.get_value(), "csv", &asked.get())>"Export CSV"</a>
<a class="btn" id="audit-export-json" download=""
href=move || export_href(&acct.get_value(), "json", &asked.get())>"Export JSON"</a>
</div>
{move || match page.get() {
None => view! { <Loading/> }.into_any(),
Some(Err(fail)) => view! { <div id="audit-refused"><FailBox fail=fail/></div> }.into_any(),
Some(Ok(p)) => {
let line = p.chain.as_ref().map(|c| chain_line(c, p.retention_days));
let holds = p.chain.as_ref().map(|c| c.holds).unwrap_or(true);
let next = p.next_before;
view! {
{line.map(|l| view! { <p class="hint" id="audit-chain" data-holds=holds.to_string()>{l}</p> })}
<div class="table-wrap"><table id="audit-entries">
<thead><tr>
<th scope="col">"When"</th><th scope="col">"What"</th><th scope="col">"Who"</th>
<th scope="col">"On"</th><th scope="col">"Detail"</th><th scope="col">"Outcome"</th>
</tr></thead>
<tbody>
{p.entries.iter().map(|e| view! {
<tr data-kind=e.kind.clone() data-outcome=e.outcome.clone() data-seq=e.seq.to_string()>
<td class="mono" title=e.hash.clone()>{crate::fmt::date(e.at)}</td>
<td>{kind_words(&e.kind)}</td>
<td>{who(e)}</td>
<td class="mono">{e.target.clone()}</td>
<td>{e.detail.clone()}</td>
<td>{e.outcome.clone()}</td>
</tr>
}).collect_view()}
</tbody>
</table></div>
{next.map(|seq| view! {
<p><button class="btn" id="audit-older" on:click=move |_| before.set(Some(seq))>"Older"</button></p>
})}
}.into_any()
}
}}
</Panel>
}
}
#[component]
fn OperatorLooks(account: String) -> impl IntoView {
let acct = StoredValue::new(account);
let looks = LocalResource::new(move || {
let url = format!("/api/account/operator-access?account={}", crate::auth::percent_encode(&acct.get_value()));
async move { crate::api::get::<Looks>(&url).await }
});
let failed = RwSignal::new(None::<crate::api::Fail>);
let act = move |path: String, body: Option<serde_json::Value>| {
leptos::task::spawn_local(async move {
let answered = match body {
Some(body) => crate::api::post::<_, serde_json::Value>(&path, &body).await,
None => crate::api::post_empty::<serde_json::Value>(&path).await,
};
match answered {
Ok(_) => {
failed.set(None);
looks.refetch();
}
Err(fail) => failed.set(Some(fail)),
}
});
};
let approve = move |id: String, code: String| {
leptos::task::spawn_local(async move {
let path = format!("/api/account/operator-access/{}/approve", crate::api::segment(&id));
let touched = async {
let options = crate::api::get::<serde_json::Value>("/api/account/step-up").await?;
let touch = crate::enterprise::touch(&options).await.map_err(|message| crate::api::Fail {
status: 0,
code: "aborted".to_owned(),
message,
})?;
crate::api::post::<_, serde_json::Value>(&path, &crate::enterprise::approval_body(&code, 0, &touch)).await
};
match touched.await {
Ok(_) => {
failed.set(None);
looks.refetch();
}
Err(fail) => failed.set(Some(fail)),
}
});
};
view! {
<Panel title="The operator's access">
<Hint line="The operator sees your account's totals only, unless you approve a look here."
more="A look lets the operator READ inside your account until it ends; it never writes. Approving asks for a fresh touch of your own passkey. Compare the code with the one the operator gives you. You can end a look at any time; every step is in the log above."/>
{move || match looks.get() {
None => view! { <Loading/> }.into_any(),
Some(Err(fail)) => view! { <FailBox fail=fail/> }.into_any(),
Some(Ok(l)) if l.requests.is_empty() => view! { <p class="hint" id="looks-none">"No look has been asked for."</p> }.into_any(),
Some(Ok(l)) => view! {
<ul id="looks">
{l.requests.iter().map(|r| {
let (id, code) = (r.id.clone(), r.code.clone());
let (id2, id3) = (r.id.clone(), r.id.clone());
let state = r.state.clone();
view! {
<li data-look=r.id.clone() data-state=state.clone()>
<span>{look_line(r)}</span>" "
<span class="hint">{r.reason.clone()}</span>" "
{(state == "pending").then(|| view! {
<button class="btn primary look-approve" on:click=move |_| approve(id.clone(), code.clone())>"Approve"</button>" "
<button class="btn look-refuse" on:click=move |_| act(format!("/api/account/operator-access/{}/refuse", crate::api::segment(&id2)), None)>"Refuse"</button>
})}
{(state == "approved").then(|| view! {
<button class="btn look-end" on:click=move |_| act(format!("/api/account/operator-access/{}/end", crate::api::segment(&id3)), None)>"End now"</button>
})}
</li>
}
}).collect_view()}
</ul>
}.into_any(),
}}
{move || failed.get().map(|fail| view! { <FailBox fail=fail/> })}
</Panel>
}
}
#[component]
fn HeldNames(account: String) -> impl IntoView {
let acct = StoredValue::new(account);
let reviews = LocalResource::new(move || {
let url = format!("/api/account/names?account={}", crate::auth::percent_encode(&acct.get_value()));
async move { crate::api::get::<Reviews>(&url).await }
});
let failed = RwSignal::new(None::<crate::api::Fail>);
let decide = move |id: String, allow: bool| {
let path = format!("/api/account/names/{}", crate::api::segment(&id));
let body = serde_json::json!({ "account": acct.get_value(), "allow": allow });
leptos::task::spawn_local(async move {
match crate::api::post::<_, serde_json::Value>(&path, &body).await {
Ok(_) => {
failed.set(None);
reviews.refetch();
}
Err(fail) => failed.set(Some(fail)),
}
});
};
view! {
<Panel title="Names">
<Hint line="Names the filter stopped in your account."
more="A refused name is shown so you know it was tried. A held name waits for you: allow it and it can be used in your account from then on, or refuse it. The lists are the server's."/>
{move || match reviews.get() {
None => view! { <Loading/> }.into_any(),
Some(Err(fail)) => view! { <FailBox fail=fail/> }.into_any(),
Some(Ok(r)) if r.reviews.is_empty() => view! { <p class="hint" id="names-none">"No name has been stopped."</p> }.into_any(),
Some(Ok(r)) => view! {
<ul id="names" data-pending=r.pending.to_string()>
{r.reviews.iter().map(|n| {
let (allow_id, refuse_id) = (n.id.clone(), n.id.clone());
let pending = n.state == "pending";
view! {
<li data-name=n.name.clone() data-state=n.state.clone()>
<span class="mono">{n.name.clone()}</span>" "
<span>{format!("{} {}, by {} — {}", n.kind, n.verdict, n.actor, n.state)}</span>" "
{pending.then(|| view! {
<button class="btn name-allow" on:click=move |_| decide(allow_id.clone(), true)>"Allow"</button>" "
<button class="btn name-refuse" on:click=move |_| decide(refuse_id.clone(), false)>"Refuse"</button>
})}
</li>
}
}).collect_view()}
</ul>
}.into_any(),
}}
{move || failed.get().map(|fail| view! { <FailBox fail=fail/> })}
</Panel>
}
}