Compare commits
3 Commits
eb88656c43
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| aed551231c | |||
| b851f4267a | |||
| e9536b6d98 |
@@ -7,4 +7,17 @@ ignore = [
|
||||
# not a realistic attack surface here. Revisit when mongodb bumps hickory.
|
||||
"RUSTSEC-2026-0118", # NSEC3 loop, no fix available upstream
|
||||
"RUSTSEC-2026-0119", # O(n²) name compression, fixed in hickory-proto >=0.26.1
|
||||
|
||||
# rmcp 0.16.0 — DNS rebinding in Streamable HTTP server transport (missing
|
||||
# Host header validation). Patched in rmcp >= 1.4.0, which is a major API
|
||||
# version jump from our pin; rmcp shipped 0.x → 1.x → 2.x in three months
|
||||
# and the migration touches every tool handler + the auth middleware we
|
||||
# just landed in #92. Threat model in our deployment: the MCP server is
|
||||
# exposed at a public hostname (comp-mcp-dev.meghsakha.com) behind orca's
|
||||
# TLS-terminating ingress with per-tenant bearer auth — the attack model
|
||||
# (browser DNS-rebinding into localhost MCP server) doesn't directly apply.
|
||||
# Defense-in-depth Host-header check is still a worthwhile follow-up.
|
||||
# FOLLOW-UP: bump rmcp to 2.x in a dedicated PR (M7.3 follow-up, sized
|
||||
# multi-hour due to API surface change).
|
||||
"RUSTSEC-2026-0189",
|
||||
]
|
||||
|
||||
Generated
+2
-2
@@ -4282,9 +4282,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.14"
|
||||
version = "0.11.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
|
||||
@@ -7,11 +7,18 @@ use crate::agent::ComplianceAgent;
|
||||
use crate::database::Database;
|
||||
use crate::error::AgentError;
|
||||
|
||||
/// Default tenant the scheduler runs against when `SCHEDULER_TENANT_IDS`
|
||||
/// isn't set. Matches the dev-injector default so a bare `cargo run` has
|
||||
/// the scheduler scanning whatever lives in `<prefix>_dev`.
|
||||
/// Default tenant the scheduler runs against when neither the tenant
|
||||
/// registry nor `SCHEDULER_TENANT_IDS` are configured. Matches the
|
||||
/// dev-injector default so a bare `cargo run` has the scheduler
|
||||
/// scanning whatever lives in `<prefix>_dev`.
|
||||
const DEFAULT_SCHEDULER_TENANT_ID: &str = "dev";
|
||||
|
||||
/// Request timeout when fetching the live tenant list from the
|
||||
/// registry. Kept short — if the registry is slow we'd rather fall
|
||||
/// back to env-configured ids and finish the tick than block the
|
||||
/// scheduler loop.
|
||||
const REGISTRY_FETCH_TIMEOUT_SECS: u64 = 5;
|
||||
|
||||
pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError> {
|
||||
let sched = JobScheduler::new()
|
||||
.await
|
||||
@@ -24,7 +31,12 @@ pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError>
|
||||
let agent = scan_agent.clone();
|
||||
Box::pin(async move {
|
||||
tracing::info!("Scheduled scan triggered");
|
||||
for tenant_id in scheduler_tenants() {
|
||||
let tenants = scheduler_tenants(&agent).await;
|
||||
tracing::debug!(
|
||||
tenant_count = tenants.len(),
|
||||
"Scheduled scan: tenants resolved"
|
||||
);
|
||||
for tenant_id in tenants {
|
||||
scan_all_repos(&agent, &tenant_id).await;
|
||||
}
|
||||
})
|
||||
@@ -42,7 +54,12 @@ pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError>
|
||||
let agent = cve_agent.clone();
|
||||
Box::pin(async move {
|
||||
tracing::info!("CVE monitor triggered");
|
||||
for tenant_id in scheduler_tenants() {
|
||||
let tenants = scheduler_tenants(&agent).await;
|
||||
tracing::debug!(
|
||||
tenant_count = tenants.len(),
|
||||
"CVE monitor: tenants resolved"
|
||||
);
|
||||
for tenant_id in tenants {
|
||||
monitor_cves(&agent, &tenant_id).await;
|
||||
}
|
||||
})
|
||||
@@ -58,9 +75,14 @@ pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError>
|
||||
.await
|
||||
.map_err(|e| AgentError::Scheduler(format!("Failed to start scheduler: {e}")))?;
|
||||
|
||||
let tenants = scheduler_tenants();
|
||||
let tenants = scheduler_tenants(agent).await;
|
||||
let source = if agent.config.tenant_registry_url.is_some() {
|
||||
"tenant-registry (env fallback)"
|
||||
} else {
|
||||
"env (SCHEDULER_TENANT_IDS)"
|
||||
};
|
||||
tracing::info!(
|
||||
"Scheduler started: scans='{}', CVE monitor='{}', tenants={tenants:?}",
|
||||
"Scheduler started: scans='{}', CVE monitor='{}', tenant source={source}, tenants={tenants:?}",
|
||||
agent.config.scan_schedule,
|
||||
agent.config.cve_monitor_schedule,
|
||||
);
|
||||
@@ -71,10 +93,40 @@ pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError>
|
||||
}
|
||||
}
|
||||
|
||||
/// Tenants the scheduler iterates each tick. From `SCHEDULER_TENANT_IDS`
|
||||
/// (comma-separated), or `DEFAULT_SCHEDULER_TENANT_ID` if unset. M7.2-D
|
||||
/// will replace this with a pull from the tenant-registry.
|
||||
fn scheduler_tenants() -> Vec<String> {
|
||||
/// Tenants the scheduler iterates each tick.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. **Tenant registry** at `agent.config.tenant_registry_url`
|
||||
/// (`GET /v1/tenants`). Fresh on every tick — picks up newly
|
||||
/// provisioned tenants without an agent restart.
|
||||
/// 2. **`SCHEDULER_TENANT_IDS`** env (comma-separated) — fallback when
|
||||
/// the registry is unreachable, the response is malformed, or no
|
||||
/// registry URL is configured.
|
||||
/// 3. **`DEFAULT_SCHEDULER_TENANT_ID`** (`"dev"`) — last-ditch fallback
|
||||
/// so the scheduler keeps doing something useful in dev.
|
||||
///
|
||||
/// We never panic out of this function — the scheduler must keep
|
||||
/// firing even if the registry is offline.
|
||||
async fn scheduler_tenants(agent: &ComplianceAgent) -> Vec<String> {
|
||||
if let Some(url) = agent.config.tenant_registry_url.as_deref() {
|
||||
match fetch_tenants_from_registry(&agent.http, url).await {
|
||||
Ok(v) if !v.is_empty() => return v,
|
||||
Ok(_) => {
|
||||
tracing::warn!("tenant-registry returned empty list; falling back to env");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
url = %url,
|
||||
error = %e,
|
||||
"tenant-registry fetch failed; falling back to env"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
tenants_from_env()
|
||||
}
|
||||
|
||||
fn tenants_from_env() -> Vec<String> {
|
||||
std::env::var("SCHEDULER_TENANT_IDS")
|
||||
.ok()
|
||||
.map(|s| {
|
||||
@@ -88,6 +140,134 @@ fn scheduler_tenants() -> Vec<String> {
|
||||
.unwrap_or_else(|| vec![DEFAULT_SCHEDULER_TENANT_ID.to_string()])
|
||||
}
|
||||
|
||||
/// Shape we accept from the registry. Liberal in what we accept:
|
||||
/// the registry can return any field shape as long as either `id` or
|
||||
/// `tenant_id` is present. Other fields are ignored.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct RegistryTenant {
|
||||
#[serde(alias = "tenant_id")]
|
||||
id: String,
|
||||
/// Filter out non-running tenants if status is present. Missing
|
||||
/// status defaults to "active" so older registry deployments keep
|
||||
/// working.
|
||||
#[serde(default = "default_status")]
|
||||
status: String,
|
||||
}
|
||||
|
||||
fn default_status() -> String {
|
||||
"active".to_string()
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct RegistryListResponse {
|
||||
data: Vec<RegistryTenant>,
|
||||
}
|
||||
|
||||
async fn fetch_tenants_from_registry(
|
||||
http: &reqwest::Client,
|
||||
base_url: &str,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let url = format!("{}/v1/tenants", base_url.trim_end_matches('/'));
|
||||
let resp = http
|
||||
.get(&url)
|
||||
.timeout(std::time::Duration::from_secs(REGISTRY_FETCH_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("request failed: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("registry returned {}", resp.status()));
|
||||
}
|
||||
let body: RegistryListResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("invalid JSON: {e}"))?;
|
||||
Ok(filter_active(body.data))
|
||||
}
|
||||
|
||||
/// Frozen/Archived tenants don't need scheduled scans; the M7.1
|
||||
/// status gate would 402/410 anyway. Skip them so we don't waste
|
||||
/// cycles. Active / trial / demo / anything-else-unknown all run.
|
||||
fn filter_active(rows: Vec<RegistryTenant>) -> Vec<String> {
|
||||
rows.into_iter()
|
||||
.filter(|t| !matches!(t.status.as_str(), "frozen" | "archived"))
|
||||
.map(|t| t.id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tenant(id: &str, status: &str) -> RegistryTenant {
|
||||
RegistryTenant {
|
||||
id: id.to_string(),
|
||||
status: status.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_active_keeps_running_skips_frozen_archived() {
|
||||
let rows = vec![
|
||||
tenant("a", "active"),
|
||||
tenant("b", "trial"),
|
||||
tenant("c", "demo"),
|
||||
tenant("d", "frozen"),
|
||||
tenant("e", "archived"),
|
||||
tenant("f", "weird-but-not-known-dead"),
|
||||
];
|
||||
let out = filter_active(rows);
|
||||
assert_eq!(out, vec!["a", "b", "c", "f"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_registry_response_accepts_id_or_tenant_id() {
|
||||
let body = r#"{"data":[
|
||||
{"id":"a","status":"active"},
|
||||
{"tenant_id":"b","status":"trial"},
|
||||
{"id":"c"}
|
||||
]}"#;
|
||||
let parsed: RegistryListResponse = serde_json::from_str(body).unwrap();
|
||||
assert_eq!(parsed.data.len(), 3);
|
||||
assert_eq!(parsed.data[0].id, "a");
|
||||
assert_eq!(parsed.data[1].id, "b");
|
||||
assert_eq!(parsed.data[2].id, "c");
|
||||
// Default status for the third entry should be "active"
|
||||
assert_eq!(parsed.data[2].status, "active");
|
||||
}
|
||||
|
||||
/// Combined into a single test: cargo runs tests in parallel and
|
||||
/// env vars are process-global, so two separate tests touching
|
||||
/// `SCHEDULER_TENANT_IDS` race each other. Doing both checks in
|
||||
/// one test keeps them in a deterministic order.
|
||||
#[test]
|
||||
fn tenants_from_env_resolution() {
|
||||
std::env::remove_var("SCHEDULER_TENANT_IDS");
|
||||
assert_eq!(
|
||||
tenants_from_env(),
|
||||
vec![DEFAULT_SCHEDULER_TENANT_ID.to_string()],
|
||||
"unset → default"
|
||||
);
|
||||
|
||||
std::env::set_var("SCHEDULER_TENANT_IDS", "acme, globex ,,hello");
|
||||
let out = tenants_from_env();
|
||||
std::env::remove_var("SCHEDULER_TENANT_IDS");
|
||||
assert_eq!(
|
||||
out,
|
||||
vec!["acme", "globex", "hello"],
|
||||
"splits + trims + drops empty"
|
||||
);
|
||||
|
||||
std::env::set_var("SCHEDULER_TENANT_IDS", "");
|
||||
let out = tenants_from_env();
|
||||
std::env::remove_var("SCHEDULER_TENANT_IDS");
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![DEFAULT_SCHEDULER_TENANT_ID.to_string()],
|
||||
"empty → default"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the per-tenant database. Logs and returns `None` on failure
|
||||
/// so the loop in the caller can continue with other tenants.
|
||||
async fn tenant_db(agent: &ComplianceAgent, tenant_id: &str) -> Option<Database> {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 issues;
|
||||
pub mod mcp;
|
||||
pub mod mcp_tokens;
|
||||
pub mod notifications;
|
||||
pub mod pentest;
|
||||
#[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 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;
|
||||
|
||||
Reference in New Issue
Block a user