From b3a8a97729c4d59bd9eceb920f76b34fdc24379e Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:53:59 +0200 Subject: [PATCH] feat(dashboard): UI for managing MCP tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /mcp-tokens page that lets a logged-in user mint, list, and revoke bearer tokens for the MCP server. Stacks on #92 (which added the agent endpoints + middleware) — once both land, the loop is closed: a user can copy a token from the dashboard straight into their Claude Desktop / Cursor / ChatGPT MCP config. UX - "Create Token" button → inline form with name input. - On submit, server function calls `POST /api/v1/mcp-tokens`. The raw token is shown ONCE in a prominent yellow banner with a copy button and a "won't be shown again" warning, then the user dismisses it manually. - List view: card per token with name, prefix `mcpt_xxxx…`, created date, last_used (or "never"). Revoked tokens render dimmed with a "revoked" pill. Active tokens have a trash button → confirm modal → soft delete. - Toast feedback on create/revoke success/failure. Files - infrastructure/mcp_tokens.rs (new) — three #[server] functions: fetch_mcp_tokens, create_mcp_token, revoke_mcp_token. All go through agent_client so the Keycloak Bearer is auto-attached; the agent then enforces tenant scoping on every endpoint. - pages/mcp_tokens.rs (new) — the page component itself. - app.rs — adds Route::McpTokensPage at /mcp-tokens. - pages/mod.rs, infrastructure/mod.rs — module + re-export wiring. Timestamp format - The agent serializes BSON DateTime as extended JSON `{"$date":{"$numberLong":"..."}}`. Page has a small helper that accepts that shape, plain ISO strings, or anything else (best-effort). Same approach used elsewhere in the dashboard so there's no new dependency. Test plan - cargo fmt --all clean - cargo clippy -p compliance-dashboard --features server -- -D warnings clean - cargo clippy -p compliance-dashboard --features web --no-default-features -- -D warnings clean - cargo check on both feature sets clean Followup - No sidebar entry yet (matches mcp_servers — settings-style pages are reached via direct URL today). Worth adding a Settings sub-menu in a separate UX pass. - Token expiry + per-tool scope when those land on the agent side will need a small UI for the create modal (extra fields). Co-Authored-By: Claude Opus 4.7 --- compliance-dashboard/src/app.rs | 2 + .../src/infrastructure/mcp_tokens.rs | 90 ++++++ .../src/infrastructure/mod.rs | 1 + compliance-dashboard/src/pages/mcp_tokens.rs | 271 ++++++++++++++++++ compliance-dashboard/src/pages/mod.rs | 2 + 5 files changed, 366 insertions(+) create mode 100644 compliance-dashboard/src/infrastructure/mcp_tokens.rs create mode 100644 compliance-dashboard/src/pages/mcp_tokens.rs diff --git a/compliance-dashboard/src/app.rs b/compliance-dashboard/src/app.rs index cb276b5..8b7cdd3 100644 --- a/compliance-dashboard/src/app.rs +++ b/compliance-dashboard/src/app.rs @@ -44,6 +44,8 @@ pub enum Route { PentestSessionPage { session_id: String }, #[route("/mcp-servers")] McpServersPage {}, + #[route("/mcp-tokens")] + McpTokensPage {}, } const FAVICON: Asset = asset!("/assets/favicon.svg"); diff --git a/compliance-dashboard/src/infrastructure/mcp_tokens.rs b/compliance-dashboard/src/infrastructure/mcp_tokens.rs new file mode 100644 index 0000000..742731e --- /dev/null +++ b/compliance-dashboard/src/infrastructure/mcp_tokens.rs @@ -0,0 +1,90 @@ +//! Server-functions for the MCP-tokens management UI. +//! +//! These wrap the agent's `/api/v1/mcp-tokens` CRUD endpoints. The raw +//! token returned by `create_mcp_token` is only visible at creation +//! time — the agent's storage never holds the plaintext. + +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct McpTokenView { + pub id: String, + pub name: String, + pub token_prefix: String, + pub created_by: String, + pub created_at: serde_json::Value, + #[serde(default)] + pub last_used_at: Option, + #[serde(default)] + pub revoked: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct McpTokensListResponse { + pub data: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CreateMcpTokenResponse { + /// Raw token. Shown ONCE — the user must copy it now. + pub token: String, + pub view: McpTokenView, +} + +#[server] +pub async fn fetch_mcp_tokens() -> Result { + let resp = super::agent_client::agent_get("/api/v1/mcp-tokens") + .await? + .send() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + let body: McpTokensListResponse = resp + .json() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + Ok(body) +} + +#[server] +pub async fn create_mcp_token(name: String) -> Result { + if name.trim().is_empty() { + return Err(ServerFnError::new("Name is required")); + } + let resp = super::agent_client::agent_request(reqwest::Method::POST, "/api/v1/mcp-tokens") + .await? + .json(&serde_json::json!({ "name": name.trim() })) + .send() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(ServerFnError::new(format!( + "Failed to create token: {body}" + ))); + } + let body: CreateMcpTokenResponse = resp + .json() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + Ok(body) +} + +#[server] +pub async fn revoke_mcp_token(id: String) -> Result<(), ServerFnError> { + let resp = super::agent_client::agent_request( + reqwest::Method::DELETE, + &format!("/api/v1/mcp-tokens/{id}"), + ) + .await? + .send() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(ServerFnError::new(format!( + "Failed to revoke token: {body}" + ))); + } + Ok(()) +} diff --git a/compliance-dashboard/src/infrastructure/mod.rs b/compliance-dashboard/src/infrastructure/mod.rs index 4660eef..dd8066b 100644 --- a/compliance-dashboard/src/infrastructure/mod.rs +++ b/compliance-dashboard/src/infrastructure/mod.rs @@ -8,6 +8,7 @@ pub mod graph; pub mod help_chat; pub mod issues; pub mod mcp; +pub mod mcp_tokens; pub mod notifications; pub mod pentest; #[allow(clippy::too_many_arguments)] diff --git a/compliance-dashboard/src/pages/mcp_tokens.rs b/compliance-dashboard/src/pages/mcp_tokens.rs new file mode 100644 index 0000000..4c44209 --- /dev/null +++ b/compliance-dashboard/src/pages/mcp_tokens.rs @@ -0,0 +1,271 @@ +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() +} diff --git a/compliance-dashboard/src/pages/mod.rs b/compliance-dashboard/src/pages/mod.rs index c54217a..1df2d9e 100644 --- a/compliance-dashboard/src/pages/mod.rs +++ b/compliance-dashboard/src/pages/mod.rs @@ -11,6 +11,7 @@ pub mod graph_index; pub mod impact_analysis; pub mod issues; pub mod mcp_servers; +pub mod mcp_tokens; pub mod overview; pub mod pentest_dashboard; pub mod pentest_session; @@ -30,6 +31,7 @@ pub use graph_index::GraphIndexPage; pub use impact_analysis::ImpactAnalysisPage; pub use issues::IssuesPage; pub use mcp_servers::McpServersPage; +pub use mcp_tokens::McpTokensPage; pub use overview::OverviewPage; pub use pentest_dashboard::PentestDashboardPage; pub use pentest_session::PentestSessionPage; -- 2.52.0