Stage 3: Admin-UI (maud + htmx) -- Config maskiert anzeigen/aendern, Live-Reload
CI / test (push) Successful in 11m47s

This commit is contained in:
2026-06-23 21:01:01 +00:00
parent f8acc3ad36
commit fab77775ff
+185
View File
@@ -0,0 +1,185 @@
use std::path::PathBuf;
use std::sync::Arc;
use axum::{
extract::{Request, State},
http::{
header::{AUTHORIZATION, WWW_AUTHENTICATE},
HeaderValue, StatusCode,
},
middleware::Next,
response::{Html, IntoResponse, Response},
routing::{get, post},
Form, Router,
};
use base64::Engine;
use dtrack_core::{DtrackClient, DtrackConfig};
use dtrack_tools::SharedClient;
use maud::{html, Markup, PreEscaped, DOCTYPE};
use serde::Deserialize;
use tokio::sync::RwLock;
/// Geteilter Zustand der Admin-Routen.
#[derive(Clone)]
pub struct AppState {
pub config: Arc<RwLock<DtrackConfig>>,
pub client: SharedClient,
pub config_path: Option<PathBuf>,
}
/// Basic-Auth-Credentials fuer /admin. `None` = Admin-UI deaktiviert.
#[derive(Clone)]
pub struct AdminAuth(pub Option<Arc<(String, String)>>);
impl AdminAuth {
pub fn from_env() -> Self {
match (
std::env::var("DTRACK_ADMIN_USER"),
std::env::var("DTRACK_ADMIN_PASSWORD"),
) {
(Ok(u), Ok(p)) if !u.is_empty() && !p.is_empty() => Self(Some(Arc::new((u, p)))),
_ => {
tracing::warn!(
"DTRACK_ADMIN_USER/PASSWORD nicht gesetzt -- Admin-UI ist deaktiviert."
);
Self(None)
}
}
}
}
/// Baut den Admin-Router (eigene Basic-Auth-Schicht + AppState).
pub fn router(state: AppState, auth: AdminAuth) -> Router {
Router::new()
.route("/admin", get(page))
.route("/admin/config", post(update))
.route("/admin/reveal", get(reveal))
.layer(axum::middleware::from_fn_with_state(auth, basic_auth))
.with_state(state)
}
async fn basic_auth(State(auth): State<AdminAuth>, req: Request, next: Next) -> Response {
let Some(creds) = &auth.0 else {
// Admin nicht konfiguriert -> Routen existieren faktisch nicht.
return StatusCode::NOT_FOUND.into_response();
};
let decoded = req
.headers()
.get(AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.and_then(|h| h.strip_prefix("Basic "))
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
.and_then(|bytes| String::from_utf8(bytes).ok());
let ok = decoded
.as_deref()
.and_then(|s| s.split_once(':'))
.map(|(u, p)| u == creds.0.as_str() && p == creds.1.as_str())
.unwrap_or(false);
if ok {
next.run(req).await
} else {
let mut resp = StatusCode::UNAUTHORIZED.into_response();
resp.headers_mut().insert(
WWW_AUTHENTICATE,
HeaderValue::from_static("Basic realm=\"dtrack-admin\""),
);
resp
}
}
const CSS: &str = "body{font-family:system-ui,sans-serif;max-width:680px;margin:2rem auto;padding:0 1rem;color:#222}\
h1{font-size:1.3rem}label{display:block;margin:1rem 0}\
input[type=url],input[type=password]{width:100%;padding:.5rem;margin-top:.25rem;box-sizing:border-box}\
button{padding:.5rem 1rem;cursor:pointer}.ok{color:#176d2c;font-weight:600}\
#keyfield{font-family:monospace;background:#f3f3f3;padding:.2rem .4rem;border-radius:3px}";
fn render(cfg: &DtrackConfig, saved: bool) -> Markup {
html! {
(DOCTYPE)
html lang="de" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
title { "dtrack-mcp · Admin" }
script src="https://unpkg.com/htmx.org@2.0.3" {}
style { (PreEscaped(CSS)) }
}
body {
h1 { "Dependency-Track MCP — Konfiguration" }
@if saved { p.ok { "Gespeichert. Client wurde live neu gebaut." } }
form method="post" action="/admin/config" {
label {
"DT-URL"
input type="url" name="url" value=(cfg.url) required;
}
label {
"API-Key (aktuell)"
div {
span #keyfield { (cfg.masked_api_key()) }
" "
button type="button"
hx-get="/admin/reveal"
hx-target="#keyfield"
hx-swap="innerHTML" { "anzeigen" }
}
}
label {
"Neuen API-Key setzen (leer = unveraendert)"
input type="password" name="api_key" autocomplete="off";
}
label {
input type="checkbox" name="insecure" checked[cfg.insecure];
" TLS-Pruefung deaktivieren (insecure)"
}
button type="submit" { "Speichern" }
}
}
}
}
}
async fn page(State(st): State<AppState>) -> Html<String> {
let cfg = st.config.read().await.clone();
Html(render(&cfg, false).into_string())
}
async fn reveal(State(st): State<AppState>) -> Html<String> {
let key = st.config.read().await.api_key.clone();
Html(html! { (key) }.into_string())
}
#[derive(Deserialize)]
struct ConfigForm {
url: String,
api_key: String,
insecure: Option<String>,
}
async fn update(State(st): State<AppState>, Form(form): Form<ConfigForm>) -> Response {
let mut cfg = st.config.read().await.clone();
cfg.url = form.url.trim().to_string();
let new_key = form.api_key.trim();
if !new_key.is_empty() {
cfg.api_key = new_key.to_string();
}
cfg.insecure = form.insecure.is_some();
let new_client = match DtrackClient::from_config(&cfg) {
Ok(c) => c,
Err(e) => return (StatusCode::BAD_REQUEST, format!("Config ungueltig: {e}")).into_response(),
};
*st.client.write().await = new_client;
*st.config.write().await = cfg.clone();
if let Some(path) = &st.config_path {
if let Err(e) = cfg.save(path) {
tracing::error!("Config konnte nicht gespeichert werden: {e}");
}
}
Html(render(&cfg, true).into_string()).into_response()
}