use dioxus::prelude::*; use dioxus_free_icons::icons::bs_icons::*; use dioxus_free_icons::Icon; use crate::components::page_header::PageHeader; use crate::components::toast::{ToastType, Toasts}; use crate::infrastructure::mcp_tokens::{ create_mcp_token, fetch_mcp_tokens, revoke_mcp_token, CreateMcpTokenResponse, }; #[component] pub fn McpTokensPage() -> Element { let mut tokens = use_resource(|| async { fetch_mcp_tokens().await.ok() }); let mut toasts = use_context::(); // Create-form state let mut show_form = use_signal(|| false); let mut new_name = use_signal(String::new); let mut submitting = use_signal(|| false); // After creation, the raw token shows once in a banner let mut just_created: Signal> = use_signal(|| None); // Revoke confirmation: (id, name) let mut confirm_revoke: Signal> = use_signal(|| None); rsx! { PageHeader { title: "MCP Tokens", description: "Static bearer tokens for the MCP server. Use in your LLM client (Claude Desktop, Cursor, etc.) — one token per tool/device.", } // ── Just-created banner ──────────────────────────────────── if let Some(resp) = just_created() { div { class: "card mb-4", style: "border: 1px solid var(--accent-warning); background: var(--bg-warning-subtle);", div { class: "card-header", style: "color: var(--accent-warning);", Icon { icon: BsExclamationTriangle, width: 14, height: 14 } " Copy this token now — it won't be shown again" } div { style: "padding: 1rem;", p { style: "margin-bottom: 0.5rem; color: var(--text-secondary);", "Token for " strong { "{resp.view.name}" } } div { class: "copyable", style: "background: var(--bg-secondary); padding: 0.75rem; border-radius: 4px;", code { style: "font-family: var(--font-mono); word-break: break-all; flex: 1;", "{resp.token}" } crate::components::copy_button::CopyButton { value: resp.token.clone(), small: false } } div { style: "margin-top: 0.75rem;", button { class: "btn btn-sm btn-ghost", onclick: move |_| just_created.set(None), "Dismiss" } } } } } // ── Create form ──────────────────────────────────────────── div { class: "mb-4", button { class: "btn btn-primary", onclick: move |_| { show_form.set(!show_form()); new_name.set(String::new()); }, if show_form() { "Cancel" } else { Icon { icon: BsPlusLg, width: 14, height: 14 } " Create Token" } } } if show_form() { div { class: "card mb-4", div { class: "card-header", "New MCP Token" } div { style: "padding: 1rem;", div { class: "form-group", label { "Name" } input { r#type: "text", placeholder: "Claude Desktop on my laptop", value: "{new_name}", oninput: move |e| new_name.set(e.value()), } small { style: "color: var(--text-secondary);", "A label so you can identify this token in the list. Not visible to LLM clients." } } div { style: "margin-top: 1rem;", button { class: "btn btn-primary", disabled: submitting() || new_name().trim().is_empty(), onclick: move |_| { let name = new_name().trim().to_string(); if name.is_empty() { return; } spawn(async move { submitting.set(true); match create_mcp_token(name).await { Ok(resp) => { toasts.push(ToastType::Success, "Token created. Copy it now — it won't be shown again."); just_created.set(Some(resp)); show_form.set(false); new_name.set(String::new()); tokens.restart(); } Err(e) => { toasts.push(ToastType::Error, format!("Failed to create token: {e}")); } } submitting.set(false); }); }, if submitting() { "Creating..." } else { "Create" } } } } } } // ── Tokens list ──────────────────────────────────────────── match &*tokens.read() { Some(Some(resp)) => { if resp.data.is_empty() { rsx! { div { class: "card", p { style: "padding: 1rem; color: var(--text-secondary);", "No MCP tokens yet. Create one to start using the MCP server from an LLM client." } } } } else { rsx! { div { class: "mcp-cards-grid", for token in resp.data.iter() { { let id = token.id.clone(); let name = token.name.clone(); let prefix = token.token_prefix.clone(); let created_str = format_timestamp(&token.created_at); let last_used_str = token .last_used_at .as_ref() .map(format_timestamp) .unwrap_or_else(|| "never".to_string()); let revoked = token.revoked; rsx! { div { class: "mcp-card", style: if revoked { "opacity: 0.55;" } else { "" }, div { class: "mcp-card-header", div { class: "mcp-card-title", Icon { icon: BsKey, width: 14, height: 14 } h3 { "{name}" } if revoked { span { class: "mcp-card-status stopped", "revoked" } } } if !revoked { button { class: "btn btn-sm btn-ghost btn-ghost-danger", title: "Revoke token", onclick: { let id = id.clone(); let name = name.clone(); move |_| { confirm_revoke.set(Some((id.clone(), name.clone()))); } }, Icon { icon: BsTrash, width: 14, height: 14 } } } } div { class: "mcp-card-details", div { class: "mcp-detail-row", Icon { icon: BsKey, width: 13, height: 13 } span { class: "mcp-detail-label", "Prefix" } code { class: "mcp-detail-value", "{prefix}…" } } div { class: "mcp-detail-row", Icon { icon: BsCalendar, width: 13, height: 13 } span { class: "mcp-detail-label", "Created" } span { class: "mcp-detail-value", "{created_str}" } } div { class: "mcp-detail-row", Icon { icon: BsClockHistory, width: 13, height: 13 } span { class: "mcp-detail-label", "Last used" } span { class: "mcp-detail-value", "{last_used_str}" } } } } } } } } } } } Some(None) => rsx! { div { class: "card", p { style: "padding: 1rem; color: var(--accent-danger);", "Failed to load MCP tokens." } } }, None => rsx! { div { class: "card", p { style: "padding: 1rem; color: var(--text-secondary);", "Loading..." } } }, } // ── Revoke confirmation modal ────────────────────────────── if let Some((id, name)) = confirm_revoke() { div { class: "modal-overlay", div { class: "modal", h3 { "Revoke token?" } p { "The token " strong { "{name}" } " will stop working immediately. This cannot be undone. Any LLM client using it will start getting 401." } div { style: "display: flex; gap: 0.5rem; margin-top: 1rem; justify-content: flex-end;", button { class: "btn btn-ghost", onclick: move |_| confirm_revoke.set(None), "Cancel" } button { class: "btn btn-danger", onclick: { let id = id.clone(); move |_| { let id = id.clone(); spawn(async move { match revoke_mcp_token(id).await { Ok(()) => { toasts.push(ToastType::Success, "Token revoked"); tokens.restart(); } Err(e) => { toasts.push(ToastType::Error, format!("Failed to revoke: {e}")); } } confirm_revoke.set(None); }); } }, "Revoke" } } } } } } } /// Best-effort timestamp formatter. The agent serializes BSON DateTime /// as `{"$date":{"$numberLong":"..."}}` in extended JSON. We accept /// that shape, plain ISO strings, or anything else (best-effort). fn format_timestamp(v: &serde_json::Value) -> String { if let Some(s) = v.as_str() { return s.to_string(); } if let Some(ms) = v .get("$date") .and_then(|d| d.get("$numberLong")) .and_then(|s| s.as_str()) .and_then(|s| s.parse::().ok()) { return chrono::DateTime::::from_timestamp_millis(ms) .map(|d| d.format("%Y-%m-%d %H:%M").to_string()) .unwrap_or_else(|| ms.to_string()); } "—".to_string() }