feat(dashboard): UI for managing MCP tokens
CI / Check (pull_request) Successful in 8m8s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
CI / Check (pull_request) Successful in 8m8s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
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 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,8 @@ pub enum Route {
|
|||||||
PentestSessionPage { session_id: String },
|
PentestSessionPage { session_id: String },
|
||||||
#[route("/mcp-servers")]
|
#[route("/mcp-servers")]
|
||||||
McpServersPage {},
|
McpServersPage {},
|
||||||
|
#[route("/mcp-tokens")]
|
||||||
|
McpTokensPage {},
|
||||||
}
|
}
|
||||||
|
|
||||||
const FAVICON: Asset = asset!("/assets/favicon.svg");
|
const FAVICON: Asset = asset!("/assets/favicon.svg");
|
||||||
|
|||||||
@@ -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_json::Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub revoked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct McpTokensListResponse {
|
||||||
|
pub data: Vec<McpTokenView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<McpTokensListResponse, ServerFnError> {
|
||||||
|
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<CreateMcpTokenResponse, ServerFnError> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ pub mod graph;
|
|||||||
pub mod help_chat;
|
pub mod help_chat;
|
||||||
pub mod issues;
|
pub mod issues;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
|
pub mod mcp_tokens;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod pentest;
|
pub mod pentest;
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
|||||||
@@ -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::<Toasts>();
|
||||||
|
|
||||||
|
// 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<Option<CreateMcpTokenResponse>> = use_signal(|| None);
|
||||||
|
|
||||||
|
// Revoke confirmation: (id, name)
|
||||||
|
let mut confirm_revoke: Signal<Option<(String, String)>> = 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::<i64>().ok())
|
||||||
|
{
|
||||||
|
return chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms)
|
||||||
|
.map(|d| d.format("%Y-%m-%d %H:%M").to_string())
|
||||||
|
.unwrap_or_else(|| ms.to_string());
|
||||||
|
}
|
||||||
|
"—".to_string()
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ pub mod graph_index;
|
|||||||
pub mod impact_analysis;
|
pub mod impact_analysis;
|
||||||
pub mod issues;
|
pub mod issues;
|
||||||
pub mod mcp_servers;
|
pub mod mcp_servers;
|
||||||
|
pub mod mcp_tokens;
|
||||||
pub mod overview;
|
pub mod overview;
|
||||||
pub mod pentest_dashboard;
|
pub mod pentest_dashboard;
|
||||||
pub mod pentest_session;
|
pub mod pentest_session;
|
||||||
@@ -30,6 +31,7 @@ pub use graph_index::GraphIndexPage;
|
|||||||
pub use impact_analysis::ImpactAnalysisPage;
|
pub use impact_analysis::ImpactAnalysisPage;
|
||||||
pub use issues::IssuesPage;
|
pub use issues::IssuesPage;
|
||||||
pub use mcp_servers::McpServersPage;
|
pub use mcp_servers::McpServersPage;
|
||||||
|
pub use mcp_tokens::McpTokensPage;
|
||||||
pub use overview::OverviewPage;
|
pub use overview::OverviewPage;
|
||||||
pub use pentest_dashboard::PentestDashboardPage;
|
pub use pentest_dashboard::PentestDashboardPage;
|
||||||
pub use pentest_session::PentestSessionPage;
|
pub use pentest_session::PentestSessionPage;
|
||||||
|
|||||||
Reference in New Issue
Block a user