This commit is contained in:
2026-05-12 19:25:14 +02:00
commit 0f3173d93e
93 changed files with 11865 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
# Per-package ignores live next to their package.json / Cargo.toml.
# This file only covers root-level artefacts.
# Local secrets / env overrides
deploy/.env
deploy/*.local.yaml
# Claude / editor / OS noise
.claude/
.idea/
.vscode/
*.swp
.DS_Store
Thumbs.db
# Generic build artefacts that may accidentally land at root
*.log
+87
View File
@@ -0,0 +1,87 @@
# SMGW PKI · Monorepo runner.
# Run `just` (no args) to list recipes.
set shell := ["bash", "-euo", "pipefail", "-c"]
repo := justfile_directory()
deploy := repo / "deploy"
backend := repo / "backend"
frontend := repo / "frontend"
# Default target — print recipe list.
default:
@just --list --unsorted
# ─── Compose lifecycle ───────────────────────────────────────────────────────
# Bring stack up (attached). Builds on first run.
up *ARGS:
docker compose --project-directory {{deploy}} -f {{deploy}}/compose.yaml up {{ARGS}}
# Bring stack up detached.
up-d *ARGS:
docker compose --project-directory {{deploy}} -f {{deploy}}/compose.yaml up -d {{ARGS}}
# Stop and remove containers + network. Volumes preserved.
down:
docker compose --project-directory {{deploy}} -f {{deploy}}/compose.yaml down
# Rebuild images. `just rebuild backend` rebuilds one service.
build *ARGS:
docker compose --project-directory {{deploy}} -f {{deploy}}/compose.yaml build {{ARGS}}
rebuild *ARGS:
docker compose --project-directory {{deploy}} -f {{deploy}}/compose.yaml build --no-cache {{ARGS}}
# Tail logs (default: both services). Pass a service to filter.
logs *ARGS:
docker compose --project-directory {{deploy}} -f {{deploy}}/compose.yaml logs -f {{ARGS}}
# Reload nginx config without restarting the container.
nginx-reload:
docker compose --project-directory {{deploy}} -f {{deploy}}/compose.yaml exec frontend nginx -s reload
# Compose status table.
ps:
docker compose --project-directory {{deploy}} -f {{deploy}}/compose.yaml ps
# ─── Local dev (without Docker) ──────────────────────────────────────────────
# Run the Rust backend locally with dev-auth + CORS for Vite.
dev-backend:
cd {{backend}} && DEV_AUTH=1 CORS_ALLOW_ORIGIN=http://localhost:5173 cargo run
# Run the Vite dev server (expects backend on :8443).
dev-frontend:
cd {{frontend}} && bun run dev
# ─── OpenAPI contract ────────────────────────────────────────────────────────
# Regenerate openapi.json from the backend and the TS client from it.
gen-api:
cd {{backend}} && cargo run --quiet -- --emit-openapi > {{frontend}}/openapi.json
cd {{frontend}} && bun run gen:api
# Fail if the committed openapi.json drifts from the current Rust source.
openapi-diff:
cd {{backend}} && cargo run --quiet -- --emit-openapi > /tmp/smgw-fresh-openapi.json
diff {{frontend}}/openapi.json /tmp/smgw-fresh-openapi.json \
&& echo "openapi.json in sync" \
|| ( echo "drift — run \`just gen-api\`" >&2 ; exit 1 )
# ─── Checks ──────────────────────────────────────────────────────────────────
# Run both side checks (cargo check + bun typecheck).
check:
cd {{backend}} && cargo check
cd {{frontend}} && bun run typecheck
# Run both test suites.
test:
cd {{backend}} && cargo test
cd {{frontend}} && bun run typecheck # placeholder — frontend tests TBD
# Format + lint (best-effort).
fmt:
cd {{backend}} && cargo fmt
cd {{frontend}} && bun x prettier --write 'src/**/*.{ts,tsx,css}' 2>/dev/null || true
+69
View File
@@ -0,0 +1,69 @@
# smgw-pki
Automatisierung der Smart-Meter-Gateway-PKI-Prozesse + Operator-Konsole. **Test- und Labor-Umgebung** gemäß BSI TR-03129-4, TR-03109-1 und SM-PKI Certificate Policy.
## Pakete
| Pfad | Inhalt |
| ----------- | --------------------------------------------------------------- |
| `backend/` | Rust-Service (`smgw-pki-automator`) — Sub-CA-Anbindung, HSM, Scheduler |
| `frontend/` | React-Konsole (`smgw-pki-console`) — Operator-UI auf gleicher API |
| `deploy/` | Docker-Compose, nginx-Config, `.env.example` |
| `docs/` | BSI-Architektur + Compliance-Mapping + Entwicklungs-Setup |
Kontextdokumente in jedem Paket: `backend/CLAUDE.md`, `frontend/CLAUDE.md` und das Top-Level [`CLAUDE.md`](./CLAUDE.md).
## Quickstart
Voraussetzungen: Docker (+ compose), [`just`](https://github.com/casey/just). Für lokale Entwicklung ohne Container zusätzlich Rust ≥ 1.85 und [Bun](https://bun.sh/) ≥ 1.2.
```bash
just # Rezepte auflisten
cp deploy/.env.example deploy/.env
just up # Stack hochfahren
```
Frontend: <http://localhost:8080>. Backend läuft intern auf `backend:8443` — siehe `deploy/compose.yaml`.
Login im Lab: Cert-Subject wird vom Reverse-Proxy via `X-Forwarded-Cert-Subject` gesetzt. Solange `DEV_AUTH=1` (Standard in `.env`), wird zusätzlich ein Dev-Subject im Login-Formular akzeptiert.
## Häufige Befehle
```bash
just up # build + run
just down # stoppen
just logs backend # logs eines Services
just rebuild backend # ohne Cache neu bauen
just dev-backend # cargo run (dev-auth + CORS für Vite)
just dev-frontend # bun run dev → http://localhost:5173
just gen-api # Rust → openapi.json → TS-Client
just openapi-diff # Drift-Check (CI-tauglich)
just check # cargo check + bun typecheck
just test # cargo test
```
## API-Vertrag
Single source of truth ist das Rust-Backend. `utoipa-axum` emittiert OpenAPI, `openapi-typescript` baut daraus den TS-Client.
```bash
just gen-api # nach jeder API-Änderung im Backend
```
`frontend/openapi.json` ist eingecheckt für reproduzierbare Builds. `just openapi-diff` schlägt fehl, wenn der Snapshot von der aktuellen Rust-Quelle abweicht.
## Sicherheit
- **Nicht für Produktion.** SoftHSMv2 erfüllt SM-PKI CP Level 1 nur für Entwicklungszwecke.
- mTLS-Termination erfolgt vor dem Frontend-nginx (Caddy/Traefik o. ä.). Der Proxy setzt `X-Forwarded-Cert-Subject`.
- Sitzungen via `HttpOnly; SameSite=Strict; Secure`-Cookie. TTL 8 h.
- Callback-Handler des Backends prüft mTLS-Cert + SOAP-Signatur (Status TODO, siehe `docs/bsi-compliance.md`).
## Status
Skeleton. Sub-CA-Adapter, HSM-Anbindung und iconfig-Signatur sind Stubs. Frontend ist vollständig funktional gegen das aktuelle Backend; Aktionen, die noch nicht implementiert sind, geben deterministisch `501 not_implemented` zurück.
Umsetzungsreihenfolge: `docs/architecture.md#umsetzungsreihenfolge`.
+7
View File
@@ -0,0 +1,7 @@
target/
.git/
.idea/
.vscode/
*.swp
Dockerfile
.dockerignore
+27
View File
@@ -0,0 +1,27 @@
# Rust build artefacts
target/
**/*.rs.bk
*.pdb
# Local env overrides (real config lives in deploy/.env)
.env
.env.local
.env.*.local
# SQLx offline cache (regenerate via `cargo sqlx prepare`)
.sqlx-tmp/
# Local SQLite databases / data dirs created by `cargo run`
*.db
*.db-journal
*.db-wal
*.db-shm
/data/
# Claude / editor / OS noise
.claude/
.idea/
.vscode/
*.swp
.DS_Store
*.log
+3399
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
[package]
name = "smgw-pki-automator"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full"] }
axum = "0.7"
async-trait = "0.1"
thiserror = "1"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
time = { version = "0.3", features = ["serde", "serde-well-known", "macros"] }
# Storage
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "time", "macros", "migrate"] }
# HSM (PKCS#11)
cryptoki = "0.7"
# HTTP / SOAP
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "rustls-tls-native-roots"] }
quick-xml = { version = "0.36", features = ["serialize"] }
base64 = "0.22"
# Mail
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "builder"] }
# Cron
tokio-cron-scheduler = "0.11"
# OpenAPI / HTTP middleware
utoipa = { version = "5", features = ["axum_extras", "time", "uuid"] }
utoipa-axum = "0.1"
tower-http = { version = "0.6", features = ["cors", "trace"] }
uuid = { version = "1", features = ["v4", "serde"] }
[dev-dependencies]
tokio = { version = "1", features = ["full", "test-util"] }
+37
View File
@@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1.7
# Build stage — cached deps, then app sources.
FROM rust:1-bookworm AS builder
WORKDIR /app
ENV CARGO_TERM_COLOR=always
ENV CARGO_INCREMENTAL=0
COPY Cargo.toml Cargo.lock ./
# Touch a stub main to allow dependency-only build for caching.
RUN mkdir -p src && echo 'fn main() {}' > src/main.rs \
&& cargo build --release \
&& rm -rf src target/release/deps/smgw_pki_automator*
COPY src ./src
COPY migrations ./migrations
RUN cargo build --release
# Runtime stage — minimal Debian with TLS + xmlsec1 (for InitialConfigBuilder),
# softhsm2 libs available at runtime when needed.
FROM debian:bookworm-slim AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
libssl3 \
xmlsec1 \
libsofthsm2 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt/smgw
COPY --from=builder /app/target/release/smgw-pki-automator /usr/local/bin/smgw-pki-automator
ENV RUST_LOG=info
ENV BIND_ADDR=0.0.0.0:8443
EXPOSE 8443
ENTRYPOINT ["/usr/local/bin/smgw-pki-automator"]
+64
View File
@@ -0,0 +1,64 @@
# smgw-pki-automator
Automatisierungs-Tool für die Smart-Meter-Gateway (SMGW) PKI-Prozesse in einer
Test-/Labor-Umgebung. Erzeugt Schlüsselmaterial in einem HSM, beantragt
Zertifikate bei einer Sub-CA gemäß **BSI TR-03129-4**, erzeugt signierte
Initial-Konfigurationen gemäß **BSI TR-03109-1** und überwacht
Zertifikatslaufzeiten gemäß den Vorgaben der SM-PKI Certificate Policy.
## Ziel
- Asynchrone `RequestCertificate`-Aufrufe an die Test-Sub-CA (mTLS, SOAP).
- Asynchroner Callback-Endpunkt zur Annahme fertiger Zertifikate.
- Generierung signierter `iconfig.xml` + `iconfig.sig`, verpackt in
`iconfig.tar`.
- Periodische Prüfung auf ablaufende Zertifikate (Standard: 30 Tage vor Ablauf)
und automatische Erneuerung.
- Alerting per SMTP bei Fehlern.
## Architektur
Hexagonale Architektur (Ports & Adapters). Die fachliche Kernlogik
(`src/domain/`) kennt keine Infrastruktur. Sie spricht ausschließlich gegen
Ports (`src/ports/`). Konkrete Implementierungen liegen in `src/adapters/`.
Details: [`../docs/architecture.md`](../docs/architecture.md).
## Projektstruktur
```
src/
├── domain/ Geschäftslogik, Entities (Certificate, Gateway)
├── ports/ Traits (Inbound/Outbound) — Schnittstellen
├── adapters/ Konkrete Impl.: HSM, Sub-CA, SQLite, SMTP, Clock
├── builders/ Builder für SOAP-Requests und iconfig.xml
├── app.rs Composition Root (Dependency Injection)
└── main.rs Tokio-Runtime, Tracing, Boot
```
## Dokumentation
- [`../docs/architecture.md`](../docs/architecture.md) — Hexagonale Architektur, Ports & Adapters, Datenflüsse.
- [`../docs/bsi-compliance.md`](../docs/bsi-compliance.md) — Mapping BSI-Vorgaben → Code (TR-03129-4, TR-03109-1, SM-PKI CP).
- [`../docs/development.md`](../docs/development.md) — Lokales Setup (SoftHSM2-Container, mTLS-Testzertifikate, Build & Run).
## Quickstart
```bash
cargo check
cargo run
```
Für die volle Lab-Umgebung siehe [`../docs/development.md`](../docs/development.md).
## Status
Skeleton. Ports und Domäne stehen. Adapter sind Stubs, die `not implemented`
zurückgeben. Reihenfolge der Umsetzung siehe
[`../docs/architecture.md`](../docs/architecture.md#umsetzungsreihenfolge).
## Sicherheitshinweis
Dieses Tool ist **ausschließlich** für Test- und Labor-Umgebungen gedacht. Der
SoftHSMv2 erfüllt die "Security Level 1"-Anforderung der SM-PKI CP nur für
Entwicklungszwecke; für Produktion ist ein zertifiziertes HSM zwingend.
+36
View File
@@ -0,0 +1,36 @@
-- Smart Meter Gateways under PKI management.
CREATE TABLE IF NOT EXISTS gateways (
id TEXT PRIMARY KEY,
serial_number TEXT NOT NULL,
admin_key_label TEXT NOT NULL,
created_at TEXT NOT NULL
);
-- Issued end-entity certificates per gateway and usage.
-- not_before/not_after stored as RFC3339 UTC text (sorts lexicographically).
CREATE TABLE IF NOT EXISTS certificates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
gateway_id TEXT NOT NULL REFERENCES gateways(id) ON DELETE CASCADE,
serial TEXT NOT NULL,
usage TEXT NOT NULL CHECK (usage IN ('tls', 'signature', 'encryption')),
pem TEXT NOT NULL,
not_before TEXT NOT NULL,
not_after TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (gateway_id, usage)
);
CREATE INDEX IF NOT EXISTS idx_certificates_not_after
ON certificates (not_after);
-- TR-03129-4 messageID -> gateway_id lookup for asynchronous CA callbacks.
CREATE TABLE IF NOT EXISTS pending_requests (
message_id TEXT PRIMARY KEY,
gateway_id TEXT NOT NULL REFERENCES gateways(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
resolved_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_pending_unresolved
ON pending_requests (resolved_at)
WHERE resolved_at IS NULL;
+11
View File
@@ -0,0 +1,11 @@
use time::OffsetDateTime;
use crate::ports::outbound::ClockPort;
pub struct SystemClock;
impl ClockPort for SystemClock {
fn now(&self) -> OffsetDateTime {
OffsetDateTime::now_utc()
}
}
+283
View File
@@ -0,0 +1,283 @@
use std::str::FromStr;
use async_trait::async_trait;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use sqlx::{Row, SqlitePool};
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use crate::domain::certificate::{Certificate, CertificateUsage};
use crate::domain::gateway::Gateway;
use crate::ports::outbound::{StorageError, StoragePort};
static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
pub struct SqliteAdapter {
pool: SqlitePool,
}
impl SqliteAdapter {
pub async fn new(url: &str) -> Result<Self, StorageError> {
let opts = SqliteConnectOptions::from_str(url)
.map_err(|e| StorageError::Backend(format!("parse url: {e}")))?
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(opts)
.await
.map_err(|e| StorageError::Backend(e.to_string()))?;
MIGRATOR
.run(&pool)
.await
.map_err(|e| StorageError::Backend(format!("migrate: {e}")))?;
Ok(Self { pool })
}
pub async fn new_in_memory() -> Result<Self, StorageError> {
Self::new("sqlite::memory:").await
}
pub fn pool(&self) -> &SqlitePool {
&self.pool
}
}
#[async_trait]
impl StoragePort for SqliteAdapter {
async fn get_expiring_certificates(
&self,
now: OffsetDateTime,
days_left: u32,
) -> Result<Vec<Certificate>, StorageError> {
let now_s = now.format(&Rfc3339).map_err(fmt_err)?;
let cutoff = (now + time::Duration::days(days_left as i64))
.format(&Rfc3339)
.map_err(fmt_err)?;
let rows = sqlx::query(
"SELECT gateway_id, serial, usage, pem, not_before, not_after \
FROM certificates \
WHERE not_after >= ?1 AND not_after <= ?2 \
ORDER BY not_after ASC",
)
.bind(&now_s)
.bind(&cutoff)
.fetch_all(&self.pool)
.await
.map_err(backend_err)?;
rows.into_iter().map(row_to_certificate).collect()
}
async fn list_certificates(&self) -> Result<Vec<Certificate>, StorageError> {
let rows = sqlx::query(
"SELECT gateway_id, serial, usage, pem, not_before, not_after \
FROM certificates \
ORDER BY not_after ASC",
)
.fetch_all(&self.pool)
.await
.map_err(backend_err)?;
rows.into_iter().map(row_to_certificate).collect()
}
async fn list_gateways(&self) -> Result<Vec<Gateway>, StorageError> {
let rows = sqlx::query(
"SELECT id, serial_number, admin_key_label FROM gateways ORDER BY id ASC",
)
.fetch_all(&self.pool)
.await
.map_err(backend_err)?;
rows.into_iter()
.map(|row| {
Ok(Gateway {
id: row.try_get("id").map_err(backend_err)?,
serial_number: row.try_get("serial_number").map_err(backend_err)?,
admin_key_label: row.try_get("admin_key_label").map_err(backend_err)?,
})
})
.collect()
}
async fn save_pending_request(
&self,
message_id: &str,
gateway_id: &str,
) -> Result<(), StorageError> {
let created_at = OffsetDateTime::now_utc().format(&Rfc3339).map_err(fmt_err)?;
sqlx::query(
"INSERT INTO pending_requests (message_id, gateway_id, created_at) \
VALUES (?1, ?2, ?3)",
)
.bind(message_id)
.bind(gateway_id)
.bind(created_at)
.execute(&self.pool)
.await
.map_err(backend_err)?;
Ok(())
}
async fn update_certificate(
&self,
gateway_id: &str,
new_cert_pem: &str,
) -> Result<(), StorageError> {
// NOTE: parsing serial/not_before/not_after from PEM is the caller's
// responsibility today. The port signature should grow a structured
// Certificate argument; until then we only refresh PEM + updated_at.
let updated_at = OffsetDateTime::now_utc().format(&Rfc3339).map_err(fmt_err)?;
let result = sqlx::query(
"UPDATE certificates SET pem = ?1, updated_at = ?2 WHERE gateway_id = ?3",
)
.bind(new_cert_pem)
.bind(updated_at)
.bind(gateway_id)
.execute(&self.pool)
.await
.map_err(backend_err)?;
if result.rows_affected() == 0 {
return Err(StorageError::NotFound);
}
Ok(())
}
}
fn row_to_certificate(row: sqlx::sqlite::SqliteRow) -> Result<Certificate, StorageError> {
let gateway_id: String = row.try_get("gateway_id").map_err(backend_err)?;
let serial: String = row.try_get("serial").map_err(backend_err)?;
let usage_s: String = row.try_get("usage").map_err(backend_err)?;
let pem: String = row.try_get("pem").map_err(backend_err)?;
let not_before_s: String = row.try_get("not_before").map_err(backend_err)?;
let not_after_s: String = row.try_get("not_after").map_err(backend_err)?;
Ok(Certificate {
gateway_id,
serial,
usage: parse_usage(&usage_s)?,
pem,
not_before: OffsetDateTime::parse(&not_before_s, &Rfc3339).map_err(parse_err)?,
not_after: OffsetDateTime::parse(&not_after_s, &Rfc3339).map_err(parse_err)?,
})
}
fn parse_usage(s: &str) -> Result<CertificateUsage, StorageError> {
match s {
"tls" => Ok(CertificateUsage::Tls),
"signature" => Ok(CertificateUsage::Signature),
"encryption" => Ok(CertificateUsage::Encryption),
other => Err(StorageError::Backend(format!("unknown usage: {other}"))),
}
}
pub fn usage_str(u: &CertificateUsage) -> &'static str {
match u {
CertificateUsage::Tls => "tls",
CertificateUsage::Signature => "signature",
CertificateUsage::Encryption => "encryption",
}
}
fn backend_err<E: std::fmt::Display>(e: E) -> StorageError {
StorageError::Backend(e.to_string())
}
fn fmt_err<E: std::fmt::Display>(e: E) -> StorageError {
StorageError::Backend(format!("format: {e}"))
}
fn parse_err<E: std::fmt::Display>(e: E) -> StorageError {
StorageError::Backend(format!("parse: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
async fn seed_gateway(pool: &SqlitePool, id: &str) {
let now = OffsetDateTime::now_utc().format(&Rfc3339).unwrap();
sqlx::query(
"INSERT INTO gateways (id, serial_number, admin_key_label, created_at) \
VALUES (?1, ?2, ?3, ?4)",
)
.bind(id)
.bind("SN-1")
.bind("ADMIN-LABEL")
.bind(now)
.execute(pool)
.await
.unwrap();
}
async fn seed_cert(
pool: &SqlitePool,
gateway_id: &str,
usage: CertificateUsage,
not_after: OffsetDateTime,
) {
let nb = (not_after - time::Duration::days(365)).format(&Rfc3339).unwrap();
let na = not_after.format(&Rfc3339).unwrap();
let updated = OffsetDateTime::now_utc().format(&Rfc3339).unwrap();
sqlx::query(
"INSERT INTO certificates (gateway_id, serial, usage, pem, not_before, not_after, updated_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)
.bind(gateway_id)
.bind("SERIAL")
.bind(usage_str(&usage))
.bind("-----BEGIN CERTIFICATE-----\nold\n-----END CERTIFICATE-----")
.bind(nb)
.bind(na)
.bind(updated)
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn get_expiring_returns_only_in_window() {
let db = SqliteAdapter::new_in_memory().await.unwrap();
seed_gateway(db.pool(), "gw-1").await;
let now = OffsetDateTime::now_utc();
seed_cert(db.pool(), "gw-1", CertificateUsage::Tls, now + time::Duration::days(10)).await;
seed_cert(db.pool(), "gw-1", CertificateUsage::Signature, now + time::Duration::days(90)).await;
let result = db.get_expiring_certificates(now, 30).await.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].usage, CertificateUsage::Tls);
}
#[tokio::test]
async fn save_pending_then_update_certificate_roundtrip() {
let db = SqliteAdapter::new_in_memory().await.unwrap();
seed_gateway(db.pool(), "gw-1").await;
let now = OffsetDateTime::now_utc();
seed_cert(db.pool(), "gw-1", CertificateUsage::Tls, now + time::Duration::days(5)).await;
db.save_pending_request("msg-abc", "gw-1").await.unwrap();
db.update_certificate("gw-1", "-----BEGIN CERTIFICATE-----\nnew\n-----END CERTIFICATE-----")
.await
.unwrap();
let row = sqlx::query("SELECT pem FROM certificates WHERE gateway_id = 'gw-1'")
.fetch_one(db.pool())
.await
.unwrap();
let pem: String = row.try_get("pem").unwrap();
assert!(pem.contains("new"));
}
#[tokio::test]
async fn update_certificate_unknown_gateway_is_not_found() {
let db = SqliteAdapter::new_in_memory().await.unwrap();
let err = db.update_certificate("nope", "pem").await.unwrap_err();
assert!(matches!(err, StorageError::NotFound));
}
}
+33
View File
@@ -0,0 +1,33 @@
use crate::ports::outbound::{HsmError, HsmPort};
pub struct SoftHsmAdapter {
_module_path: String,
_pin: String,
}
impl SoftHsmAdapter {
pub fn new(module_path: impl Into<String>, pin: impl Into<String>) -> Self {
Self {
_module_path: module_path.into(),
_pin: pin.into(),
}
}
pub fn new_stub() -> Self {
Self::new("/usr/lib/softhsm/libsofthsm2.so", "1234")
}
}
impl HsmPort for SoftHsmAdapter {
fn generate_key_pair(&self, _label: &str) -> Result<String, HsmError> {
Err(HsmError::Other("not implemented".into()))
}
fn sign_csr(&self, _key_id: &str, _payload: &[u8]) -> Result<Vec<u8>, HsmError> {
Err(HsmError::Other("not implemented".into()))
}
fn sign_xml(&self, _key_id: &str, _xml_data: &str) -> Result<String, HsmError> {
Err(HsmError::Other("not implemented".into()))
}
}
+29
View File
@@ -0,0 +1,29 @@
use async_trait::async_trait;
use crate::ports::outbound::{NotificationError, NotificationPort};
pub struct SmtpAdapter {
_host: String,
_port: u16,
}
impl SmtpAdapter {
pub fn new(host: impl Into<String>, port: u16) -> Self {
Self {
_host: host.into(),
_port: port,
}
}
pub fn new_stub() -> Self {
Self::new("smtp.local", 587)
}
}
#[async_trait]
impl NotificationPort for SmtpAdapter {
async fn send_alert(&self, subject: &str, body: &str) -> Result<(), NotificationError> {
tracing::info!(subject, body, "alert (stub)");
Ok(())
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod clock;
pub mod db;
pub mod hsm;
pub mod mail;
pub mod sub_ca;
+27
View File
@@ -0,0 +1,27 @@
use async_trait::async_trait;
use crate::domain::certificate::CertificateRequest;
use crate::ports::outbound::{CaError, CertificateCaPort};
pub struct SubCaSoapAdapter {
_endpoint: String,
}
impl SubCaSoapAdapter {
pub fn new(endpoint: impl Into<String>) -> Self {
Self {
_endpoint: endpoint.into(),
}
}
pub fn new_stub() -> Self {
Self::new("https://test-ca.local/soap")
}
}
#[async_trait]
impl CertificateCaPort for SubCaSoapAdapter {
async fn request_certificate(&self, _csr: CertificateRequest) -> Result<String, CaError> {
Err(CaError::Transport("not implemented".into()))
}
}
+111
View File
@@ -0,0 +1,111 @@
use std::sync::Arc;
use anyhow::{Context, Result};
use tokio::sync::{RwLock, Semaphore};
use crate::adapters;
use crate::http::{self, HttpState};
use crate::ports::inbound::{HandleCaCallback, RenewExpiringCertificates};
use crate::ports::outbound::{CertificateCaPort, ClockPort, HsmPort, NotificationPort, StoragePort};
use crate::scheduler;
use crate::state::{
RuntimeConfig, SchedulerState, SessionStore, SharedAlerts, SharedScheduler, SharedSessions,
};
use crate::usecases;
#[derive(Clone)]
pub struct AppState {
pub hsm: Arc<dyn HsmPort>,
pub ca: Arc<dyn CertificateCaPort>,
pub storage: Arc<dyn StoragePort>,
pub mail: Arc<dyn NotificationPort>,
pub clock: Arc<dyn ClockPort>,
}
pub async fn run() -> Result<()> {
let state = build_state().await?;
let config = Arc::new(RwLock::new(RuntimeConfig::from_env()));
let cfg_snapshot = config.read().await.clone();
let scheduler_state: SharedScheduler = Arc::new(RwLock::new(SchedulerState {
cron_schedule: cfg_snapshot.cron_schedule.clone(),
days_window: cfg_snapshot.days_window,
..Default::default()
}));
let alerts: SharedAlerts = Arc::new(RwLock::new(Vec::new()));
let sessions: SharedSessions = Arc::new(RwLock::new(SessionStore::default()));
let run_lock = Arc::new(Semaphore::new(1));
let renew: Arc<dyn RenewExpiringCertificates> = Arc::new(usecases::renew::RenewService {
storage: state.storage.clone(),
ca: state.ca.clone(),
hsm: state.hsm.clone(),
clock: state.clock.clone(),
notifier: state.mail.clone(),
});
let callback: Arc<dyn HandleCaCallback> = Arc::new(usecases::callback::CallbackService {
storage: state.storage.clone(),
});
let sched_handle = scheduler::start(
&cfg_snapshot.cron_schedule,
cfg_snapshot.days_window,
renew.clone(),
scheduler_state.clone(),
run_lock.clone(),
)
.await?;
let cors_origin = std::env::var("CORS_ALLOW_ORIGIN").ok();
let dev_auth = std::env::var("DEV_AUTH").map(|v| v == "1").unwrap_or(false);
let http_state = HttpState {
callback,
renew,
storage: state.storage.clone(),
mail: state.mail.clone(),
clock: state.clock.clone(),
config: config.clone(),
scheduler: scheduler_state.clone(),
alerts,
sessions,
run_lock,
dev_auth,
};
let router = http::router(http_state, cors_origin);
let bind = cfg_snapshot.bind_addr.clone();
let listener = tokio::net::TcpListener::bind(&bind)
.await
.with_context(|| format!("bind {bind}"))?;
tracing::info!(%bind, "http server up");
axum::serve(listener, router)
.with_graceful_shutdown(shutdown_signal())
.await
.context("axum serve")?;
sched_handle.shutdown().await?;
Ok(())
}
async fn build_state() -> Result<AppState> {
let hsm = Arc::new(adapters::hsm::SoftHsmAdapter::new_stub());
let ca = Arc::new(adapters::sub_ca::SubCaSoapAdapter::new_stub());
let storage = Arc::new(adapters::db::SqliteAdapter::new_in_memory().await?);
let mail = Arc::new(adapters::mail::SmtpAdapter::new_stub());
let clock = Arc::new(adapters::clock::SystemClock);
Ok(AppState {
hsm,
ca,
storage,
mail,
clock,
})
}
async fn shutdown_signal() {
let _ = tokio::signal::ctrl_c().await;
tracing::info!("shutdown signal received");
}
+33
View File
@@ -0,0 +1,33 @@
use std::sync::Arc;
use crate::ports::outbound::HsmPort;
pub struct InitialConfigBuilder {
hsm: Arc<dyn HsmPort>,
admin_key_id: String,
gateway_id: Option<String>,
}
impl InitialConfigBuilder {
pub fn new(hsm: Arc<dyn HsmPort>, admin_key_id: impl Into<String>) -> Self {
Self {
hsm,
admin_key_id: admin_key_id.into(),
gateway_id: None,
}
}
pub fn gateway_id(mut self, id: impl Into<String>) -> Self {
self.gateway_id = Some(id.into());
self
}
pub fn build_signed(self) -> Result<String, String> {
let gw = self.gateway_id.ok_or("gateway_id required")?;
// TODO: build iconfig.xml via quick-xml, C14N, sign via HSM
let raw_xml = format!("<iconfig gateway=\"{}\"/>", gw);
self.hsm
.sign_xml(&self.admin_key_id, &raw_xml)
.map_err(|e| e.to_string())
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod iconfig;
pub mod soap_req;
+38
View File
@@ -0,0 +1,38 @@
use crate::domain::certificate::CertificateRequest;
pub struct SoapRequestBuilder<'a> {
csr: Option<&'a CertificateRequest>,
message_id: Option<String>,
}
impl<'a> SoapRequestBuilder<'a> {
pub fn new() -> Self {
Self {
csr: None,
message_id: None,
}
}
pub fn csr(mut self, csr: &'a CertificateRequest) -> Self {
self.csr = Some(csr);
self
}
pub fn message_id(mut self, id: impl Into<String>) -> Self {
self.message_id = Some(id.into());
self
}
pub fn build_request_certificate(self) -> Result<String, &'static str> {
let _csr = self.csr.ok_or("csr required")?;
let _mid = self.message_id.ok_or("message_id required")?;
// TODO: TR-03129-4 RequestCertificate envelope, base64(csr_der)
Err("not implemented")
}
}
impl<'a> Default for SoapRequestBuilder<'a> {
fn default() -> Self {
Self::new()
}
}
+36
View File
@@ -0,0 +1,36 @@
use time::OffsetDateTime;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CertificateUsage {
Tls,
Signature,
Encryption,
}
#[derive(Debug, Clone)]
pub struct Certificate {
pub gateway_id: String,
pub serial: String,
pub usage: CertificateUsage,
pub pem: String,
pub not_before: OffsetDateTime,
pub not_after: OffsetDateTime,
}
impl Certificate {
pub fn days_until_expiry(&self, now: OffsetDateTime) -> i64 {
(self.not_after - now).whole_days()
}
pub fn is_expiring_within(&self, now: OffsetDateTime, days: u32) -> bool {
let d = self.days_until_expiry(now);
d >= 0 && d <= days as i64
}
}
#[derive(Debug, Clone)]
pub struct CertificateRequest {
pub gateway_id: String,
pub usage: CertificateUsage,
pub csr_der: Vec<u8>,
}
+6
View File
@@ -0,0 +1,6 @@
#[derive(Debug, Clone)]
pub struct Gateway {
pub id: String,
pub serial_number: String,
pub admin_key_label: String,
}
+2
View File
@@ -0,0 +1,2 @@
pub mod certificate;
pub mod gateway;
+81
View File
@@ -0,0 +1,81 @@
use axum::extract::State;
use axum::Json;
use serde::Deserialize;
use time::OffsetDateTime;
use utoipa::ToSchema;
use utoipa_axum::router::OpenApiRouter;
use utoipa_axum::routes;
use super::error::ApiResult;
use crate::http::HttpState;
use crate::state::{AlertEntry, AlertSeverity};
pub fn router() -> OpenApiRouter<HttpState> {
OpenApiRouter::new()
.routes(routes!(list_alerts))
.routes(routes!(send_test_alert))
}
#[derive(Debug, serde::Serialize, ToSchema)]
pub struct AlertListResponse {
pub items: Vec<AlertEntry>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct TestAlertRequest {
pub subject: Option<String>,
pub body: Option<String>,
}
#[utoipa::path(
get,
path = "",
tag = "alerts",
responses(
(status = 200, description = "Recent alerts", body = AlertListResponse),
)
)]
pub async fn list_alerts(State(state): State<HttpState>) -> Json<AlertListResponse> {
let items = state.alerts.read().await.clone();
Json(AlertListResponse { items })
}
#[utoipa::path(
post,
path = "/test",
tag = "alerts",
request_body = TestAlertRequest,
responses(
(status = 200, description = "Sent (or stub-logged)"),
)
)]
pub async fn send_test_alert(
State(state): State<HttpState>,
Json(req): Json<TestAlertRequest>,
) -> ApiResult<()> {
let subject = req
.subject
.unwrap_or_else(|| "smgw-pki-automator: test alert".into());
let body = req
.body
.unwrap_or_else(|| "If you see this, SMTP wiring works.".into());
let send_result = state.mail.send_alert(&subject, &body).await;
let mut alerts = state.alerts.write().await;
alerts.push(AlertEntry {
at: OffsetDateTime::now_utc(),
severity: if send_result.is_ok() {
AlertSeverity::Info
} else {
AlertSeverity::Error
},
subject,
body,
});
// Trim ringbuffer.
let len = alerts.len();
if len > 200 {
alerts.drain(0..(len - 200));
}
Ok(())
}
+160
View File
@@ -0,0 +1,160 @@
use axum::extract::State;
use axum::http::header::{HeaderMap, SET_COOKIE};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::Json;
use serde::{Deserialize, Serialize};
use time::{Duration, OffsetDateTime};
use utoipa::ToSchema;
use utoipa_axum::router::OpenApiRouter;
use utoipa_axum::routes;
use uuid::Uuid;
use super::error::{ApiResponseError, ApiResult};
use crate::http::HttpState;
use crate::state::Session;
pub const SESSION_COOKIE: &str = "smgw_session";
pub const CERT_SUBJECT_HEADER: &str = "x-forwarded-cert-subject";
pub const SESSION_TTL_MINUTES: i64 = 60 * 8;
pub fn router() -> OpenApiRouter<HttpState> {
OpenApiRouter::new()
.routes(routes!(create_session))
.routes(routes!(end_session))
.routes(routes!(whoami))
}
#[derive(Debug, Serialize, ToSchema)]
pub struct SessionResponse {
pub subject: String,
#[serde(with = "time::serde::rfc3339")]
pub expires_at: OffsetDateTime,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct LoginRequest {
/// Optional fallback subject for dev mode when no mTLS header is present.
pub dev_subject: Option<String>,
}
/// Exchange mTLS client cert (passed via `X-Forwarded-Cert-Subject`) for a
/// server-issued session cookie. The reverse proxy terminating mTLS is the
/// trust anchor.
#[utoipa::path(
post,
path = "/session",
tag = "auth",
request_body = LoginRequest,
responses(
(status = 200, description = "Session issued", body = SessionResponse),
(status = 403, description = "No client cert subject", body = super::error::ApiError),
)
)]
pub async fn create_session(
State(state): State<HttpState>,
headers: HeaderMap,
Json(body): Json<LoginRequest>,
) -> ApiResult<impl IntoResponse> {
let subject = headers
.get(CERT_SUBJECT_HEADER)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.or_else(|| {
if state.dev_auth {
body.dev_subject
} else {
None
}
})
.ok_or_else(ApiResponseError::forbidden)?;
let now = OffsetDateTime::now_utc();
let expires_at = now + Duration::minutes(SESSION_TTL_MINUTES);
let session = Session {
id: Uuid::new_v4().to_string(),
subject: subject.clone(),
issued_at: now,
expires_at,
};
{
let mut store = state.sessions.write().await;
store.gc(now);
store.insert(session.clone());
}
let cookie = format!(
"{}={}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}{}",
SESSION_COOKIE,
session.id,
SESSION_TTL_MINUTES * 60,
if state.dev_auth { "" } else { "; Secure" },
);
Ok((
StatusCode::OK,
[(SET_COOKIE, cookie)],
Json(SessionResponse {
subject,
expires_at,
}),
))
}
/// Revoke the current session and clear cookie.
#[utoipa::path(
delete,
path = "/session",
tag = "auth",
responses(
(status = 204, description = "Session ended"),
)
)]
pub async fn end_session(
State(state): State<HttpState>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Some(id) = session_id_from_cookie(&headers) {
state.sessions.write().await.remove(&id);
}
let clear = format!(
"{}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0",
SESSION_COOKIE
);
(StatusCode::NO_CONTENT, [(SET_COOKIE, clear)])
}
/// Inspect the current session.
#[utoipa::path(
get,
path = "/me",
tag = "auth",
responses(
(status = 200, description = "Current session", body = SessionResponse),
(status = 401, description = "No active session", body = super::error::ApiError),
)
)]
pub async fn whoami(
State(state): State<HttpState>,
headers: HeaderMap,
) -> ApiResult<Json<SessionResponse>> {
let id = session_id_from_cookie(&headers).ok_or_else(ApiResponseError::unauthorized)?;
let store = state.sessions.read().await;
let session = store.get(&id).ok_or_else(ApiResponseError::unauthorized)?;
if session.expires_at <= OffsetDateTime::now_utc() {
return Err(ApiResponseError::unauthorized());
}
Ok(Json(SessionResponse {
subject: session.subject.clone(),
expires_at: session.expires_at,
}))
}
pub fn session_id_from_cookie(headers: &HeaderMap) -> Option<String> {
let raw = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
raw.split(';').find_map(|kv| {
let (k, v) = kv.split_once('=')?;
(k.trim() == SESSION_COOKIE).then(|| v.trim().to_string())
})
}
+148
View File
@@ -0,0 +1,148 @@
use axum::extract::{Path, State};
use axum::Json;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use utoipa::ToSchema;
use utoipa_axum::router::OpenApiRouter;
use utoipa_axum::routes;
use super::error::{ApiResponseError, ApiResult};
use crate::domain::certificate::{Certificate, CertificateUsage};
use crate::http::HttpState;
pub fn router() -> OpenApiRouter<HttpState> {
OpenApiRouter::new()
.routes(routes!(list_certificates))
.routes(routes!(renew_certificate))
}
#[derive(Debug, Serialize, ToSchema)]
pub struct CertificateDto {
pub gateway_id: String,
pub serial: String,
pub usage: CertificateUsageDto,
#[serde(with = "time::serde::rfc3339")]
pub not_before: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub not_after: OffsetDateTime,
pub days_to_expiry: i64,
pub state: CertState,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum CertificateUsageDto {
Tls,
Signature,
Encryption,
}
impl From<&CertificateUsage> for CertificateUsageDto {
fn from(u: &CertificateUsage) -> Self {
match u {
CertificateUsage::Tls => Self::Tls,
CertificateUsage::Signature => Self::Signature,
CertificateUsage::Encryption => Self::Encryption,
}
}
}
impl From<CertificateUsageDto> for CertificateUsage {
fn from(u: CertificateUsageDto) -> Self {
match u {
CertificateUsageDto::Tls => Self::Tls,
CertificateUsageDto::Signature => Self::Signature,
CertificateUsageDto::Encryption => Self::Encryption,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum CertState {
Valid,
Expiring,
Expired,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct CertListResponse {
pub items: Vec<CertificateDto>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct RenewAccepted {
pub message_id: String,
}
fn to_dto(c: &Certificate, now: OffsetDateTime, days_window: u32) -> CertificateDto {
let d = c.days_until_expiry(now);
let state = if d < 0 {
CertState::Expired
} else if d <= days_window as i64 {
CertState::Expiring
} else {
CertState::Valid
};
CertificateDto {
gateway_id: c.gateway_id.clone(),
serial: c.serial.clone(),
usage: (&c.usage).into(),
not_before: c.not_before,
not_after: c.not_after,
days_to_expiry: d,
state,
}
}
/// List all known end-entity certificates with derived state.
#[utoipa::path(
get,
path = "",
tag = "certs",
responses(
(status = 200, description = "Certificates", body = CertListResponse),
)
)]
pub async fn list_certificates(
State(state): State<HttpState>,
) -> ApiResult<Json<CertListResponse>> {
let certs = state
.storage
.list_certificates()
.await
.map_err(|e| ApiResponseError::internal(e.to_string()))?;
let now = state.clock.now();
let cfg = state.config.read().await.clone();
let items = certs
.iter()
.map(|c| to_dto(c, now, cfg.days_window))
.collect();
Ok(Json(CertListResponse { items }))
}
/// Trigger an out-of-band renewal for a specific (gateway, usage) pair.
/// Returns the SOAP `messageID` so the caller can correlate the async callback.
#[utoipa::path(
post,
path = "/{gateway_id}/{usage}/renew",
tag = "certs",
params(
("gateway_id" = String, Path, description = "Gateway identifier"),
("usage" = CertificateUsageDto, Path, description = "Certificate usage"),
),
responses(
(status = 202, description = "Renewal accepted", body = RenewAccepted),
(status = 501, description = "Sub-CA adapter not implemented yet", body = super::error::ApiError),
)
)]
pub async fn renew_certificate(
State(_state): State<HttpState>,
Path((gateway_id, usage)): Path<(String, CertificateUsageDto)>,
) -> ApiResult<Json<RenewAccepted>> {
// TODO wire to RenewService once SubCaSoapAdapter is real.
tracing::info!(%gateway_id, ?usage, "manual renewal requested");
Err(ApiResponseError::not_implemented(
"manual renewal pending SubCaSoapAdapter",
))
}
+88
View File
@@ -0,0 +1,88 @@
use axum::extract::State;
use axum::Json;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use utoipa_axum::router::OpenApiRouter;
use utoipa_axum::routes;
use super::error::ApiResult;
use crate::http::HttpState;
use crate::state::{HsmConfig, RuntimeConfig, SmtpConfig, SubCaConfig};
pub fn router() -> OpenApiRouter<HttpState> {
OpenApiRouter::new()
.routes(routes!(get_config))
.routes(routes!(update_config))
}
#[derive(Debug, Serialize, ToSchema)]
pub struct ConfigView {
pub config: RuntimeConfig,
pub restart_required_fields: Vec<&'static str>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct ConfigUpdate {
pub cron_schedule: Option<String>,
pub days_window: Option<u32>,
pub sub_ca: Option<SubCaConfig>,
pub smtp: Option<SmtpConfig>,
pub hsm: Option<HsmConfig>,
}
#[utoipa::path(
get,
path = "",
tag = "config",
responses(
(status = 200, description = "Current runtime config", body = ConfigView),
)
)]
pub async fn get_config(State(state): State<HttpState>) -> ApiResult<Json<ConfigView>> {
let cfg = state.config.read().await.clone();
Ok(Json(ConfigView {
config: cfg,
restart_required_fields: vec!["bind_addr", "database_url"],
}))
}
#[utoipa::path(
put,
path = "",
tag = "config",
request_body = ConfigUpdate,
responses(
(status = 200, description = "Updated runtime config", body = ConfigView),
)
)]
pub async fn update_config(
State(state): State<HttpState>,
Json(patch): Json<ConfigUpdate>,
) -> ApiResult<Json<ConfigView>> {
let mut cfg = state.config.write().await;
if let Some(v) = patch.cron_schedule {
cfg.cron_schedule = v;
}
if let Some(v) = patch.days_window {
cfg.days_window = v;
}
if let Some(v) = patch.sub_ca {
cfg.sub_ca = v;
}
if let Some(v) = patch.smtp {
cfg.smtp = v;
}
if let Some(v) = patch.hsm {
cfg.hsm = v;
}
// Mirror scheduler-affecting fields.
{
let mut sch = state.scheduler.write().await;
sch.cron_schedule = cfg.cron_schedule.clone();
sch.days_window = cfg.days_window;
}
Ok(Json(ConfigView {
config: cfg.clone(),
restart_required_fields: vec!["bind_addr", "database_url"],
}))
}
+77
View File
@@ -0,0 +1,77 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Serialize;
use utoipa::ToSchema;
#[derive(Debug, Serialize, ToSchema)]
pub struct ApiError {
pub code: &'static str,
pub message: String,
}
impl ApiError {
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
}
pub struct ApiResponseError {
pub status: StatusCode,
pub body: ApiError,
}
impl ApiResponseError {
pub fn bad_request(msg: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
body: ApiError::new("bad_request", msg),
}
}
pub fn unauthorized() -> Self {
Self {
status: StatusCode::UNAUTHORIZED,
body: ApiError::new("unauthorized", "session missing or expired"),
}
}
pub fn forbidden() -> Self {
Self {
status: StatusCode::FORBIDDEN,
body: ApiError::new("forbidden", "client certificate not accepted"),
}
}
pub fn not_found() -> Self {
Self {
status: StatusCode::NOT_FOUND,
body: ApiError::new("not_found", "resource not found"),
}
}
pub fn not_implemented(msg: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_IMPLEMENTED,
body: ApiError::new("not_implemented", msg),
}
}
pub fn internal(msg: impl Into<String>) -> Self {
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
body: ApiError::new("internal", msg),
}
}
}
impl IntoResponse for ApiResponseError {
fn into_response(self) -> Response {
(self.status, Json(self.body)).into_response()
}
}
pub type ApiResult<T> = Result<T, ApiResponseError>;
+52
View File
@@ -0,0 +1,52 @@
use axum::extract::State;
use axum::Json;
use serde::Serialize;
use utoipa::ToSchema;
use utoipa_axum::router::OpenApiRouter;
use utoipa_axum::routes;
use super::error::{ApiResponseError, ApiResult};
use crate::http::HttpState;
pub fn router() -> OpenApiRouter<HttpState> {
OpenApiRouter::new().routes(routes!(list_gateways))
}
#[derive(Debug, Serialize, ToSchema)]
pub struct GatewayDto {
pub id: String,
pub serial_number: String,
pub admin_key_label: String,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct GatewayListResponse {
pub items: Vec<GatewayDto>,
}
#[utoipa::path(
get,
path = "",
tag = "gateways",
responses(
(status = 200, description = "Gateways", body = GatewayListResponse),
)
)]
pub async fn list_gateways(
State(state): State<HttpState>,
) -> ApiResult<Json<GatewayListResponse>> {
let rows = state
.storage
.list_gateways()
.await
.map_err(|e| ApiResponseError::internal(e.to_string()))?;
let items = rows
.into_iter()
.map(|g| GatewayDto {
id: g.id,
serial_number: g.serial_number,
admin_key_label: g.admin_key_label,
})
.collect();
Ok(Json(GatewayListResponse { items }))
}
+70
View File
@@ -0,0 +1,70 @@
use axum::extract::State;
use axum::Json;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use utoipa_axum::router::OpenApiRouter;
use utoipa_axum::routes;
use super::error::{ApiResponseError, ApiResult};
use crate::http::HttpState;
pub fn router() -> OpenApiRouter<HttpState> {
OpenApiRouter::new()
.routes(routes!(preview_iconfig))
.routes(routes!(build_iconfig))
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct IconfigRequest {
pub gateway_id: String,
pub admin_key_label: String,
pub profile: String,
pub extras: Option<serde_json::Value>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct IconfigPreview {
pub xml: String,
}
/// Render the unsigned `iconfig.xml` for review. Does not touch the HSM.
#[utoipa::path(
post,
path = "/preview",
tag = "iconfig",
request_body = IconfigRequest,
responses(
(status = 200, description = "Preview XML", body = IconfigPreview),
)
)]
pub async fn preview_iconfig(
State(_state): State<HttpState>,
Json(req): Json<IconfigRequest>,
) -> ApiResult<Json<IconfigPreview>> {
// TODO replace with InitialConfigBuilder once it produces canonical XML.
let xml = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<InitialConfig gatewayId=\"{}\" profile=\"{}\">\n <AdminKeyLabel>{}</AdminKeyLabel>\n</InitialConfig>\n",
req.gateway_id, req.profile, req.admin_key_label
);
Ok(Json(IconfigPreview { xml }))
}
/// Build, sign via HSM, and stream back `iconfig.tar`.
#[utoipa::path(
post,
path = "/build",
tag = "iconfig",
request_body = IconfigRequest,
responses(
(status = 200, description = "iconfig.tar", content_type = "application/x-tar"),
(status = 501, description = "HSM signature not implemented", body = super::error::ApiError),
)
)]
pub async fn build_iconfig(
State(_state): State<HttpState>,
Json(_req): Json<IconfigRequest>,
) -> ApiResult<()> {
Err(ApiResponseError::not_implemented(
"iconfig build pending InitialConfigBuilder + HsmPort::sign_xml",
))
}
+104
View File
@@ -0,0 +1,104 @@
pub mod alerts;
pub mod auth;
pub mod certs;
pub mod config;
pub mod error;
pub mod gateways;
pub mod iconfig;
pub mod scheduler;
use std::sync::Arc;
use axum::Router;
use tower_http::cors::{AllowOrigin, CorsLayer};
use utoipa::OpenApi;
use utoipa_axum::router::OpenApiRouter;
use crate::http::HttpState;
#[derive(OpenApi)]
#[openapi(
info(
title = "smgw-pki-automator",
version = env!("CARGO_PKG_VERSION"),
description = "Control + observation surface for the SMGW PKI automation tool. Test/lab use only.",
),
tags(
(name = "auth", description = "mTLS-bridged session management"),
(name = "certs", description = "Certificate lifecycle"),
(name = "gateways", description = "Smart Meter Gateways"),
(name = "config", description = "Runtime configuration"),
(name = "scheduler", description = "Renewal scheduler"),
(name = "iconfig", description = "BSI TR-03109-1 initial config"),
(name = "alerts", description = "Operator alerts"),
),
)]
pub struct ApiDoc;
fn build() -> OpenApiRouter<HttpState> {
OpenApiRouter::with_openapi(ApiDoc::openapi())
.nest("/auth", auth::router())
.nest("/certs", certs::router())
.nest("/gateways", gateways::router())
.nest("/config", config::router())
.nest("/scheduler", scheduler::router())
.nest("/iconfig", iconfig::router())
.nest("/alerts", alerts::router())
}
/// Build the OpenAPI document without instantiating runtime state. Used for
/// static spec emission feeding the frontend client generator.
pub fn openapi_spec() -> utoipa::openapi::OpenApi {
let (_, api) = build().split_for_parts();
api
}
/// Mount `/api/*` and emit OpenAPI at `/api/openapi.json`.
pub fn router(state: HttpState) -> Router {
let (router, api) = build().split_for_parts();
let router = router.with_state(state);
let openapi_json = serde_json::to_string(&api).expect("serialize openapi");
let openapi_arc = Arc::new(openapi_json);
router.route(
"/openapi.json",
axum::routing::get({
let doc = openapi_arc.clone();
move || {
let doc = doc.clone();
async move {
(
[(axum::http::header::CONTENT_TYPE, "application/json")],
doc.as_str().to_string(),
)
}
}
}),
)
}
pub fn cors_layer(allowed_origin: Option<String>) -> CorsLayer {
let base = CorsLayer::new()
.allow_credentials(true)
.allow_methods([
axum::http::Method::GET,
axum::http::Method::POST,
axum::http::Method::PUT,
axum::http::Method::DELETE,
axum::http::Method::OPTIONS,
])
.allow_headers([
axum::http::header::CONTENT_TYPE,
axum::http::header::AUTHORIZATION,
axum::http::HeaderName::from_static("x-forwarded-cert-subject"),
]);
match allowed_origin {
Some(origin) => base.allow_origin(
origin
.parse::<axum::http::HeaderValue>()
.map(AllowOrigin::exact)
.unwrap_or_else(|_| AllowOrigin::any()),
),
None => base.allow_origin(AllowOrigin::any()),
}
}
+96
View File
@@ -0,0 +1,96 @@
use axum::extract::State;
use axum::Json;
use serde::Deserialize;
use utoipa::ToSchema;
use utoipa_axum::router::OpenApiRouter;
use utoipa_axum::routes;
use super::error::{ApiResponseError, ApiResult};
use crate::http::HttpState;
use crate::state::SchedulerState;
pub fn router() -> OpenApiRouter<HttpState> {
OpenApiRouter::new()
.routes(routes!(get_status))
.routes(routes!(trigger_run))
.routes(routes!(set_paused))
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct PauseRequest {
pub paused: bool,
}
#[utoipa::path(
get,
path = "",
tag = "scheduler",
responses(
(status = 200, description = "Scheduler state", body = SchedulerState),
)
)]
pub async fn get_status(State(state): State<HttpState>) -> Json<SchedulerState> {
Json(state.scheduler.read().await.clone())
}
/// Run renewal once, out of band. Honours the same overlap-lock as the cron job.
#[utoipa::path(
post,
path = "/trigger",
tag = "scheduler",
responses(
(status = 202, description = "Run accepted"),
(status = 409, description = "Run already in progress", body = super::error::ApiError),
)
)]
pub async fn trigger_run(State(state): State<HttpState>) -> ApiResult<()> {
let days = state.scheduler.read().await.days_window;
let renew = state.renew.clone();
let sched = state.scheduler.clone();
if state.run_lock.clone().try_acquire_owned().is_err() {
return Err(ApiResponseError {
status: axum::http::StatusCode::CONFLICT,
body: super::error::ApiError::new("run_in_progress", "previous run still active"),
});
}
tokio::spawn(async move {
let permit = state.run_lock.clone().acquire_owned().await;
let started = time::OffsetDateTime::now_utc();
let outcome = renew.run(days).await;
let mut s = sched.write().await;
s.last_run_at = Some(started);
match outcome {
Ok(n) => {
s.last_run_ok = Some(true);
s.last_handled = Some(n);
s.last_error = None;
}
Err(e) => {
s.last_run_ok = Some(false);
s.last_error = Some(e.to_string());
}
}
drop(permit);
});
Ok(())
}
#[utoipa::path(
post,
path = "/pause",
tag = "scheduler",
request_body = PauseRequest,
responses(
(status = 200, description = "Pause state updated", body = SchedulerState),
)
)]
pub async fn set_paused(
State(state): State<HttpState>,
Json(body): Json<PauseRequest>,
) -> Json<SchedulerState> {
let mut s = state.scheduler.write().await;
s.paused = body.paused;
Json(s.clone())
}
+45
View File
@@ -0,0 +1,45 @@
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use super::HttpState;
/// TR-03129-4 Callback-Endpunkt.
///
/// SECURITY: Vor dem Aufruf der Domäne MÜSSEN geprüft werden:
/// 1. mTLS-Client-Cert der CA (Server-seitig terminiert oder per
/// Connection-Info).
/// 2. XML-Signatur des SOAP-Envelopes.
/// Beides ist hier noch nicht implementiert. Siehe docs/bsi-compliance.md §1.3.
pub async fn handler(State(state): State<HttpState>, body: String) -> impl IntoResponse {
let parsed = parse_callback(&body);
let (message_id, cert) = match parsed {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "callback parse failed");
return (StatusCode::BAD_REQUEST, "bad request");
}
};
match state.callback.handle(&message_id, &cert).await {
Ok(()) => (StatusCode::OK, "ok"),
Err(e) => {
tracing::error!(error = %e, "callback handler failed");
(StatusCode::INTERNAL_SERVER_ERROR, "error")
}
}
}
/// Naive Extraktion. Echte Impl. nutzt quick-xml und prüft den SOAP-Envelope.
fn parse_callback(body: &str) -> Result<(String, String), &'static str> {
let message_id = between(body, "<messageID>", "</messageID>").ok_or("missing messageID")?;
let cert = between(body, "<certificateSeq>", "</certificateSeq>")
.ok_or("missing certificateSeq")?;
Ok((message_id.to_string(), cert.to_string()))
}
fn between<'a>(s: &'a str, start: &str, end: &str) -> Option<&'a str> {
let i = s.find(start)? + start.len();
let j = s[i..].find(end)? + i;
Some(&s[i..j])
}
+5
View File
@@ -0,0 +1,5 @@
use axum::http::StatusCode;
pub async fn handler() -> (StatusCode, &'static str) {
(StatusCode::OK, "ok")
}
+41
View File
@@ -0,0 +1,41 @@
pub mod api;
pub mod callback;
pub mod health;
use std::sync::Arc;
use axum::routing::{get, post};
use axum::Router;
use tokio::sync::Semaphore;
use crate::ports::inbound::{HandleCaCallback, RenewExpiringCertificates};
use crate::ports::outbound::{ClockPort, NotificationPort, StoragePort};
use crate::state::{SharedAlerts, SharedConfig, SharedScheduler, SharedSessions};
/// State shared with every HTTP handler. Cheap to clone (Arc inside).
#[derive(Clone)]
pub struct HttpState {
pub callback: Arc<dyn HandleCaCallback>,
pub renew: Arc<dyn RenewExpiringCertificates>,
pub storage: Arc<dyn StoragePort>,
pub mail: Arc<dyn NotificationPort>,
pub clock: Arc<dyn ClockPort>,
pub config: SharedConfig,
pub scheduler: SharedScheduler,
pub alerts: SharedAlerts,
pub sessions: SharedSessions,
/// Single-flight guard reused by cron scheduler and manual trigger.
pub run_lock: Arc<Semaphore>,
/// In dev (no reverse proxy) accept a `dev_subject` field in /api/auth/session.
pub dev_auth: bool,
}
pub fn router(state: HttpState, cors_origin: Option<String>) -> Router {
let api_router = api::router(state.clone()).layer(api::cors_layer(cors_origin));
Router::new()
.route("/health", get(health::handler))
.route("/pki/callback", post(callback::handler))
.with_state(state)
.nest("/api", api_router)
}
+29
View File
@@ -0,0 +1,29 @@
mod adapters;
mod app;
mod builders;
mod domain;
mod http;
mod ports;
mod scheduler;
mod state;
mod usecases;
use anyhow::Result;
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
if args.iter().any(|a| a == "--emit-openapi") {
let spec = http::api::openapi_spec();
let json = serde_json::to_string_pretty(&spec)?;
println!("{json}");
return Ok(());
}
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.init();
app::run().await
}
+20
View File
@@ -0,0 +1,20 @@
use async_trait::async_trait;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum UseCaseError {
#[error("dependency failed: {0}")]
Dependency(String),
#[error("invariant violated: {0}")]
Invariant(String),
}
#[async_trait]
pub trait RenewExpiringCertificates: Send + Sync {
async fn run(&self, days_window: u32) -> Result<usize, UseCaseError>;
}
#[async_trait]
pub trait HandleCaCallback: Send + Sync {
async fn handle(&self, message_id: &str, cert_pem: &str) -> Result<(), UseCaseError>;
}
+2
View File
@@ -0,0 +1,2 @@
pub mod inbound;
pub mod outbound;
+83
View File
@@ -0,0 +1,83 @@
use async_trait::async_trait;
use thiserror::Error;
use time::OffsetDateTime;
use crate::domain::certificate::{Certificate, CertificateRequest};
use crate::domain::gateway::Gateway;
#[derive(Debug, Error)]
pub enum HsmError {
#[error("pkcs11 session error: {0}")]
Session(String),
#[error("key not found: {0}")]
KeyNotFound(String),
#[error("sign failed: {0}")]
Sign(String),
#[error("other: {0}")]
Other(String),
}
#[derive(Debug, Error)]
pub enum CaError {
#[error("transport error: {0}")]
Transport(String),
#[error("soap fault: {0}")]
SoapFault(String),
#[error("malformed response: {0}")]
Malformed(String),
}
#[derive(Debug, Error)]
pub enum StorageError {
#[error("not found")]
NotFound,
#[error("backend error: {0}")]
Backend(String),
}
#[derive(Debug, Error)]
pub enum NotificationError {
#[error("send failed: {0}")]
Send(String),
}
pub trait HsmPort: Send + Sync {
fn generate_key_pair(&self, label: &str) -> Result<String, HsmError>;
fn sign_csr(&self, key_id: &str, payload: &[u8]) -> Result<Vec<u8>, HsmError>;
fn sign_xml(&self, key_id: &str, xml_data: &str) -> Result<String, HsmError>;
}
#[async_trait]
pub trait CertificateCaPort: Send + Sync {
async fn request_certificate(&self, csr: CertificateRequest) -> Result<String, CaError>;
}
#[async_trait]
pub trait StoragePort: Send + Sync {
async fn get_expiring_certificates(
&self,
now: OffsetDateTime,
days_left: u32,
) -> Result<Vec<Certificate>, StorageError>;
async fn list_certificates(&self) -> Result<Vec<Certificate>, StorageError>;
async fn list_gateways(&self) -> Result<Vec<Gateway>, StorageError>;
async fn save_pending_request(
&self,
message_id: &str,
gateway_id: &str,
) -> Result<(), StorageError>;
async fn update_certificate(
&self,
gateway_id: &str,
new_cert_pem: &str,
) -> Result<(), StorageError>;
}
#[async_trait]
pub trait NotificationPort: Send + Sync {
async fn send_alert(&self, subject: &str, body: &str) -> Result<(), NotificationError>;
}
pub trait ClockPort: Send + Sync {
fn now(&self) -> OffsetDateTime;
}
+76
View File
@@ -0,0 +1,76 @@
use std::sync::Arc;
use anyhow::{Context, Result};
use time::OffsetDateTime;
use tokio::sync::Semaphore;
use tokio_cron_scheduler::{Job, JobScheduler};
use crate::ports::inbound::RenewExpiringCertificates;
use crate::state::SharedScheduler;
pub struct SchedulerHandle {
sched: JobScheduler,
}
impl SchedulerHandle {
pub async fn shutdown(mut self) -> Result<()> {
self.sched.shutdown().await.context("scheduler shutdown")?;
Ok(())
}
}
pub async fn start(
cron_expr: &str,
days_window: u32,
renew: Arc<dyn RenewExpiringCertificates>,
state: SharedScheduler,
run_lock: Arc<Semaphore>,
) -> Result<SchedulerHandle> {
let sched = JobScheduler::new().await.context("scheduler new")?;
let renew_for_job = renew.clone();
let lock_for_job = run_lock.clone();
let state_for_job = state.clone();
let job = Job::new_async(cron_expr, move |_uuid, _l| {
let renew = renew_for_job.clone();
let lock = lock_for_job.clone();
let state = state_for_job.clone();
Box::pin(async move {
if state.read().await.paused {
tracing::info!("renew job paused — skipping tick");
return;
}
let _permit = match lock.try_acquire() {
Ok(p) => p,
Err(_) => {
tracing::warn!("renew job overlap — previous run still active, skipping");
return;
}
};
let started = OffsetDateTime::now_utc();
let outcome = renew.run(days_window).await;
let mut s = state.write().await;
s.last_run_at = Some(started);
match outcome {
Ok(n) => {
tracing::info!(handled = n, "renew run finished");
s.last_run_ok = Some(true);
s.last_handled = Some(n);
s.last_error = None;
}
Err(e) => {
tracing::error!(error = %e, "renew run failed");
s.last_run_ok = Some(false);
s.last_error = Some(e.to_string());
}
}
})
})
.context("build renew job")?;
sched.add(job).await.context("scheduler add")?;
sched.start().await.context("scheduler start")?;
tracing::info!(cron = cron_expr, days_window, "scheduler up");
Ok(SchedulerHandle { sched })
}
+152
View File
@@ -0,0 +1,152 @@
use std::collections::HashMap;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use tokio::sync::RwLock;
use utoipa::ToSchema;
/// Mutable runtime config. Seeded from env on boot; UI may override at runtime
/// for fields flagged `hot_reload`. Restart-only fields (BIND_ADDR) are read
/// but cannot be applied without restart.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct RuntimeConfig {
pub bind_addr: String,
pub cron_schedule: String,
pub days_window: u32,
pub database_url: String,
pub sub_ca: SubCaConfig,
pub smtp: SmtpConfig,
pub hsm: HsmConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SubCaConfig {
pub endpoint: String,
pub client_cert_path: Option<String>,
pub client_key_path: Option<String>,
pub ca_bundle_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SmtpConfig {
pub host: String,
pub port: u16,
pub from: String,
pub to: String,
pub starttls: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct HsmConfig {
pub module_path: String,
pub slot: Option<u64>,
pub pin_env_var: String,
}
impl RuntimeConfig {
pub fn from_env() -> Self {
Self {
bind_addr: std::env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:8443".into()),
cron_schedule: std::env::var("CRON_SCHEDULE").unwrap_or_else(|_| "0 0 3 * * *".into()),
days_window: std::env::var("DAYS_WINDOW")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(30),
database_url: std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "sqlite::memory:".into()),
sub_ca: SubCaConfig {
endpoint: std::env::var("SUB_CA_ENDPOINT")
.unwrap_or_else(|_| "https://test-ca.local/soap".into()),
client_cert_path: std::env::var("SUB_CA_CLIENT_CERT").ok(),
client_key_path: std::env::var("SUB_CA_CLIENT_KEY").ok(),
ca_bundle_path: std::env::var("SUB_CA_BUNDLE").ok(),
},
smtp: SmtpConfig {
host: std::env::var("SMTP_HOST").unwrap_or_else(|_| "smtp.local".into()),
port: std::env::var("SMTP_PORT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(587),
from: std::env::var("SMTP_FROM").unwrap_or_else(|_| "pki-bot@local".into()),
to: std::env::var("SMTP_TO").unwrap_or_else(|_| "ops@local".into()),
starttls: std::env::var("SMTP_STARTTLS")
.map(|v| v != "0")
.unwrap_or(true),
},
hsm: HsmConfig {
module_path: std::env::var("HSM_MODULE")
.unwrap_or_else(|_| "/usr/lib/softhsm/libsofthsm2.so".into()),
slot: std::env::var("HSM_SLOT").ok().and_then(|s| s.parse().ok()),
pin_env_var: std::env::var("HSM_PIN_ENV").unwrap_or_else(|_| "HSM_PIN".into()),
},
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
pub struct SchedulerState {
pub cron_schedule: String,
pub days_window: u32,
pub paused: bool,
#[serde(with = "time::serde::rfc3339::option")]
pub last_run_at: Option<OffsetDateTime>,
pub last_run_ok: Option<bool>,
pub last_error: Option<String>,
pub last_handled: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AlertEntry {
#[serde(with = "time::serde::rfc3339")]
pub at: OffsetDateTime,
pub severity: AlertSeverity,
pub subject: String,
pub body: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum AlertSeverity {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct Session {
pub id: String,
pub subject: String,
#[serde(with = "time::serde::rfc3339")]
pub issued_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub expires_at: OffsetDateTime,
}
#[derive(Default)]
pub struct SessionStore {
inner: HashMap<String, Session>,
}
impl SessionStore {
pub fn insert(&mut self, s: Session) {
self.inner.insert(s.id.clone(), s);
}
pub fn get(&self, id: &str) -> Option<&Session> {
self.inner.get(id)
}
pub fn remove(&mut self, id: &str) {
self.inner.remove(id);
}
pub fn gc(&mut self, now: OffsetDateTime) {
self.inner.retain(|_, s| s.expires_at > now);
}
}
pub type SharedConfig = Arc<RwLock<RuntimeConfig>>;
pub type SharedScheduler = Arc<RwLock<SchedulerState>>;
pub type SharedAlerts = Arc<RwLock<Vec<AlertEntry>>>;
pub type SharedSessions = Arc<RwLock<SessionStore>>;
+20
View File
@@ -0,0 +1,20 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::ports::inbound::{HandleCaCallback, UseCaseError};
use crate::ports::outbound::StoragePort;
pub struct CallbackService {
pub storage: Arc<dyn StoragePort>,
}
#[async_trait]
impl HandleCaCallback for CallbackService {
async fn handle(&self, message_id: &str, cert_pem: &str) -> Result<(), UseCaseError> {
tracing::info!(message_id, len = cert_pem.len(), "callback received");
// TODO: pending lookup über message_id, dann update_certificate
let _ = self.storage.as_ref();
Ok(())
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod callback;
pub mod renew;
+32
View File
@@ -0,0 +1,32 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::ports::inbound::{RenewExpiringCertificates, UseCaseError};
use crate::ports::outbound::{
CertificateCaPort, ClockPort, HsmPort, NotificationPort, StoragePort,
};
pub struct RenewService {
pub storage: Arc<dyn StoragePort>,
pub ca: Arc<dyn CertificateCaPort>,
pub hsm: Arc<dyn HsmPort>,
pub clock: Arc<dyn ClockPort>,
pub notifier: Arc<dyn NotificationPort>,
}
#[async_trait]
impl RenewExpiringCertificates for RenewService {
async fn run(&self, days_window: u32) -> Result<usize, UseCaseError> {
let now = self.clock.now();
let expiring = self
.storage
.get_expiring_certificates(now, days_window)
.await
.map_err(|e| UseCaseError::Dependency(e.to_string()))?;
tracing::info!(count = expiring.len(), days_window, "renew scan");
// TODO: für jedes Cert: keypair -> CSR -> sign -> SOAP RequestCertificate
Ok(expiring.len())
}
}
+29
View File
@@ -0,0 +1,29 @@
# Copy to .env (compose picks it up automatically). All values are overridable
# at deploy time; defaults in compose.yaml are sensible for lab use.
# ─── Web exposure ────────────────────────────────────────────────────────────
WEB_PORT=8080
CORS_ALLOW_ORIGIN=http://localhost:8080
# ─── Auth (LAB ONLY) ─────────────────────────────────────────────────────────
# When DEV_AUTH=1 the backend accepts a `dev_subject` in the login body
# instead of requiring the reverse-proxy header. Switch to 0 once mTLS
# termination is wired in front of nginx.
DEV_AUTH=1
# ─── Scheduler ───────────────────────────────────────────────────────────────
# 6-field cron: sec min hour day month weekday
CRON_SCHEDULE=0 0 3 * * *
DAYS_WINDOW=30
# ─── Storage ─────────────────────────────────────────────────────────────────
DATABASE_URL=sqlite:///data/smgw.db?mode=rwc
# ─── Sub-CA (TR-03129-4) ─────────────────────────────────────────────────────
SUB_CA_ENDPOINT=https://test-ca.local/soap
# ─── HSM (SoftHSMv2 inside container) ────────────────────────────────────────
HSM_MODULE=/usr/lib/softhsm/libsofthsm2.so
# ─── Logging ─────────────────────────────────────────────────────────────────
RUST_LOG=info,smgw_pki_automator=debug
+50
View File
@@ -0,0 +1,50 @@
name: smgw-pki
services:
backend:
image: smgw-pki-automator:dev
build:
context: ../backend
dockerfile: Dockerfile
container_name: smgw-pki-automator
environment:
RUST_LOG: ${RUST_LOG:-info,smgw_pki_automator=debug}
BIND_ADDR: 0.0.0.0:8443
CRON_SCHEDULE: ${CRON_SCHEDULE:-0 0 3 * * *}
DAYS_WINDOW: ${DAYS_WINDOW:-30}
DATABASE_URL: ${DATABASE_URL:-sqlite:///data/smgw.db?mode=rwc}
CORS_ALLOW_ORIGIN: ${CORS_ALLOW_ORIGIN:-http://localhost:8080}
DEV_AUTH: ${DEV_AUTH:-1}
SUB_CA_ENDPOINT: ${SUB_CA_ENDPOINT:-https://test-ca.local/soap}
HSM_MODULE: ${HSM_MODULE:-/usr/lib/softhsm/libsofthsm2.so}
volumes:
- backend_data:/data
- softhsm_tokens:/var/lib/softhsm/tokens
expose:
- "8443"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8443/health"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
frontend:
image: smgw-pki-console:dev
build:
context: ../frontend
dockerfile: Dockerfile
container_name: smgw-pki-console
depends_on:
backend:
condition: service_healthy
ports:
- "${WEB_PORT:-8080}:80"
volumes:
# nginx.conf lives next to compose.yaml so ops can iterate without
# rebuilding the frontend image. Reload via `just nginx-reload`.
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
volumes:
backend_data:
softhsm_tokens:
+44
View File
@@ -0,0 +1,44 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA: route everything that isn't a real file back to index.html so
# TanStack Router handles deep links.
location / {
try_files $uri $uri/ /index.html;
}
# API → Rust backend. The compose network name `backend` resolves to the
# smgw-pki-automator service.
location /api/ {
proxy_pass http://backend:8443/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# mTLS terminating proxy in front of nginx is expected to set this
# header. In dev (DEV_AUTH=1) the backend accepts a body field instead.
proxy_set_header X-Forwarded-Cert-Subject $http_x_forwarded_cert_subject;
proxy_buffering off;
proxy_read_timeout 300s;
}
# PKI callback path (TR-03129-4). Kept separate so it can be moved behind
# a different listener that requires a Sub-CA client certificate.
location /pki/ {
proxy_pass http://backend:8443/pki/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Long-cache hashed assets.
location ~* \.(js|css|woff2?|svg|png|webp|avif)$ {
expires 7d;
access_log off;
try_files $uri =404;
}
}
+198
View File
@@ -0,0 +1,198 @@
# Architektur
## Leitprinzip — Hexagonale Architektur
Die Anwendung folgt dem Muster **Ports & Adapters** (Alistair Cockburn). Ziel
ist die strikte Trennung der fachlichen Logik (Domain) von technischer
Infrastruktur (HSM, CA, DB, SMTP, HTTP). Konsequenzen:
- Die Domäne ist frei von asynchroner Laufzeit, ORM-Typen, HTTP-Clients und
XML-Bibliotheken. Sie enthält reine Daten und reine Funktionen.
- Use-Cases ("Anwendungsdienste") orchestrieren die Domäne, indem sie Ports
konsumieren.
- Adapter sind austauschbar. Für Tests gibt es In-Memory-Adapter; für die
Lab-Umgebung gibt es SoftHSM-/SQLite-/SMTP-/SOAP-Adapter.
```
┌──────────────────────────────────────┐
│ Domain │
│ Certificate, Gateway, Policies │
└───────────────▲──────────────────────┘
┌────────────────────┴──────────────────────┐
│ Ports │
│ Inbound (UseCases) Outbound (Traits) │
└─────▲─────────────────────────▲───────────┘
│ │
┌────────────────┴─────┐ ┌─────────┴─────────────────┐
│ Inbound │ │ Outbound │
│ (treibt die App) │ │ (wird von der App │
│ │ │ getrieben) │
│ • Axum HTTP-Server │ │ • SoftHsmAdapter │
│ • Tokio-Cron │ │ • SubCaSoapAdapter │
│ │ │ • SqliteAdapter │
│ │ │ • SmtpAdapter │
│ │ │ • SystemClock │
└──────────────────────┘ └───────────────────────────┘
```
## Schichten
### Domain (`src/domain/`)
Keine externen Abhängigkeiten außer `time` (Datum/Zeit-Typen).
- `certificate.rs``Certificate`, `CertificateRequest`, `CertificateUsage`,
reine Methoden wie `days_until_expiry`, `is_expiring_within`.
- `gateway.rs``Gateway`.
### Ports (`src/ports/`)
Traits, die die Domäne nach außen exponiert bzw. von außen erwartet.
**Outbound-Ports** (`outbound.rs`) — was die Domäne von der Außenwelt braucht:
| Port | Zweck |
| ------------------- | ------------------------------------------- |
| `HsmPort` | Keypair-Generierung, CSR- und XML-Signatur. |
| `CertificateCaPort` | Asynchrone Zertifikatsanfrage an Sub-CA. |
| `StoragePort` | Persistenz von Zertifikaten + Pending Reqs. |
| `NotificationPort` | Versand von Alert-Mails. |
| `ClockPort` | Testbare Zeitquelle für Ablauflogik. |
Jeder Port hat seinen eigenen Fehlertyp (`HsmError`, `CaError`,
`StorageError`, `NotificationError`) via `thiserror`. Auf Use-Case-Ebene wird
auf `UseCaseError` aggregiert; auf App-Ebene auf `anyhow::Result`.
**Inbound-Ports** (`inbound.rs`) — was die Domäne anbietet:
- `RenewExpiringCertificates` — wird vom Cron getrieben.
- `HandleCaCallback` — wird vom HTTP-Webhook getrieben.
### Adapters (`src/adapters/`)
Konkrete Implementierungen der Outbound-Ports:
| Adapter | Crates | Anmerkung |
| -------------------- | ---------------------------- | ------------------------------------------------------ |
| `SoftHsmAdapter` | `cryptoki` | PKCS#11. Blocking-Aufrufe via `spawn_blocking`. |
| `SubCaSoapAdapter` | `reqwest` (rustls), `quick-xml` | mTLS-Client. SOAP per Hand (kleine Surface). |
| `SqliteAdapter` | `sqlx` | Migrationen unter `migrations/`. |
| `SmtpAdapter` | `lettre` | SMTP via Tokio + rustls. |
| `SystemClock` | `time` | Triviale `now()`-Implementierung. |
### Builders (`src/builders/`)
Kapseln das Erzeugen komplexer Payloads:
- `SoapRequestBuilder` — TR-03129-4 `RequestCertificate`-Envelope inkl.
`callbackIndicator=callback_possible`, eindeutiger `messageID`,
Base64-kodierter CSR im Feld `certReq`.
- `InitialConfigBuilder` — Erzeugt `iconfig.xml` (TR-03109-1) und ruft den
`HsmPort` zur Signatur (`iconfig.sig`) auf. Wichtig: Signatur erfolgt auf
den kanonischen (C14N) Bytes, **nicht** auf pretty-printed XML.
### Composition Root (`src/app.rs`)
`AppState` hält `Arc<dyn Port>` für alle Outbound-Ports. `app::run()` baut die
Adapter, wired sie in den AppState, startet Axum-Router und Tokio-Cron.
## Datenflüsse
### Zertifikatserneuerung (Cron-getrieben)
```
Cron ──► RenewExpiringCertificates::run(days=30)
├─► StoragePort::get_expiring_certificates(now, 30)
└─► für jedes Cert:
├─► HsmPort::generate_key_pair("gw-<id>-<usage>")
├─► CSR bauen, HsmPort::sign_csr(...)
├─► SoapRequestBuilder::build_request_certificate(...)
├─► CertificateCaPort::request_certificate(csr)
│ └─ liefert messageID
└─► StoragePort::save_pending_request(messageID, gateway_id)
```
Die CA antwortet synchron nur mit `returnCode=ok_syntax`. Das eigentliche
Zertifikat kommt asynchron über den Callback.
### Callback-Annahme (HTTP-getrieben)
```
CA ──► POST /pki/callback (mTLS, SOAP)
└─► Axum Handler
├─► mTLS-Client-Cert prüfen
├─► SOAP-Signatur prüfen
├─► messageID + certificateSeq extrahieren
└─► HandleCaCallback::handle(messageID, certificateSeq)
├─► StoragePort: pending → resolved
├─► StoragePort::update_certificate(...)
└─► NotificationPort::send_alert(...) bei Fehler
```
**Polling ist laut BSI verboten.** Der Callback ist der einzige Weg.
### Initial-Konfiguration
```
CLI/HTTP-Trigger ──► InitialConfigBuilder
├─► XML bauen (quick-xml)
├─► C14N
├─► HsmPort::sign_xml(GWADM_SIG_PRV-Label, c14n)
│ └─ liefert iconfig.sig (XML-DSig)
└─► tar(iconfig.xml, iconfig.sig) → iconfig.tar
```
## Querschnittsthemen
### Fehlerbehandlung
- Adapter werfen ihren eigenen `thiserror`-Typ.
- Use-Cases mappen auf `UseCaseError`.
- `main.rs` / `app::run` arbeitet mit `anyhow::Result`.
- Keine `Result<_, String>` in Ports — strukturierte Fehler sind testbar und
matchbar.
### Async-Modell
- `HsmPort` ist **synchron**, weil PKCS#11 nativ blockierend ist. Adapter
ruft `tokio::task::spawn_blocking` an den richtigen Stellen.
- Alle anderen Ports sind `async_trait`.
- Eine Tokio-Runtime trägt Axum und Cron. `tokio-cron-scheduler` läuft im
selben Runtime.
### Nebenläufigkeit der Cron-Jobs
`tokio-cron-scheduler` startet einen Job auch dann, wenn der vorherige Lauf
noch läuft. Erneuerungs-Job daher mit `Semaphore(1)` schützen, um doppelte
`RequestCertificate`-Aufrufe für dasselbe Gateway zu verhindern.
### Testbarkeit
- Domäne wird ohne Adapter getestet.
- Pro Port existiert ein In-Memory-Adapter unter `#[cfg(test)]`.
- Integrationstests gegen SoftHSM2 laufen in Docker (siehe
[`development.md`](development.md)).
## Umsetzungsreihenfolge
1. **Domain + Ports** stehen — Code kompiliert.
2. **In-Memory-Adapter** für jeden Port → erste Use-Case-Tests grün.
3. **`SoftHsmAdapter`** — riskantestes Stück, früh validieren.
4. **`SoapRequestBuilder` + Mock-CA** (Wiremock o.ä.).
5. **`SqliteAdapter`** + Migrationen.
6. **Axum** Router (Callback + GUI) + Cron-Wiring.
7. **`SmtpAdapter`** zuletzt.
## Bewusste Nicht-Entscheidungen
- **Kein WSDL-Codegen.** Die `RequestCertificate`-Surface ist klein genug,
um per Hand mit `quick-xml` zu bauen. Eingespart: ein zerbrechlicher
Codegen-Schritt.
- **Kein DI-Framework.** `Arc<dyn Trait>` im `AppState` reicht.
- **XML-DSig nicht in reinem Rust.** Wir wrappen das `xmlsec1`-CLI im
`SoftHsmAdapter` (über Pipes), bis ein reifer Rust-Wrapper für `libxmlsec1`
verfügbar ist. Dokumentiert in [`bsi-compliance.md`](bsi-compliance.md).
+141
View File
@@ -0,0 +1,141 @@
# BSI-Konformität
Mapping der Vorgaben aus den BSI-Richtlinien auf die Code-Stellen in diesem
Projekt. Quelle aller Anforderungen: **BSI TR-03129-4**, **BSI TR-03109-1**
und **SM-PKI Certificate Policy**.
## 1. TR-03129-4 — Schnittstelle zur Zertifizierungsstelle
### 1.1 Transportsicherheit (mTLS)
| Anforderung | Code-Stelle |
| ------------------------------------------------------------------ | -------------------------------------- |
| SOAP-Nachrichten enthalten **keine** Auth-Daten. | `builders/soap_req.rs` |
| Autorisierung + Verschlüsselung **ausschließlich** via mTLS. | `adapters/sub_ca.rs` |
| Client-Cert (EMT-Testzertifikat) konfiguriert in `reqwest`. | `SubCaSoapAdapter::new(...)` |
| Server-Cert der CA gegen vertrauten CA-Trust-Store verifiziert. | `SubCaSoapAdapter::new(...)` |
Konkret in `reqwest`:
```rust
reqwest::ClientBuilder::new()
.use_rustls_tls()
.identity(reqwest::Identity::from_pem(client_pem_bundle)?)
.add_root_certificate(reqwest::Certificate::from_pem(ca_pem)?)
.build()?
```
### 1.2 WSDL-Konformität
| Anforderung | Code-Stelle |
| -------------------------------------------------------- | ------------------------- |
| Envelope folgt der offiziellen BSI-WSDL. | `builders/soap_req.rs` |
| Operation `RequestCertificate` mit korrektem Namespace. | `SoapRequestBuilder::build_request_certificate` |
| CSR wird Base64-kodiert im Feld `certReq` übertragen. | dito |
Bewusste Entscheidung: **kein WSDL-Codegen**. Die Surface ist klein,
`quick-xml` reicht; Codegen-Crates für Rust sind unausgereift und brittle.
### 1.3 Asynchrone Callbacks (Polling-Verbot)
| Anforderung | Code-Stelle |
| ------------------------------------------------------ | -------------------------------------------------- |
| `callbackIndicator = callback_possible` im Request. | `SoapRequestBuilder::build_request_certificate` |
| Eindeutige `messageID` pro Anfrage. | `SoapRequestBuilder::message_id` |
| Synchroner Response enthält i.d.R. nur `returnCode`. | `SubCaSoapAdapter::request_certificate` |
| Webhook nimmt das fertige Zertifikat entgegen. | Axum-Handler (folgt; `POST /pki/callback`) |
| Zuordnung Callback → Request über `messageID`. | `StoragePort::save_pending_request` / Lookup |
| Zertifikatskette aus `certificateSeq` extrahieren. | Axum-Handler / `HandleCaCallback` |
**Sicherheitspunkt:** `messageID` allein ist **kein** Trust-Boundary. Der
Callback-Handler muss zusätzlich:
1. mTLS-Client-Cert der CA prüfen.
2. SOAP-Signatur der Nachricht prüfen.
Erst dann darf in der DB nachgeschlagen werden.
### 1.4 Datenfelder
| Feld | Inhalt | Quelle |
| ----------------- | ------------------------------------------ | ----------------------------------- |
| `certReq` | Base64(CSR-DER) — vom HSM erzeugt. | `HsmPort::sign_csr` |
| `messageID` | UUIDv4, persistiert in `pending_requests`. | `SoapRequestBuilder::message_id` |
| `callbackIndicator` | `callback_possible` | `SoapRequestBuilder` |
| `certificateSeq` | Antwort der CA via Callback. | Axum-Handler |
## 2. TR-03109-1 — Initial-Konfiguration
### 2.1 Dateiformat `iconfig.tar`
| Pflicht-Inhalt | Code-Stelle |
| ----------------------------------------------- | ------------------------------------ |
| `iconfig.xml` — Konfiguration. | `builders/iconfig.rs` |
| `iconfig.sig` — XML-DSig über `iconfig.xml`. | `builders/iconfig.rs` + `HsmPort` |
| Beide Dateien in einem Tar-Archiv verpackt. | `builders/iconfig.rs` (Tar-Stufe) |
### 2.2 Inhalt der `iconfig.xml`
Pflichtbestandteile:
- Admin-Zertifikate (öffentliche Teile).
- CA-Zertifikatskette.
- Netzwerkprofil (IP-Access für WAN).
- Kommunikationsprofil (TLS-Admin für WAN-Schnittstelle).
Generierung im `InitialConfigBuilder` via `quick-xml`. Eingangsdaten aus der
Domäne (`Gateway`) bzw. aus Konfig-Files.
### 2.3 Kryptografische Signatur `iconfig.sig`
| Anforderung | Code-Stelle |
| -------------------------------------------------------------------- | ------------------------------------ |
| Signatur mit `GWADM_SIG_PRV` (privater Signaturschlüssel des GWA). | `HsmPort::sign_xml` |
| Schlüssel liegt im HSM; Zugriff via PKCS#11. | `adapters/hsm.rs` (`cryptoki`) |
| Signatur über **kanonische** Bytes (XML-C14N). | `InitialConfigBuilder` |
**Implementierungs-Hinweis (XML-DSig):**
Reines Rust für XML-DSig ist nicht reif. Optionen:
1. `xmlsec1` CLI über `std::process::Command` aus dem Adapter aufrufen
(pragmatischer Start).
2. `libxmlsec1` über FFI binden.
3. C14N + Signatur manuell via `cryptoki` zusammensetzen (fehleranfällig
wegen exakter Canonicalization).
Wir starten mit Option 1 und kapseln das vollständig hinter `HsmPort::sign_xml`,
damit ein späterer Wechsel keinen Domänen-Code anfasst.
## 3. SM-PKI Certificate Policy
### 3.1 Schlüsselspeicherung
| Anforderung | Code-Stelle |
| ------------------------------------------------------------ | ------------------------- |
| Alle Teilnehmer-Schlüssel in HSMs ≥ "Security Level 1". | `adapters/hsm.rs` |
| SoftHSMv2 erfüllt SL1 **nur** für Entwicklung. | siehe Sicherheitshinweis |
In Produktion: zertifiziertes HSM (CC EAL4+) zwingend. Der `HsmPort` bleibt
identisch — nur das Backend tauscht.
### 3.2 Zertifikatslaufzeiten und Rotation
| Anforderung | Code-Stelle |
| -------------------------------------------------------- | ------------------------------------------ |
| Endentitäten/Sub-CAs in der Regel alle 2 Jahre. | Datenmodell `Certificate.not_after` |
| Erneuerung rechtzeitig vor Ablauf (Standard: 30 Tage). | `RenewExpiringCertificates::run(days=30)` |
| Asynchroner `RequestCertificate`-Flow. | siehe §1.3 |
| Testbare Zeitquelle. | `ClockPort` / `SystemClock` |
Das 30-Tage-Fenster ist Default, kein Gesetz — über Config einstellbar.
## 4. Offene Punkte
- Konkrete Werte der BSI-Namespace-URIs und Operationsnamen aus der aktuellen
WSDL-Version müssen in `builders/soap_req.rs` als Konstanten gepflegt
werden, sobald die WSDL aus dem BSI-Repo gezogen ist.
- Schema-Validierung der `iconfig.xml` gegen das BSI-XSD ist offen; sinnvoll
als zusätzlicher Adapter-Check vor dem Signieren.
- mTLS-Server (Axum für Callback) muss Client-Cert-Pinning auf die CA-Cert
durchsetzen — Konfiguration unter [`development.md`](development.md).
+153
View File
@@ -0,0 +1,153 @@
# Development Setup
Lokales Setup für die Lab-Umgebung. Ziel: Tool gegen einen SoftHSM2-Container
und eine Test-Sub-CA betreiben, ohne echte SMGW-Hardware.
## Voraussetzungen
- Rust ≥ 1.80 (`rustup`)
- Docker / Podman
- `xmlsec1` CLI (für die XML-DSig in `iconfig.sig`)
- OpenSSL CLI (für Testzertifikate)
Auf Debian/Ubuntu:
```bash
sudo apt install xmlsec1 libxmlsec1-dev openssl pkg-config
```
## Build & Run
```bash
cargo check
cargo run
```
Logs via `RUST_LOG`:
```bash
RUST_LOG=smgw_pki_automator=debug,info cargo run
```
## SoftHSMv2 als Container
`SoftHSMv2` per Docker laufen lassen. Beispiel (Pseudo-Compose, anpassen an
euer Setup):
```yaml
services:
softhsm:
image: ghcr.io/example/softhsm2:latest
volumes:
- ./tokens:/var/lib/softhsm/tokens
environment:
SOFTHSM2_CONF: /etc/softhsm2.conf
```
Token einmalig initialisieren:
```bash
softhsm2-util --init-token --slot 0 \
--label "smgw-lab" \
--pin 1234 --so-pin 5678
```
Im `app.rs`-Boot-Pfad wird der Adapter konstruiert mit:
```rust
SoftHsmAdapter::new("/usr/lib/softhsm/libsofthsm2.so", "1234")
```
Pfad zur PKCS#11-Lib hängt vom Container/OS ab.
## mTLS-Testzertifikate
Für die Kommunikation mit der Test-Sub-CA brauchen wir:
- **Client-Cert (EMT)** — wird im `reqwest`-Client als `Identity` geladen.
- **Server-Cert der CA** — wird als Trust-Root im Client geladen.
- **Eigenes Server-Cert** für den Axum-Callback-Endpunkt (mTLS-Server).
- **CA-Trust** für eingehende CA-Calls, damit das Client-Cert der CA gegen
diesen Trust verifiziert werden kann.
Test-PKI mit OpenSSL aufsetzen (vereinfacht):
```bash
mkdir -p certs && cd certs
# Test-Root
openssl req -x509 -newkey rsa:4096 -days 3650 -nodes \
-keyout test-root.key -out test-root.crt \
-subj "/CN=smgw-lab-root"
# Client (EMT)
openssl req -newkey rsa:4096 -nodes -keyout emt.key -out emt.csr \
-subj "/CN=emt-client"
openssl x509 -req -in emt.csr -CA test-root.crt -CAkey test-root.key \
-CAcreateserial -out emt.crt -days 825
# Server (CA-Mock + Axum-Callback)
openssl req -newkey rsa:4096 -nodes -keyout server.key -out server.csr \
-subj "/CN=ca.test.local"
openssl x509 -req -in server.csr -CA test-root.crt -CAkey test-root.key \
-CAcreateserial -out server.crt -days 825
```
PEM-Bundle für `reqwest::Identity::from_pem`:
```bash
cat emt.crt emt.key > emt-bundle.pem
```
## Datenbank
SQLite, default `sqlite::memory:` im Stub-Boot. Für Persistenz Pfad setzen:
```bash
DATABASE_URL=sqlite://./data/smgw.db cargo run
```
Migrationen (sobald angelegt) via `sqlx`:
```bash
cargo install sqlx-cli --no-default-features --features sqlite,rustls
sqlx migrate run
```
## SMTP
Für lokales Testing `MailHog` oder `Mailpit`:
```bash
docker run -d -p 1025:1025 -p 8025:8025 axllent/mailpit
```
`SmtpAdapter::new("localhost", 1025)`.
## Test-Sub-CA
Optionen:
1. **Mock-Server** mit `wiremock` für Integrationstests — antwortet auf
`RequestCertificate` mit `ok_syntax` und triggert anschließend einen
HTTP-Call gegen den eigenen Callback-Endpunkt. Kein BSI-Compliance-Ersatz,
aber gut für CI.
2. **Echte Test-Sub-CA**-Instanz (sofern verfügbar) — Adresse + Test-EMT-
Zertifikate aus eurem Lab-Bestand einsetzen.
## Tests
```bash
cargo test # Unit + In-Memory-Integration
cargo test --features it # Integration mit SoftHSM-Container (folgt)
```
Konvention: Tests liegen neben dem Code (`#[cfg(test)] mod tests`). Adapter-
Tests, die externe Dienste brauchen, hinter Cargo-Feature `it` gaten.
## Code-Style
- `cargo fmt` vor jedem Commit.
- `cargo clippy --all-targets -- -D warnings` muss grün sein.
- Keine `Result<_, String>` in Produktionscode. Strukturierte Fehler via
`thiserror`.
+10
View File
@@ -0,0 +1,10 @@
node_modules/
dist/
.vite/
.git/
.idea/
.vscode/
*.log
pnpm-lock.yaml
package-lock.json
yarn.lock
+4
View File
@@ -0,0 +1,4 @@
# Used by Vite during `pnpm dev`.
# Where to proxy /api/* during development. Default: the Rust binary running
# locally on the default BIND_ADDR.
VITE_API_PROXY_TARGET=http://localhost:8443
+25
View File
@@ -0,0 +1,25 @@
node_modules/
dist/
.vite/
.tanstack/
.tsbuildinfo
.env
.env.local
.env.*.local
*.log
# Claude / editor / OS noise
.claude/
.idea/
.vscode/
*.swp
.DS_Store
# Lockfiles from other package managers — bun.lock is the source of truth
pnpm-lock.yaml
package-lock.json
yarn.lock
# Generated client schema — leave the latest in repo for reproducible builds,
# regenerate via `pnpm gen:api`.
# src/api/schema.d.ts
+19
View File
@@ -0,0 +1,19 @@
# syntax=docker/dockerfile:1.7
FROM oven/bun:1-alpine AS builder
WORKDIR /app
COPY package.json bun.lock* bun.lockb* ./
RUN bun install --frozen-lockfile
COPY tsconfig*.json vite.config.ts index.html ./
COPY openapi.json ./
COPY public ./public
COPY src ./src
RUN bun run gen:api && bun run build
# Runtime image is intentionally generic — the nginx config is volume-mounted
# from ../deploy/nginx.conf by compose so ops can iterate without rebuilds.
FROM nginx:1.27-alpine AS runtime
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
+88
View File
@@ -0,0 +1,88 @@
# smgw-pki-console
Web-Konsole für den `smgw-pki-automator`. React + TypeScript + Vite. Typisierter
API-Client wird aus der utoipa-generierten OpenAPI-Spezifikation gebaut.
## Stack
- React 19 + TypeScript (strict) + Vite 6
- TanStack Router (file-based) + TanStack Query
- Tailwind v4 + shadcn-Stil Primitives auf Radix
- `openapi-typescript` + `openapi-fetch` für typisierten Backend-Client
- Sonner für Toasts, Lucide-Icons
## Layout
```
src/
├── api/schema.d.ts Generiert via `just gen-api` (oder `bun run gen:api`)
├── components/
│ ├── ui/ Button, Card, Badge, Table, Dialog …
│ ├── layout/ Sidebar, Topbar, App-Shell
│ └── state-badge.tsx
├── lib/
│ ├── api.ts openapi-fetch Client
│ ├── format.ts Datum/Serial-Formatter
│ └── utils.ts cn()
├── routes/ TanStack Router (file-based)
│ ├── __root.tsx
│ ├── login.tsx
│ ├── _app.tsx Auth-Guard + Shell
│ ├── _app.index.tsx Dashboard
│ ├── _app.certificates.tsx
│ ├── _app.configuration.tsx
│ └── _app.iconfig.tsx
├── index.css Tailwind Theme (light, BSI-Ton)
└── main.tsx
```
## Lokale Entwicklung
Bun ist die Standard-Toolchain (ein Binary, kein Approval-Tanz für Build-Skripte). Andere Manager (pnpm, npm) funktionieren grundsätzlich auch.
```bash
bun install
bun run gen:api # liest ./openapi.json
bun run dev # http://localhost:5173, /api wird zu localhost:8443 gepro­xied
```
Backend separat aus `../backend`:
```bash
DEV_AUTH=1 CORS_ALLOW_ORIGIN=http://localhost:5173 cargo run
```
## OpenAPI-Client
```bash
bun run gen:api # statisch aus ./openapi.json
bun run gen:api:live # gegen laufendes Backend (localhost:8443)
```
OpenAPI selbst stammt aus dem Rust-Code via `utoipa-axum`. Frischen Snapshot in
diesem Verzeichnis ablegen:
```bash
cd ../backend && cargo run -- --emit-openapi > ../frontend/openapi.json
```
## Docker (über Repo-Root)
```bash
just up # beide Container bauen + starten
just down
just logs frontend
```
- Frontend: <http://localhost:8080>
- Backend (intern): `backend:8443`
- `deploy/nginx.conf` wird zur Laufzeit in den Frontend-Container gemountet — Edit + `just nginx-reload` ohne Image-Rebuild.
- mTLS-Termination findet **vor** nginx statt; der Proxy setzt `X-Forwarded-Cert-Subject`. Im Lab läuft das Backend mit `DEV_AUTH=1` und akzeptiert ein `dev_subject` im Login-Body.
## Was noch fehlt (TODOs)
- PEM-Anzeige im Certificate-Detail-Drawer (Backend liefert PEM noch nicht
separat).
- SSE-Stream für Live-Scheduler-Logs (Endpoint `/api/scheduler/stream`).
- Vollständige iconfig-Profil-Felder, sobald `InitialConfigBuilder` real ist.
- Audit-Log-Seite.
+745
View File
@@ -0,0 +1,745 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "smgw-pki-console",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.5",
"@tanstack/react-query": "^5.62.7",
"@tanstack/react-router": "^1.93.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"geist": "^1.3.1",
"lucide-react": "^0.468.0",
"openapi-fetch": "^0.13.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sonner": "^1.7.1",
"tailwind-merge": "^2.5.5",
"zod": "^3.24.1",
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@tanstack/router-cli": "^1.166.43",
"@tanstack/router-plugin": "^1.93.0",
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"openapi-typescript": "^7.4.4",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2",
"vite": "^6.0.5",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="],
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
"@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="],
"@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="],
"@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@redocly/ajv": ["@redocly/ajv@8.11.2", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js-replace": "^1.0.1" } }, "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg=="],
"@redocly/config": ["@redocly/config@0.22.0", "", {}, "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ=="],
"@redocly/openapi-core": ["@redocly/openapi-core@1.34.14", "", { "dependencies": { "@redocly/ajv": "8.11.2", "@redocly/config": "0.22.0", "colorette": "1.4.0", "https-proxy-agent": "7.0.6", "js-levenshtein": "1.1.6", "js-yaml": "4.1.1", "minimatch": "5.1.9", "pluralize": "8.0.0", "yaml-ast-parser": "0.0.43" } }, "sha512-y+xFx+Zz54Xhr8jUdnLENYnt7Y7GEDL6Q03ga7rTtX8DVwefX9H+hQEPgJp1nda7vdH+wJ9/HBVvyfBuW9x6rA=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.3", "", { "os": "android", "cpu": "arm" }, "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.3", "", { "os": "android", "cpu": "arm64" }, "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.3", "", { "os": "none", "cpu": "arm64" }, "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA=="],
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.0", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.0", "", { "dependencies": { "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw=="],
"@tanstack/history": ["@tanstack/history@1.161.6", "", {}, "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg=="],
"@tanstack/query-core": ["@tanstack/query-core@5.100.10", "", {}, "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w=="],
"@tanstack/react-query": ["@tanstack/react-query@5.100.10", "", { "dependencies": { "@tanstack/query-core": "5.100.10" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q=="],
"@tanstack/react-router": ["@tanstack/react-router@1.169.2", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.169.2", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-OJM7Kguc7ERnweaNRWsyWgIKcl3z23rD1B4jaxjzd9RGdnzpt2HfrWa9rggbT0Hfzhfo4D2ZmsfoTme035tniQ=="],
"@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="],
"@tanstack/router-cli": ["@tanstack/router-cli@1.166.43", "", { "dependencies": { "@tanstack/router-generator": "1.166.42", "chokidar": "^3.6.0", "yargs": "^17.7.2" }, "bin": { "tsr": "bin/tsr.cjs" } }, "sha512-XvKFA47F5KjL0R8PzUdkBQrVDPjSzE7FgWeKnYLVRGytDTlZOJhgVzoznITdiAsNe9KPe93xHE1z5h80hhOGWg=="],
"@tanstack/router-core": ["@tanstack/router-core@1.169.2", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-5sm0DJF1A7Mz+9gy4Gz/lLovNailK3yot4vYvz9MkBUPw26uLnhQiR8hSCYxucjE0wD6Mdlc5l+Z0/XTlZ7xHw=="],
"@tanstack/router-generator": ["@tanstack/router-generator@1.166.42", "", { "dependencies": { "@babel/types": "^7.28.5", "@tanstack/router-core": "1.169.2", "@tanstack/router-utils": "1.161.8", "@tanstack/virtual-file-routes": "1.161.7", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.5.0", "zod": "^3.24.2" } }, "sha512-2qBWC0t78r6b3vI+AbnvCZcFAvbYBDlLuWZrTjQbcjUmwG3qyeQp983tJyDuj9wb5//adG1tgAGXZkJ3aDwdBg=="],
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.167.35", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.169.2", "@tanstack/router-generator": "1.166.42", "@tanstack/router-utils": "1.161.8", "@tanstack/virtual-file-routes": "1.161.7", "chokidar": "^3.6.0", "unplugin": "^3.0.0", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "^1.169.2", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-UAScU5VAzLYVY4FML/Cbc5S5TucT4I8Ata05yozGOe4ZfepTKRffA5xWLtD2N+ov5svdv0KTX/kqlZnYPe28mA=="],
"@tanstack/router-utils": ["@tanstack/router-utils@1.161.8", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-xyiLWEKjfBAVhauDSSjXxyf7s8elU6SM+V050sbkofvGmIIvkwPFtDsX7Gvwh14kBd6iCwAT+RiPvXTxAptY0Q=="],
"@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="],
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.7", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"ansis": ["ansis@4.3.0", "", {}, "sha512-44mvgtPvohuU/70DdY5Oz2AIrLJ9k6/5x4KmoSvPwO+5Moijo0+N9D0fKbbYZQWP1hNm5CpOf+E01jhxG/r8xg=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.29", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"caniuse-lite": ["caniuse-lite@1.0.30001792", "", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="],
"change-case": ["change-case@5.4.4", "", {}, "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"electron-to-chromium": ["electron-to-chromium@1.5.353", "", {}, "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"enhanced-resolve": ["enhanced-resolve@5.21.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"geist": ["geist@1.7.0", "", { "peerDependencies": { "next": ">=13.2.0" } }, "sha512-ZaoiZwkSf0DwwB1ncdLKp+ggAldqxl5L1+SXaNIBGkPAqcu+xjVJLxlf3/S8vLt9UHx1xu5fz3lbzKCj5iOVdQ=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"index-to-position": ["index-to-position@1.2.0", "", {}, "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"isbot": ["isbot@5.1.40", "", {}, "sha512-yNeeynhhtIVRBk12tBV4eHNxwB42HzR4Q3Ea7vCOiJhImGaAIdIMrbJtacQlBizGLjUPw+akkFI5Dn9T70XoVQ=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.468.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
"next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="],
"node-releases": ["node-releases@2.0.44", "", {}, "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"openapi-fetch": ["openapi-fetch@0.13.8", "", { "dependencies": { "openapi-typescript-helpers": "^0.0.15" } }, "sha512-yJ4QKRyNxE44baQ9mY5+r/kAzZ8yXMemtNAOFwOzRXJscdjSxxzWSNlyBAr+o5JjkUw9Lc3W7OIoca0cY3PYnQ=="],
"openapi-typescript": ["openapi-typescript@7.13.0", "", { "dependencies": { "@redocly/openapi-core": "^1.34.6", "ansi-colors": "^4.1.3", "change-case": "^5.4.4", "parse-json": "^8.3.0", "supports-color": "^10.2.2", "yargs-parser": "^21.1.1" }, "peerDependencies": { "typescript": "^5.x" }, "bin": { "openapi-typescript": "bin/cli.js" } }, "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ=="],
"openapi-typescript-helpers": ["openapi-typescript-helpers@0.0.15", "", {}, "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw=="],
"parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
"postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],
"prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
"react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
"react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"seroval": ["seroval@1.5.4", "", {}, "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw=="],
"seroval-plugins": ["seroval-plugins@1.5.4", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"sonner": ["sonner@1.7.4", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="],
"tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"uri-js-replace": ["uri-js-replace@1.0.1", "", {}, "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml-ast-parser": ["yaml-ast-parser@0.0.43", "", {}, "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"sharp/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
}
}
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,600;12..96,700&family=Instrument+Serif:ital@0;1&display=swap"
/>
<title>SMGW PKI · Console</title>
</head>
<body class="bg-canvas text-ink antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+949
View File
@@ -0,0 +1,949 @@
{
"openapi": "3.1.0",
"info": {
"title": "smgw-pki-automator",
"description": "Control + observation surface for the SMGW PKI automation tool. Test/lab use only.",
"license": {
"name": ""
},
"version": "0.1.0"
},
"paths": {
"/alerts": {
"get": {
"tags": [
"alerts"
],
"operationId": "list_alerts",
"responses": {
"200": {
"description": "Recent alerts",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AlertListResponse"
}
}
}
}
}
}
},
"/alerts/test": {
"post": {
"tags": [
"alerts"
],
"operationId": "send_test_alert",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestAlertRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Sent (or stub-logged)"
}
}
}
},
"/auth/me": {
"get": {
"tags": [
"auth"
],
"summary": "Inspect the current session.",
"operationId": "whoami",
"responses": {
"200": {
"description": "Current session",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionResponse"
}
}
}
},
"401": {
"description": "No active session",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/auth/session": {
"post": {
"tags": [
"auth"
],
"summary": "Exchange mTLS client cert (passed via `X-Forwarded-Cert-Subject`) for a\nserver-issued session cookie. The reverse proxy terminating mTLS is the\ntrust anchor.",
"operationId": "create_session",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LoginRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Session issued",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionResponse"
}
}
}
},
"403": {
"description": "No client cert subject",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
},
"delete": {
"tags": [
"auth"
],
"summary": "Revoke the current session and clear cookie.",
"operationId": "end_session",
"responses": {
"204": {
"description": "Session ended"
}
}
}
},
"/certs": {
"get": {
"tags": [
"certs"
],
"summary": "List all known end-entity certificates with derived state.",
"operationId": "list_certificates",
"responses": {
"200": {
"description": "Certificates",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CertListResponse"
}
}
}
}
}
}
},
"/certs/{gateway_id}/{usage}/renew": {
"post": {
"tags": [
"certs"
],
"summary": "Trigger an out-of-band renewal for a specific (gateway, usage) pair.\nReturns the SOAP `messageID` so the caller can correlate the async callback.",
"operationId": "renew_certificate",
"parameters": [
{
"name": "gateway_id",
"in": "path",
"description": "Gateway identifier",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "usage",
"in": "path",
"description": "Certificate usage",
"required": true,
"schema": {
"$ref": "#/components/schemas/CertificateUsageDto"
}
}
],
"responses": {
"202": {
"description": "Renewal accepted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RenewAccepted"
}
}
}
},
"501": {
"description": "Sub-CA adapter not implemented yet",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/config": {
"get": {
"tags": [
"config"
],
"operationId": "get_config",
"responses": {
"200": {
"description": "Current runtime config",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConfigView"
}
}
}
}
}
},
"put": {
"tags": [
"config"
],
"operationId": "update_config",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConfigUpdate"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Updated runtime config",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConfigView"
}
}
}
}
}
}
},
"/gateways": {
"get": {
"tags": [
"gateways"
],
"operationId": "list_gateways",
"responses": {
"200": {
"description": "Gateways",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GatewayListResponse"
}
}
}
}
}
}
},
"/iconfig/build": {
"post": {
"tags": [
"iconfig"
],
"summary": "Build, sign via HSM, and stream back `iconfig.tar`.",
"operationId": "build_iconfig",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/IconfigRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "iconfig.tar",
"content": {
"application/x-tar": {}
}
},
"501": {
"description": "HSM signature not implemented",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/iconfig/preview": {
"post": {
"tags": [
"iconfig"
],
"summary": "Render the unsigned `iconfig.xml` for review. Does not touch the HSM.",
"operationId": "preview_iconfig",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/IconfigRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Preview XML",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/IconfigPreview"
}
}
}
}
}
}
},
"/scheduler": {
"get": {
"tags": [
"scheduler"
],
"operationId": "get_status",
"responses": {
"200": {
"description": "Scheduler state",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SchedulerState"
}
}
}
}
}
}
},
"/scheduler/pause": {
"post": {
"tags": [
"scheduler"
],
"operationId": "set_paused",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PauseRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Pause state updated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SchedulerState"
}
}
}
}
}
}
},
"/scheduler/trigger": {
"post": {
"tags": [
"scheduler"
],
"summary": "Run renewal once, out of band. Honours the same overlap-lock as the cron job.",
"operationId": "trigger_run",
"responses": {
"202": {
"description": "Run accepted"
},
"409": {
"description": "Run already in progress",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"AlertEntry": {
"type": "object",
"required": [
"at",
"severity",
"subject",
"body"
],
"properties": {
"at": {
"type": "string",
"format": "date-time"
},
"body": {
"type": "string"
},
"severity": {
"$ref": "#/components/schemas/AlertSeverity"
},
"subject": {
"type": "string"
}
}
},
"AlertListResponse": {
"type": "object",
"required": [
"items"
],
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AlertEntry"
}
}
}
},
"AlertSeverity": {
"type": "string",
"enum": [
"info",
"warning",
"error"
]
},
"ApiError": {
"type": "object",
"required": [
"code",
"message"
],
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
}
}
},
"CertListResponse": {
"type": "object",
"required": [
"items"
],
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CertificateDto"
}
}
}
},
"CertState": {
"type": "string",
"enum": [
"valid",
"expiring",
"expired"
]
},
"CertificateDto": {
"type": "object",
"required": [
"gateway_id",
"serial",
"usage",
"not_before",
"not_after",
"days_to_expiry",
"state"
],
"properties": {
"days_to_expiry": {
"type": "integer",
"format": "int64"
},
"gateway_id": {
"type": "string"
},
"not_after": {
"type": "string",
"format": "date-time"
},
"not_before": {
"type": "string",
"format": "date-time"
},
"serial": {
"type": "string"
},
"state": {
"$ref": "#/components/schemas/CertState"
},
"usage": {
"$ref": "#/components/schemas/CertificateUsageDto"
}
}
},
"CertificateUsageDto": {
"type": "string",
"enum": [
"tls",
"signature",
"encryption"
]
},
"ConfigUpdate": {
"type": "object",
"properties": {
"cron_schedule": {
"type": [
"string",
"null"
]
},
"days_window": {
"type": [
"integer",
"null"
],
"format": "int32",
"minimum": 0
},
"hsm": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/HsmConfig"
}
]
},
"smtp": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/SmtpConfig"
}
]
},
"sub_ca": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/SubCaConfig"
}
]
}
}
},
"ConfigView": {
"type": "object",
"required": [
"config",
"restart_required_fields"
],
"properties": {
"config": {
"$ref": "#/components/schemas/RuntimeConfig"
},
"restart_required_fields": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"GatewayDto": {
"type": "object",
"required": [
"id",
"serial_number",
"admin_key_label"
],
"properties": {
"admin_key_label": {
"type": "string"
},
"id": {
"type": "string"
},
"serial_number": {
"type": "string"
}
}
},
"GatewayListResponse": {
"type": "object",
"required": [
"items"
],
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/GatewayDto"
}
}
}
},
"HsmConfig": {
"type": "object",
"required": [
"module_path",
"pin_env_var"
],
"properties": {
"module_path": {
"type": "string"
},
"pin_env_var": {
"type": "string"
},
"slot": {
"type": [
"integer",
"null"
],
"format": "int64",
"minimum": 0
}
}
},
"IconfigPreview": {
"type": "object",
"required": [
"xml"
],
"properties": {
"xml": {
"type": "string"
}
}
},
"IconfigRequest": {
"type": "object",
"required": [
"gateway_id",
"admin_key_label",
"profile"
],
"properties": {
"admin_key_label": {
"type": "string"
},
"extras": {},
"gateway_id": {
"type": "string"
},
"profile": {
"type": "string"
}
}
},
"LoginRequest": {
"type": "object",
"properties": {
"dev_subject": {
"type": [
"string",
"null"
],
"description": "Optional fallback subject for dev mode when no mTLS header is present."
}
}
},
"PauseRequest": {
"type": "object",
"required": [
"paused"
],
"properties": {
"paused": {
"type": "boolean"
}
}
},
"RenewAccepted": {
"type": "object",
"required": [
"message_id"
],
"properties": {
"message_id": {
"type": "string"
}
}
},
"RuntimeConfig": {
"type": "object",
"description": "Mutable runtime config. Seeded from env on boot; UI may override at runtime\nfor fields flagged `hot_reload`. Restart-only fields (BIND_ADDR) are read\nbut cannot be applied without restart.",
"required": [
"bind_addr",
"cron_schedule",
"days_window",
"database_url",
"sub_ca",
"smtp",
"hsm"
],
"properties": {
"bind_addr": {
"type": "string"
},
"cron_schedule": {
"type": "string"
},
"database_url": {
"type": "string"
},
"days_window": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"hsm": {
"$ref": "#/components/schemas/HsmConfig"
},
"smtp": {
"$ref": "#/components/schemas/SmtpConfig"
},
"sub_ca": {
"$ref": "#/components/schemas/SubCaConfig"
}
}
},
"SchedulerState": {
"type": "object",
"required": [
"cron_schedule",
"days_window",
"paused"
],
"properties": {
"cron_schedule": {
"type": "string"
},
"days_window": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"last_error": {
"type": [
"string",
"null"
]
},
"last_handled": {
"type": [
"integer",
"null"
],
"minimum": 0
},
"last_run_at": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"last_run_ok": {
"type": [
"boolean",
"null"
]
},
"paused": {
"type": "boolean"
}
}
},
"SessionResponse": {
"type": "object",
"required": [
"subject",
"expires_at"
],
"properties": {
"expires_at": {
"type": "string",
"format": "date-time"
},
"subject": {
"type": "string"
}
}
},
"SmtpConfig": {
"type": "object",
"required": [
"host",
"port",
"from",
"to",
"starttls"
],
"properties": {
"from": {
"type": "string"
},
"host": {
"type": "string"
},
"port": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"starttls": {
"type": "boolean"
},
"to": {
"type": "string"
}
}
},
"SubCaConfig": {
"type": "object",
"required": [
"endpoint"
],
"properties": {
"ca_bundle_path": {
"type": [
"string",
"null"
]
},
"client_cert_path": {
"type": [
"string",
"null"
]
},
"client_key_path": {
"type": [
"string",
"null"
]
},
"endpoint": {
"type": "string"
}
}
},
"TestAlertRequest": {
"type": "object",
"properties": {
"body": {
"type": [
"string",
"null"
]
},
"subject": {
"type": [
"string",
"null"
]
}
}
}
}
},
"tags": [
{
"name": "auth",
"description": "mTLS-bridged session management"
},
{
"name": "certs",
"description": "Certificate lifecycle"
},
{
"name": "gateways",
"description": "Smart Meter Gateways"
},
{
"name": "config",
"description": "Runtime configuration"
},
{
"name": "scheduler",
"description": "Renewal scheduler"
},
{
"name": "iconfig",
"description": "BSI TR-03109-1 initial config"
},
{
"name": "alerts",
"description": "Operator alerts"
}
]
}
+52
View File
@@ -0,0 +1,52 @@
{
"name": "smgw-pki-console",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview --host",
"typecheck": "tsc -b --noEmit",
"lint": "eslint .",
"gen:api": "openapi-typescript ./openapi.json -o ./src/api/schema.d.ts",
"gen:api:live": "openapi-typescript http://localhost:8443/api/openapi.json -o ./src/api/schema.d.ts"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.5",
"@tanstack/react-query": "^5.62.7",
"@tanstack/react-router": "^1.93.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"geist": "^1.3.1",
"lucide-react": "^0.468.0",
"openapi-fetch": "^0.13.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sonner": "^1.7.1",
"tailwind-merge": "^2.5.5",
"zod": "^3.24.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@tanstack/router-cli": "^1.166.43",
"@tanstack/router-plugin": "^1.93.0",
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"openapi-typescript": "^7.4.4",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2",
"vite": "^6.0.5"
}
}
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#0c0c0d" />
<path d="M16 6 L24 10 V18 C24 22 20.5 25 16 26 C11.5 25 8 22 8 18 V10 Z"
fill="none" stroke="#fafaf7" stroke-width="1.5" />
<circle cx="16" cy="16" r="2.2" fill="#fafaf7" />
<path d="M16 18 V22" stroke="#fafaf7" stroke-width="1.5" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 395 B

+740
View File
@@ -0,0 +1,740 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/alerts": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["list_alerts"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/alerts/test": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["send_test_alert"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/auth/me": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Inspect the current session. */
get: operations["whoami"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/auth/session": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Exchange mTLS client cert (passed via `X-Forwarded-Cert-Subject`) for a
* server-issued session cookie. The reverse proxy terminating mTLS is the
* trust anchor.
*/
post: operations["create_session"];
/** Revoke the current session and clear cookie. */
delete: operations["end_session"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/certs": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List all known end-entity certificates with derived state. */
get: operations["list_certificates"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/certs/{gateway_id}/{usage}/renew": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Trigger an out-of-band renewal for a specific (gateway, usage) pair.
* Returns the SOAP `messageID` so the caller can correlate the async callback.
*/
post: operations["renew_certificate"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/config": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["get_config"];
put: operations["update_config"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/gateways": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["list_gateways"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/iconfig/build": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Build, sign via HSM, and stream back `iconfig.tar`. */
post: operations["build_iconfig"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/iconfig/preview": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Render the unsigned `iconfig.xml` for review. Does not touch the HSM. */
post: operations["preview_iconfig"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/scheduler": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["get_status"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/scheduler/pause": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["set_paused"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/scheduler/trigger": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Run renewal once, out of band. Honours the same overlap-lock as the cron job. */
post: operations["trigger_run"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
AlertEntry: {
/** Format: date-time */
at: string;
body: string;
severity: components["schemas"]["AlertSeverity"];
subject: string;
};
AlertListResponse: {
items: components["schemas"]["AlertEntry"][];
};
/** @enum {string} */
AlertSeverity: "info" | "warning" | "error";
ApiError: {
code: string;
message: string;
};
CertListResponse: {
items: components["schemas"]["CertificateDto"][];
};
/** @enum {string} */
CertState: "valid" | "expiring" | "expired";
CertificateDto: {
/** Format: int64 */
days_to_expiry: number;
gateway_id: string;
/** Format: date-time */
not_after: string;
/** Format: date-time */
not_before: string;
serial: string;
state: components["schemas"]["CertState"];
usage: components["schemas"]["CertificateUsageDto"];
};
/** @enum {string} */
CertificateUsageDto: "tls" | "signature" | "encryption";
ConfigUpdate: {
cron_schedule?: string | null;
/** Format: int32 */
days_window?: number | null;
hsm?: null | components["schemas"]["HsmConfig"];
smtp?: null | components["schemas"]["SmtpConfig"];
sub_ca?: null | components["schemas"]["SubCaConfig"];
};
ConfigView: {
config: components["schemas"]["RuntimeConfig"];
restart_required_fields: string[];
};
GatewayDto: {
admin_key_label: string;
id: string;
serial_number: string;
};
GatewayListResponse: {
items: components["schemas"]["GatewayDto"][];
};
HsmConfig: {
module_path: string;
pin_env_var: string;
/** Format: int64 */
slot?: number | null;
};
IconfigPreview: {
xml: string;
};
IconfigRequest: {
admin_key_label: string;
extras?: unknown;
gateway_id: string;
profile: string;
};
LoginRequest: {
/** @description Optional fallback subject for dev mode when no mTLS header is present. */
dev_subject?: string | null;
};
PauseRequest: {
paused: boolean;
};
RenewAccepted: {
message_id: string;
};
/**
* @description Mutable runtime config. Seeded from env on boot; UI may override at runtime
* for fields flagged `hot_reload`. Restart-only fields (BIND_ADDR) are read
* but cannot be applied without restart.
*/
RuntimeConfig: {
bind_addr: string;
cron_schedule: string;
database_url: string;
/** Format: int32 */
days_window: number;
hsm: components["schemas"]["HsmConfig"];
smtp: components["schemas"]["SmtpConfig"];
sub_ca: components["schemas"]["SubCaConfig"];
};
SchedulerState: {
cron_schedule: string;
/** Format: int32 */
days_window: number;
last_error?: string | null;
last_handled?: number | null;
/** Format: date-time */
last_run_at?: string | null;
last_run_ok?: boolean | null;
paused: boolean;
};
SessionResponse: {
/** Format: date-time */
expires_at: string;
subject: string;
};
SmtpConfig: {
from: string;
host: string;
/** Format: int32 */
port: number;
starttls: boolean;
to: string;
};
SubCaConfig: {
ca_bundle_path?: string | null;
client_cert_path?: string | null;
client_key_path?: string | null;
endpoint: string;
};
TestAlertRequest: {
body?: string | null;
subject?: string | null;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
list_alerts: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Recent alerts */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AlertListResponse"];
};
};
};
};
send_test_alert: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["TestAlertRequest"];
};
};
responses: {
/** @description Sent (or stub-logged) */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
whoami: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Current session */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SessionResponse"];
};
};
/** @description No active session */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiError"];
};
};
};
};
create_session: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["LoginRequest"];
};
};
responses: {
/** @description Session issued */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SessionResponse"];
};
};
/** @description No client cert subject */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiError"];
};
};
};
};
end_session: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Session ended */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
list_certificates: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Certificates */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CertListResponse"];
};
};
};
};
renew_certificate: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Gateway identifier */
gateway_id: string;
/** @description Certificate usage */
usage: components["schemas"]["CertificateUsageDto"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Renewal accepted */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["RenewAccepted"];
};
};
/** @description Sub-CA adapter not implemented yet */
501: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiError"];
};
};
};
};
get_config: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Current runtime config */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ConfigView"];
};
};
};
};
update_config: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ConfigUpdate"];
};
};
responses: {
/** @description Updated runtime config */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ConfigView"];
};
};
};
};
list_gateways: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Gateways */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["GatewayListResponse"];
};
};
};
};
build_iconfig: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["IconfigRequest"];
};
};
responses: {
/** @description iconfig.tar */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/x-tar": unknown;
};
};
/** @description HSM signature not implemented */
501: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiError"];
};
};
};
};
preview_iconfig: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["IconfigRequest"];
};
};
responses: {
/** @description Preview XML */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["IconfigPreview"];
};
};
};
};
get_status: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Scheduler state */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SchedulerState"];
};
};
};
};
set_paused: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PauseRequest"];
};
};
responses: {
/** @description Pause state updated */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SchedulerState"];
};
};
};
};
trigger_run: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Run accepted */
202: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Run already in progress */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiError"];
};
};
};
};
}
@@ -0,0 +1,94 @@
import { Link, useRouterState } from "@tanstack/react-router";
import {
LayoutGrid,
ShieldCheck,
Sliders,
FileLock2,
CircleDot,
} from "lucide-react";
import { cn } from "@/lib/utils";
type NavItem = {
to: string;
label: string;
hint: string;
icon: React.ComponentType<{ className?: string }>;
};
const NAV: NavItem[] = [
{ to: "/", label: "Übersicht", hint: "Status der PKI", icon: LayoutGrid },
{ to: "/certificates", label: "Zertifikate", hint: "Bestand & Erneuerung", icon: ShieldCheck },
{ to: "/configuration", label: "Konfiguration", hint: "Runtime-Parameter", icon: Sliders },
{ to: "/iconfig", label: "iconfig.tar", hint: "TR-03109-1 Builder", icon: FileLock2 },
];
export function Sidebar() {
const path = useRouterState({ select: (s) => s.location.pathname });
return (
<aside className="hidden lg:flex w-[260px] shrink-0 flex-col border-r border-line bg-paper/60 backdrop-blur-[2px]">
<div className="px-5 pt-6 pb-5 border-b border-line">
<Link to="/" className="flex items-center gap-3 group focus-ring rounded-md">
<div className="size-9 rounded-[10px] bg-ink text-paper grid place-items-center font-display font-semibold text-[15px] tracking-[-0.02em]">
sm
</div>
<div className="flex flex-col leading-tight">
<span className="font-display text-[14.5px] font-semibold tracking-[-0.015em] text-ink">
SMGW PKI
</span>
<span className="text-[11px] uppercase tracking-[0.08em] text-ink-faint">
Lab Console
</span>
</div>
</Link>
</div>
<nav className="flex-1 px-3 py-4 space-y-0.5">
<span className="block px-3 pb-2 text-[10.5px] uppercase tracking-[0.1em] text-ink-faint">
Steuerung
</span>
{NAV.map((item) => {
const active =
item.to === "/" ? path === "/" : path.startsWith(item.to);
const Icon = item.icon;
return (
<Link
key={item.to}
to={item.to}
className={cn(
"group flex items-start gap-3 rounded-[10px] px-3 py-2.5 text-[13px] transition-colors focus-ring",
active
? "bg-overlay text-ink"
: "text-ink-mute hover:bg-overlay/60 hover:text-ink",
)}
>
<Icon
className={cn(
"size-[18px] mt-0.5 shrink-0 transition-colors",
active ? "text-accent" : "text-ink-faint group-hover:text-ink-mute",
)}
/>
<span className="flex flex-col">
<span className="font-medium tracking-[-0.005em]">{item.label}</span>
<span className="text-[11.5px] text-ink-faint leading-tight">
{item.hint}
</span>
</span>
</Link>
);
})}
</nav>
<div className="m-3 surface-inset p-3.5">
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.08em] text-ink-faint">
<CircleDot className="size-3 text-success" />
BSI-Kontext
</div>
<p className="mt-2 text-[12px] leading-relaxed text-ink-mute">
Test- und Laborbetrieb. SoftHSMv2, Sub-CA-Stub.{" "}
<span className="text-ink">Nicht für Produktion.</span>
</p>
</div>
</aside>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import { LogOut, Play, ShieldHalf } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { api, unwrap } from "@/lib/api";
import { fmtRelative } from "@/lib/format";
export function Topbar({ title, subtitle }: { title: string; subtitle?: string }) {
const navigate = useNavigate();
const me = useQuery({
queryKey: ["auth", "me"],
queryFn: async () => {
try {
return await unwrap(api.GET("/auth/me"));
} catch {
return null;
}
},
});
const trigger = useMutation({
mutationFn: () => unwrap(api.POST("/scheduler/trigger")),
onSuccess: () => toast.success("Erneuerungslauf gestartet"),
onError: (e: Error) => toast.error(`Trigger fehlgeschlagen: ${e.message}`),
});
const logout = useMutation({
mutationFn: () => unwrap(api.DELETE("/auth/session")),
onSuccess: () => navigate({ to: "/login" }),
});
return (
<header className="sticky top-0 z-20 border-b border-line bg-canvas/85 backdrop-blur supports-[backdrop-filter]:bg-canvas/70">
<div className="flex items-center justify-between gap-6 px-6 lg:px-10 h-[68px]">
<div className="flex flex-col leading-tight">
<h1 className="font-display text-[20px] font-semibold tracking-[-0.02em] text-ink">
{title}
</h1>
{subtitle && (
<span className="text-[12.5px] text-ink-mute leading-tight">{subtitle}</span>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => trigger.mutate()}
disabled={trigger.isPending}
>
<Play className="size-3.5" />
Lauf starten
</Button>
<div className="hidden md:flex items-center gap-2 pl-3 ml-1 border-l border-line">
<ShieldHalf className="size-4 text-accent" />
<div className="flex flex-col leading-tight">
<span className="text-[12px] font-medium text-ink mono">
{me.data?.subject ?? "—"}
</span>
<span className="text-[10.5px] uppercase tracking-[0.08em] text-ink-faint">
Sitzung läuft ab {fmtRelative(me.data?.expires_at)}
</span>
</div>
<Badge tone="accent" className="ml-2">mTLS</Badge>
</div>
<Button
variant="ghost"
size="icon"
aria-label="Abmelden"
onClick={() => logout.mutate()}
>
<LogOut className="size-4" />
</Button>
</div>
</div>
</header>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { Badge } from "@/components/ui/badge";
export type CertState = "valid" | "expiring" | "expired" | "pending";
const STATE_TONE: Record<CertState, "success" | "warn" | "danger" | "neutral"> = {
valid: "success",
expiring: "warn",
expired: "danger",
pending: "neutral",
};
const STATE_LABEL: Record<CertState, string> = {
valid: "Gültig",
expiring: "Erneuerung fällig",
expired: "Abgelaufen",
pending: "Ausstehend",
};
export function StateBadge({ state }: { state: CertState }) {
return (
<Badge tone={STATE_TONE[state]} dot>
{STATE_LABEL[state]}
</Badge>
);
}
+32
View File
@@ -0,0 +1,32 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center gap-1.5 rounded-full px-2.5 h-[22px] text-[11px] font-medium tracking-[0.01em] uppercase border",
{
variants: {
tone: {
neutral: "bg-overlay text-ink-mute border-line",
accent: "bg-accent-soft text-accent border-accent-line",
success: "bg-success-soft text-success border-success/30",
warn: "bg-warn-soft text-warn border-warn/30",
danger: "bg-danger-soft text-danger border-danger/30",
outline: "bg-paper text-ink-mute border-line",
},
dot: {
true: "before:content-[''] before:w-1.5 before:h-1.5 before:rounded-full before:bg-current before:opacity-90",
false: "",
},
},
defaultVariants: { tone: "neutral", dot: false },
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {}
export function Badge({ className, tone, dot, ...props }: BadgeProps) {
return <span className={cn(badgeVariants({ tone, dot }), className)} {...props} />;
}
+54
View File
@@ -0,0 +1,54 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[8px] text-[13px] font-medium tracking-[-0.005em] transition-[background,box-shadow,transform] duration-100 disabled:pointer-events-none disabled:opacity-50 focus-ring active:scale-[0.985]",
{
variants: {
variant: {
primary:
"bg-ink text-paper hover:bg-ink/90 shadow-[inset_0_1px_0_rgba(255,255,255,0.08),0_1px_0_rgba(0,0,0,0.2)]",
accent:
"bg-accent text-paper hover:bg-accent/90 shadow-[inset_0_1px_0_rgba(255,255,255,0.16),0_1px_0_rgba(0,0,0,0.18)]",
outline:
"bg-paper text-ink border border-line hover:bg-overlay hover:border-line-strong",
ghost: "text-ink-mute hover:bg-overlay hover:text-ink",
soft: "bg-overlay text-ink hover:bg-line/60",
danger:
"bg-danger text-paper hover:bg-danger/90 shadow-[inset_0_1px_0_rgba(255,255,255,0.16)]",
link: "text-accent underline-offset-4 hover:underline px-0 h-auto",
},
size: {
sm: "h-7 px-2.5 text-[12px]",
md: "h-9 px-3.5",
lg: "h-10 px-5 text-[14px]",
icon: "h-9 w-9",
},
},
defaultVariants: { variant: "primary", size: "md" },
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { buttonVariants };
+44
View File
@@ -0,0 +1,44 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("surface", className)} {...props} />
),
);
Card.displayName = "Card";
export const CardHeader = ({ className, ...p }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex items-start justify-between gap-4 px-5 pt-5", className)} {...p} />
);
export const CardTitle = ({ className, ...p }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h3
className={cn(
"font-display text-[15px] font-semibold tracking-[-0.012em] text-ink",
className,
)}
{...p}
/>
);
export const CardDescription = ({
className,
...p
}: React.HTMLAttributes<HTMLParagraphElement>) => (
<p className={cn("text-[12.5px] text-ink-mute leading-relaxed", className)} {...p} />
);
export const CardBody = ({ className, ...p }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("px-5 py-5", className)} {...p} />
);
export const CardFooter = ({ className, ...p }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex items-center justify-between gap-3 border-t border-line bg-overlay/40 px-5 py-3 text-[12px] text-ink-mute rounded-b-[var(--radius-card)]",
className,
)}
{...p}
/>
);
+97
View File
@@ -0,0 +1,97 @@
import * as React from "react";
import * as RDialog from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
export const Dialog = RDialog.Root;
export const DialogTrigger = RDialog.Trigger;
export const DialogClose = RDialog.Close;
export const DialogPortal = ({ children, ...props }: RDialog.DialogPortalProps) => (
<RDialog.Portal {...props}>{children}</RDialog.Portal>
);
export const DialogOverlay = React.forwardRef<
React.ElementRef<typeof RDialog.Overlay>,
React.ComponentPropsWithoutRef<typeof RDialog.Overlay>
>(({ className, ...props }, ref) => (
<RDialog.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-ink/30 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = "DialogOverlay";
export const DialogContent = React.forwardRef<
React.ElementRef<typeof RDialog.Content>,
React.ComponentPropsWithoutRef<typeof RDialog.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<RDialog.Content
ref={ref}
className={cn(
"fixed top-0 right-0 z-50 h-screen w-full max-w-[520px] bg-paper border-l border-line shadow-2xl outline-none flex flex-col",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:slide-in-from-right data-[state=closed]:slide-out-to-right",
className,
)}
{...props}
>
{children}
<RDialog.Close className="absolute right-4 top-4 inline-flex h-7 w-7 items-center justify-center rounded-full text-ink-mute hover:bg-overlay hover:text-ink focus-ring">
<X className="size-4" />
<span className="sr-only">Schließen</span>
</RDialog.Close>
</RDialog.Content>
</DialogPortal>
));
DialogContent.displayName = "DialogContent";
export const DialogHeader = ({ className, ...p }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("border-b border-line px-6 py-5", className)} {...p} />
);
export const DialogTitle = React.forwardRef<
React.ElementRef<typeof RDialog.Title>,
React.ComponentPropsWithoutRef<typeof RDialog.Title>
>(({ className, ...props }, ref) => (
<RDialog.Title
ref={ref}
className={cn(
"font-display text-[18px] font-semibold tracking-[-0.015em] text-ink",
className,
)}
{...props}
/>
));
DialogTitle.displayName = "DialogTitle";
export const DialogDescription = React.forwardRef<
React.ElementRef<typeof RDialog.Description>,
React.ComponentPropsWithoutRef<typeof RDialog.Description>
>(({ className, ...props }, ref) => (
<RDialog.Description
ref={ref}
className={cn("text-[12.5px] text-ink-mute leading-relaxed mt-1", className)}
{...props}
/>
));
DialogDescription.displayName = "DialogDescription";
export const DialogBody = ({ className, ...p }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex-1 overflow-auto px-6 py-5", className)} {...p} />
);
export const DialogFooter = ({ className, ...p }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"border-t border-line bg-overlay/40 px-6 py-3 flex items-center justify-end gap-2",
className,
)}
{...p}
/>
);
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
mono?: boolean;
}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, mono, ...props }, ref) => (
<input
ref={ref}
className={cn(
"h-9 w-full rounded-[8px] border border-line bg-paper px-3 text-[13px] text-ink",
"placeholder:text-ink-faint focus-ring",
"transition-[border-color,background] duration-150",
"hover:border-line-strong focus:border-ink",
mono && "font-mono tracking-[-0.005em]",
className,
)}
{...props}
/>
),
);
Input.displayName = "Input";
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react";
import * as RLabel from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
export const Label = React.forwardRef<
React.ElementRef<typeof RLabel.Root>,
React.ComponentPropsWithoutRef<typeof RLabel.Root>
>(({ className, ...props }, ref) => (
<RLabel.Root
ref={ref}
className={cn(
"text-[11.5px] font-medium uppercase tracking-[0.06em] text-ink-mute",
className,
)}
{...props}
/>
));
Label.displayName = "Label";
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import * as RSep from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
export const Separator = React.forwardRef<
React.ElementRef<typeof RSep.Root>,
React.ComponentPropsWithoutRef<typeof RSep.Root>
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<RSep.Root
ref={ref}
orientation={orientation}
decorative={decorative}
className={cn(
"shrink-0 bg-line",
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
className,
)}
{...props}
/>
));
Separator.displayName = "Separator";
+10
View File
@@ -0,0 +1,10 @@
import { cn } from "@/lib/utils";
export function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-[6px] bg-line/70", className)}
{...props}
/>
);
}
+27
View File
@@ -0,0 +1,27 @@
import * as React from "react";
import * as RSwitch from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
export const Switch = React.forwardRef<
React.ElementRef<typeof RSwitch.Root>,
React.ComponentPropsWithoutRef<typeof RSwitch.Root>
>(({ className, ...props }, ref) => (
<RSwitch.Root
ref={ref}
className={cn(
"peer inline-flex h-[20px] w-[34px] shrink-0 cursor-pointer items-center rounded-full border border-line bg-overlay transition-colors focus-ring",
"data-[state=checked]:bg-ink data-[state=checked]:border-ink",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RSwitch.Thumb
className={cn(
"pointer-events-none block h-[14px] w-[14px] rounded-full bg-paper shadow ring-0 transition-transform",
"data-[state=checked]:translate-x-[16px] data-[state=unchecked]:translate-x-[2px]",
)}
/>
</RSwitch.Root>
));
Switch.displayName = "Switch";
+64
View File
@@ -0,0 +1,64 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export function Table({ className, ...p }: React.HTMLAttributes<HTMLTableElement>) {
return (
<div className="w-full overflow-x-auto">
<table className={cn("w-full caption-bottom border-collapse text-[13px]", className)} {...p} />
</div>
);
}
export function THead({ className, ...p }: React.HTMLAttributes<HTMLTableSectionElement>) {
return (
<thead
className={cn(
"[&_tr]:border-b [&_tr]:border-line text-[11px] uppercase tracking-[0.06em] text-ink-faint",
className,
)}
{...p}
/>
);
}
export function TBody({ className, ...p }: React.HTMLAttributes<HTMLTableSectionElement>) {
return (
<tbody
className={cn(
"[&_tr]:border-b [&_tr]:border-line [&_tr:last-child]:border-0",
className,
)}
{...p}
/>
);
}
export function TR({ className, ...p }: React.HTMLAttributes<HTMLTableRowElement>) {
return (
<tr
className={cn("transition-colors hover:bg-overlay/60 data-[state=selected]:bg-overlay", className)}
{...p}
/>
);
}
export function TH({ className, ...p }: React.ThHTMLAttributes<HTMLTableCellElement>) {
return (
<th
className={cn(
"h-9 px-3 text-left align-middle font-medium first:pl-5 last:pr-5",
className,
)}
{...p}
/>
);
}
export function TD({ className, ...p }: React.TdHTMLAttributes<HTMLTableCellElement>) {
return (
<td
className={cn("py-2.5 px-3 align-middle first:pl-5 last:pr-5", className)}
{...p}
/>
);
}
+45
View File
@@ -0,0 +1,45 @@
import * as React from "react";
import * as RTabs from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
export const Tabs = RTabs.Root;
export const TabsList = React.forwardRef<
React.ElementRef<typeof RTabs.List>,
React.ComponentPropsWithoutRef<typeof RTabs.List>
>(({ className, ...props }, ref) => (
<RTabs.List
ref={ref}
className={cn(
"inline-flex items-center gap-1 p-1 rounded-[10px] bg-overlay border border-line",
className,
)}
{...props}
/>
));
TabsList.displayName = "TabsList";
export const TabsTrigger = React.forwardRef<
React.ElementRef<typeof RTabs.Trigger>,
React.ComponentPropsWithoutRef<typeof RTabs.Trigger>
>(({ className, ...props }, ref) => (
<RTabs.Trigger
ref={ref}
className={cn(
"px-3 h-7 inline-flex items-center text-[12.5px] font-medium tracking-[-0.005em] rounded-[7px] text-ink-mute transition-colors focus-ring",
"data-[state=active]:bg-paper data-[state=active]:text-ink data-[state=active]:shadow-sm",
"hover:text-ink",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = "TabsTrigger";
export const TabsContent = React.forwardRef<
React.ElementRef<typeof RTabs.Content>,
React.ComponentPropsWithoutRef<typeof RTabs.Content>
>(({ className, ...props }, ref) => (
<RTabs.Content ref={ref} className={cn("mt-5 focus-ring", className)} {...props} />
));
TabsContent.displayName = "TabsContent";
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
declare module "*.css" {
const content: string;
export default content;
}
declare module "*.svg" {
const content: string;
export default content;
}
+85
View File
@@ -0,0 +1,85 @@
@import "tailwindcss";
@theme {
/* Refined-minimal light theme. Warm paper canvas, ink primary, BSI-ish blue accent. */
--color-canvas: oklch(98.4% 0.005 95);
--color-paper: oklch(100% 0 0);
--color-ink: oklch(18% 0.01 270);
--color-ink-mute: oklch(42% 0.01 270);
--color-ink-faint: oklch(62% 0.01 270);
--color-line: oklch(91% 0.005 270);
--color-line-strong: oklch(82% 0.008 270);
--color-overlay: oklch(96% 0.005 270);
--color-accent: oklch(45% 0.16 260);
--color-accent-soft: oklch(96% 0.03 260);
--color-accent-line: oklch(86% 0.06 260);
--color-success: oklch(48% 0.12 152);
--color-success-soft: oklch(96% 0.04 152);
--color-warn: oklch(58% 0.16 65);
--color-warn-soft: oklch(96% 0.06 80);
--color-danger: oklch(50% 0.21 25);
--color-danger-soft: oklch(96% 0.04 25);
--font-display: "Bricolage Grotesque", system-ui, sans-serif;
--font-sans: "Geist", "Bricolage Grotesque", system-ui, sans-serif;
--font-mono: "Geist Mono", "JetBrains Mono", ui-monospace, monospace;
--font-serif: "Instrument Serif", "Iowan Old Style", Georgia, serif;
--radius-card: 12px;
--radius-control: 8px;
}
@layer base {
html, body, #root { height: 100%; }
body {
font-family: var(--font-sans);
font-feature-settings: "ss01", "cv11";
background:
radial-gradient(1200px 600px at 100% -10%, oklch(95% 0.025 260 / 0.6), transparent 60%),
radial-gradient(900px 500px at -10% 110%, oklch(95% 0.02 80 / 0.5), transparent 60%),
var(--color-canvas);
background-attachment: fixed;
}
::selection { background: var(--color-accent); color: white; }
h1, h2, h3, h4 { font-family: var(--font-display); letter-spacing: -0.02em; }
.num { font-feature-settings: "tnum", "ss02"; }
.mono { font-family: var(--font-mono); }
.serif { font-family: var(--font-serif); }
}
@layer components {
.surface {
background: var(--color-paper);
border: 1px solid var(--color-line);
border-radius: var(--radius-card);
box-shadow:
0 1px 0 rgba(20, 24, 40, 0.02),
0 8px 24px -20px rgba(20, 24, 40, 0.18);
}
.surface-inset {
background: var(--color-overlay);
border: 1px solid var(--color-line);
border-radius: var(--radius-card);
}
.hairline { border-color: var(--color-line); }
.focus-ring {
outline: 2px solid transparent;
outline-offset: 2px;
transition: box-shadow 120ms ease;
}
.focus-ring:focus-visible {
box-shadow:
0 0 0 2px var(--color-paper),
0 0 0 4px var(--color-accent);
}
.grid-bg {
background-image:
linear-gradient(var(--color-line) 1px, transparent 1px),
linear-gradient(90deg, var(--color-line) 1px, transparent 1px);
background-size: 28px 28px;
background-position: -1px -1px;
mask-image: radial-gradient(ellipse at 50% 0%, black 30%, transparent 75%);
}
}
+45
View File
@@ -0,0 +1,45 @@
import createClient from "openapi-fetch";
import type { paths, components } from "@/api/schema";
/**
* Single API client. `credentials: "include"` so the session cookie issued
* by `POST /api/auth/session` is sent with every subsequent request.
*
* In dev Vite proxies `/api/*` to the Rust backend (see vite.config.ts).
* In prod the SPA is served behind the same reverse proxy that terminates
* mTLS for the backend.
*/
export const api = createClient<paths>({
baseUrl: "/api",
credentials: "include",
});
export type Schemas = components["schemas"];
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly code: string,
message: string,
) {
super(message);
}
}
type FetchResult<T> = {
data?: T;
error?: { code?: string; message?: string };
response: Response;
};
export async function unwrap<T>(call: Promise<FetchResult<T>>): Promise<T> {
const { data, error, response } = await call;
if (!response.ok || error) {
throw new ApiError(
response.status,
error?.code ?? "unknown",
error?.message ?? response.statusText,
);
}
return data as T;
}
+24
View File
@@ -0,0 +1,24 @@
import { formatDistanceToNowStrict, formatISO9075, parseISO } from "date-fns";
export function fmtIso(input?: string | null): string {
if (!input) return "—";
try {
return formatISO9075(parseISO(input));
} catch {
return input;
}
}
export function fmtRelative(input?: string | null): string {
if (!input) return "—";
try {
return formatDistanceToNowStrict(parseISO(input), { addSuffix: true });
} catch {
return input;
}
}
export function truncateSerial(serial: string, head = 6, tail = 6): string {
if (serial.length <= head + tail + 1) return serial;
return `${serial.slice(0, head)}${serial.slice(-tail)}`;
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+49
View File
@@ -0,0 +1,49 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { Toaster } from "sonner";
import { routeTree } from "@/routeTree.gen";
import "@/index.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
const router = createRouter({
routeTree,
context: { queryClient },
defaultPreload: "intent",
defaultPreloadStaleTime: 0,
});
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
const rootEl = document.getElementById("root")!;
createRoot(rootEl).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<Toaster
position="bottom-right"
toastOptions={{
classNames: {
toast:
"!bg-paper !text-ink !border !border-line !rounded-[10px] !font-sans !shadow-lg",
},
}}
/>
</QueryClientProvider>
</StrictMode>,
);
+161
View File
@@ -0,0 +1,161 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as LoginRouteImport } from './routes/login'
import { Route as AppRouteImport } from './routes/_app'
import { Route as AppIndexRouteImport } from './routes/_app.index'
import { Route as AppIconfigRouteImport } from './routes/_app.iconfig'
import { Route as AppConfigurationRouteImport } from './routes/_app.configuration'
import { Route as AppCertificatesRouteImport } from './routes/_app.certificates'
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const AppRoute = AppRouteImport.update({
id: '/_app',
getParentRoute: () => rootRouteImport,
} as any)
const AppIndexRoute = AppIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AppRoute,
} as any)
const AppIconfigRoute = AppIconfigRouteImport.update({
id: '/iconfig',
path: '/iconfig',
getParentRoute: () => AppRoute,
} as any)
const AppConfigurationRoute = AppConfigurationRouteImport.update({
id: '/configuration',
path: '/configuration',
getParentRoute: () => AppRoute,
} as any)
const AppCertificatesRoute = AppCertificatesRouteImport.update({
id: '/certificates',
path: '/certificates',
getParentRoute: () => AppRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof AppIndexRoute
'/login': typeof LoginRoute
'/certificates': typeof AppCertificatesRoute
'/configuration': typeof AppConfigurationRoute
'/iconfig': typeof AppIconfigRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/certificates': typeof AppCertificatesRoute
'/configuration': typeof AppConfigurationRoute
'/iconfig': typeof AppIconfigRoute
'/': typeof AppIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_app': typeof AppRouteWithChildren
'/login': typeof LoginRoute
'/_app/certificates': typeof AppCertificatesRoute
'/_app/configuration': typeof AppConfigurationRoute
'/_app/iconfig': typeof AppIconfigRoute
'/_app/': typeof AppIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/login' | '/certificates' | '/configuration' | '/iconfig'
fileRoutesByTo: FileRoutesByTo
to: '/login' | '/certificates' | '/configuration' | '/iconfig' | '/'
id:
| '__root__'
| '/_app'
| '/login'
| '/_app/certificates'
| '/_app/configuration'
| '/_app/iconfig'
| '/_app/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
AppRoute: typeof AppRouteWithChildren
LoginRoute: typeof LoginRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/_app': {
id: '/_app'
path: ''
fullPath: '/'
preLoaderRoute: typeof AppRouteImport
parentRoute: typeof rootRouteImport
}
'/_app/': {
id: '/_app/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof AppIndexRouteImport
parentRoute: typeof AppRoute
}
'/_app/iconfig': {
id: '/_app/iconfig'
path: '/iconfig'
fullPath: '/iconfig'
preLoaderRoute: typeof AppIconfigRouteImport
parentRoute: typeof AppRoute
}
'/_app/configuration': {
id: '/_app/configuration'
path: '/configuration'
fullPath: '/configuration'
preLoaderRoute: typeof AppConfigurationRouteImport
parentRoute: typeof AppRoute
}
'/_app/certificates': {
id: '/_app/certificates'
path: '/certificates'
fullPath: '/certificates'
preLoaderRoute: typeof AppCertificatesRouteImport
parentRoute: typeof AppRoute
}
}
}
interface AppRouteChildren {
AppCertificatesRoute: typeof AppCertificatesRoute
AppConfigurationRoute: typeof AppConfigurationRoute
AppIconfigRoute: typeof AppIconfigRoute
AppIndexRoute: typeof AppIndexRoute
}
const AppRouteChildren: AppRouteChildren = {
AppCertificatesRoute: AppCertificatesRoute,
AppConfigurationRoute: AppConfigurationRoute,
AppIconfigRoute: AppIconfigRoute,
AppIndexRoute: AppIndexRoute,
}
const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren)
const rootRouteChildren: RootRouteChildren = {
AppRoute: AppRouteWithChildren,
LoginRoute: LoginRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
+10
View File
@@ -0,0 +1,10 @@
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
import type { QueryClient } from "@tanstack/react-query";
export interface RouterContext {
queryClient: QueryClient;
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: () => <Outlet />,
});
+456
View File
@@ -0,0 +1,456 @@
import { useMemo, useState } from "react";
import {
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { toast } from "sonner";
import {
Copy,
RefreshCcw,
Search,
ShieldCheck,
ArrowUpDown,
Eye,
} from "lucide-react";
import { Topbar } from "@/components/layout/topbar";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Table,
THead,
TBody,
TR,
TH,
TD,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { StateBadge, type CertState } from "@/components/state-badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogBody,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { api, unwrap, type Schemas, ApiError } from "@/lib/api";
import { fmtIso, fmtRelative, truncateSerial } from "@/lib/format";
import { cn } from "@/lib/utils";
export const Route = createFileRoute("/_app/certificates")({
component: CertificatesPage,
});
type SortKey = "gateway" | "usage" | "not_after" | "state";
function CertificatesPage() {
const [q, setQ] = useState("");
const [stateFilter, setStateFilter] = useState<"all" | CertState>("all");
const [sortKey, setSortKey] = useState<SortKey>("not_after");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [selected, setSelected] = useState<Schemas["CertificateDto"] | null>(null);
const qc = useQueryClient();
const certs = useQuery({
queryKey: ["certs"],
queryFn: () => unwrap(api.GET("/certs")),
refetchInterval: 30_000,
});
const renew = useMutation({
mutationFn: (input: { gateway_id: string; usage: Schemas["CertificateUsageDto"] }) =>
unwrap(
api.POST("/certs/{gateway_id}/{usage}/renew", {
params: { path: input },
}),
),
onSuccess: (data) => {
toast.success("Erneuerung beauftragt", {
description: `messageID ${data.message_id}`,
});
qc.invalidateQueries({ queryKey: ["certs"] });
},
onError: (e: ApiError) =>
toast.error("Erneuerung abgelehnt", { description: e.message }),
});
const filtered = useMemo(() => {
const items = certs.data?.items ?? [];
return items
.filter((c) => (stateFilter === "all" ? true : c.state === stateFilter))
.filter((c) =>
q.trim().length === 0
? true
: `${c.gateway_id} ${c.serial} ${c.usage}`
.toLowerCase()
.includes(q.toLowerCase()),
)
.sort((a, b) => {
const dir = sortDir === "asc" ? 1 : -1;
const cmp = compareBy(sortKey, a, b);
return cmp * dir;
});
}, [certs.data, q, stateFilter, sortKey, sortDir]);
return (
<>
<Topbar
title="Zertifikate"
subtitle="Bestand pro Gateway-Profil. Sortier-, Filter- und Erneuerungsaktionen."
/>
<div className="px-6 lg:px-10 py-7 space-y-5 max-w-[1320px]">
<div className="flex flex-wrap items-center gap-3">
<div className="relative w-full sm:w-[320px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 text-ink-faint" />
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Gateway, Serial, Profil…"
className="pl-9"
/>
</div>
<div className="flex items-center gap-1 surface p-1">
{(["all", "valid", "expiring", "expired"] as const).map((s) => (
<button
key={s}
onClick={() => setStateFilter(s)}
className={cn(
"px-2.5 h-7 inline-flex items-center text-[11.5px] uppercase tracking-[0.06em] rounded-[6px] transition-colors",
stateFilter === s
? "bg-ink text-paper"
: "text-ink-mute hover:text-ink hover:bg-overlay",
)}
>
{LABEL_STATE[s]}
</button>
))}
</div>
<div className="ml-auto flex items-center gap-2 text-[12px] text-ink-mute">
<Badge tone="outline">
{filtered.length}/{certs.data?.items.length ?? 0}
</Badge>
Einträge
</div>
</div>
<Card className="overflow-hidden p-0">
<Table>
<THead>
<TR>
<ColHeader
label="Gateway / Profil"
active={sortKey === "gateway"}
dir={sortDir}
onClick={() => toggleSort("gateway", sortKey, sortDir, setSortKey, setSortDir)}
/>
<TH>Serial</TH>
<ColHeader
label="Verwendung"
active={sortKey === "usage"}
dir={sortDir}
onClick={() => toggleSort("usage", sortKey, sortDir, setSortKey, setSortDir)}
/>
<ColHeader
label="Gültig bis"
active={sortKey === "not_after"}
dir={sortDir}
onClick={() => toggleSort("not_after", sortKey, sortDir, setSortKey, setSortDir)}
/>
<TH>Restlaufzeit</TH>
<ColHeader
label="Status"
active={sortKey === "state"}
dir={sortDir}
onClick={() => toggleSort("state", sortKey, sortDir, setSortKey, setSortDir)}
/>
<TH className="text-right">Aktionen</TH>
</TR>
</THead>
<TBody>
{certs.isLoading && <TableSkeleton />}
{!certs.isLoading && filtered.length === 0 && (
<TR>
<TD colSpan={7} className="py-14 text-center text-ink-mute">
Keine Treffer.
</TD>
</TR>
)}
{filtered.map((c) => (
<TR key={`${c.gateway_id}-${c.usage}`}>
<TD>
<div className="flex flex-col leading-tight">
<span className="text-[13px] font-medium text-ink">
{c.gateway_id}
</span>
<span className="text-[11px] text-ink-faint">
Profil · {LABEL_USAGE[c.usage]}
</span>
</div>
</TD>
<TD>
<button
onClick={() => copySerial(c.serial)}
className="group inline-flex items-center gap-1.5 mono text-[12px] text-ink-mute hover:text-ink"
title={c.serial}
>
{truncateSerial(c.serial)}
<Copy className="size-3 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
</TD>
<TD>
<Badge tone="outline">{LABEL_USAGE[c.usage]}</Badge>
</TD>
<TD className="num text-[12.5px] text-ink">{fmtIso(c.not_after)}</TD>
<TD>
<span
className={cn(
"num text-[12.5px]",
c.days_to_expiry < 0
? "text-danger"
: c.days_to_expiry <= 30
? "text-warn"
: "text-ink-mute",
)}
>
{fmtRelative(c.not_after)}
</span>
</TD>
<TD>
<StateBadge state={c.state as CertState} />
</TD>
<TD className="text-right">
<div className="inline-flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setSelected(c)}
>
<Eye className="size-3.5" />
Details
</Button>
<Button
variant="outline"
size="sm"
disabled={renew.isPending}
onClick={() =>
renew.mutate({ gateway_id: c.gateway_id, usage: c.usage })
}
>
<RefreshCcw className="size-3.5" />
Erneuern
</Button>
</div>
</TD>
</TR>
))}
</TBody>
</Table>
</Card>
</div>
<Dialog
open={selected !== null}
onOpenChange={(o) => !o && setSelected(null)}
>
{selected && <DetailDrawer cert={selected} />}
</Dialog>
</>
);
}
function DetailDrawer({ cert }: { cert: Schemas["CertificateDto"] }) {
return (
<DialogContent>
<DialogHeader>
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.08em] text-ink-faint">
<ShieldCheck className="size-3.5 text-accent" />
Zertifikatsdetails
</div>
<DialogTitle className="mt-1.5">{cert.gateway_id}</DialogTitle>
<DialogDescription>
Verwendung {LABEL_USAGE[cert.usage]} · Status{" "}
<span className="text-ink">{LABEL_STATE[cert.state as CertState]}</span>
</DialogDescription>
</DialogHeader>
<DialogBody>
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Übersicht</TabsTrigger>
<TabsTrigger value="raw">PEM</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-5">
<Field label="Serial">
<code className="mono break-all text-[12px] text-ink">{cert.serial}</code>
</Field>
<div className="grid grid-cols-2 gap-5">
<Field label="Gültig ab">{fmtIso(cert.not_before)}</Field>
<Field label="Gültig bis">{fmtIso(cert.not_after)}</Field>
</div>
<Field label="Restlaufzeit">
<span
className={cn(
"num",
cert.days_to_expiry < 0
? "text-danger"
: cert.days_to_expiry <= 30
? "text-warn"
: "text-ink",
)}
>
{cert.days_to_expiry} Tage
</span>
</Field>
</TabsContent>
<TabsContent value="raw">
<pre className="surface-inset p-4 text-[11.5px] mono whitespace-pre-wrap break-all text-ink-mute">
{`-----BEGIN CERTIFICATE-----
[Backend liefert PEM nach Anbindung an /api/certs/{id}/pem]
-----END CERTIFICATE-----`}
</pre>
</TabsContent>
</Tabs>
</DialogBody>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" size="sm">Schließen</Button>
</DialogClose>
<Button variant="primary" size="sm">
<RefreshCcw className="size-3.5" />
Erneuern
</Button>
</DialogFooter>
</DialogContent>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="space-y-1">
<span className="text-[10.5px] uppercase tracking-[0.08em] text-ink-faint">
{label}
</span>
<div className="text-[13px] text-ink">{children}</div>
</div>
);
}
function ColHeader({
label,
active,
dir,
onClick,
}: {
label: string;
active: boolean;
dir: "asc" | "desc";
onClick: () => void;
}) {
return (
<TH>
<button
onClick={onClick}
className={cn(
"inline-flex items-center gap-1 text-[11px] uppercase tracking-[0.06em]",
active ? "text-ink" : "text-ink-faint hover:text-ink",
)}
>
{label}
<ArrowUpDown
className={cn(
"size-3 transition-transform",
active && dir === "desc" && "rotate-180",
!active && "opacity-40",
)}
/>
</button>
</TH>
);
}
function TableSkeleton() {
return (
<>
{[0, 1, 2, 3].map((i) => (
<TR key={i}>
{Array.from({ length: 7 }).map((_, j) => (
<TD key={j}>
<Skeleton className="h-4 w-full max-w-[180px]" />
</TD>
))}
</TR>
))}
</>
);
}
const LABEL_USAGE: Record<Schemas["CertificateUsageDto"], string> = {
tls: "TLS",
signature: "Signatur",
encryption: "Verschlüsselung",
};
const LABEL_STATE: Record<"all" | CertState, string> = {
all: "Alle",
valid: "Gültig",
expiring: "Erneuerung",
expired: "Abgelaufen",
pending: "Ausstehend",
};
function compareBy(
key: SortKey,
a: Schemas["CertificateDto"],
b: Schemas["CertificateDto"],
): number {
switch (key) {
case "gateway":
return a.gateway_id.localeCompare(b.gateway_id);
case "usage":
return a.usage.localeCompare(b.usage);
case "not_after":
return a.not_after.localeCompare(b.not_after);
case "state":
return STATE_ORDER[a.state] - STATE_ORDER[b.state];
}
}
const STATE_ORDER: Record<string, number> = {
expired: 0,
expiring: 1,
valid: 2,
};
function toggleSort(
next: SortKey,
current: SortKey,
dir: "asc" | "desc",
setKey: (k: SortKey) => void,
setDir: (d: "asc" | "desc") => void,
) {
if (current === next) {
setDir(dir === "asc" ? "desc" : "asc");
} else {
setKey(next);
setDir("asc");
}
}
async function copySerial(serial: string) {
try {
await navigator.clipboard.writeText(serial);
toast.success("Serial kopiert");
} catch {
toast.error("Kopieren fehlgeschlagen");
}
}
+457
View File
@@ -0,0 +1,457 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { toast } from "sonner";
import {
Cable,
Cpu,
Mailbox,
RotateCcw,
Save,
Settings2,
TimerReset,
} from "lucide-react";
import { Topbar } from "@/components/layout/topbar";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
import { api, unwrap, type Schemas, ApiError } from "@/lib/api";
import { cn } from "@/lib/utils";
export const Route = createFileRoute("/_app/configuration")({
component: ConfigurationPage,
});
function ConfigurationPage() {
const qc = useQueryClient();
const view = useQuery({
queryKey: ["config"],
queryFn: () => unwrap(api.GET("/config")),
});
const [draft, setDraft] = useState<Schemas["RuntimeConfig"] | null>(null);
useEffect(() => {
if (view.data && !draft) setDraft(view.data.config);
}, [view.data, draft]);
const save = useMutation({
mutationFn: (body: Schemas["ConfigUpdate"]) =>
unwrap(api.PUT("/config", { body })),
onSuccess: (data) => {
toast.success("Konfiguration gespeichert");
qc.setQueryData(["config"], data);
setDraft(data.config);
},
onError: (e: ApiError) =>
toast.error("Speichern fehlgeschlagen", { description: e.message }),
});
const testAlert = useMutation({
mutationFn: () =>
unwrap(
api.POST("/alerts/test", {
body: { subject: "SMTP-Test", body: "Konsole → Mail-Adapter" },
}),
),
onSuccess: () => toast.success("Test-Alert gesendet"),
onError: (e: ApiError) => toast.error("Test fehlgeschlagen", { description: e.message }),
});
if (!draft || !view.data) {
return (
<>
<Topbar title="Konfiguration" subtitle="Lade…" />
<div className="px-6 lg:px-10 py-7">
<div className="surface h-[60vh] grid place-items-center text-ink-faint">
Bereite Formular vor.
</div>
</div>
</>
);
}
const dirty = JSON.stringify(draft) !== JSON.stringify(view.data.config);
const restartFields = new Set(view.data.restart_required_fields);
const patch: Schemas["ConfigUpdate"] = {
cron_schedule: draft.cron_schedule,
days_window: draft.days_window,
sub_ca: draft.sub_ca,
smtp: draft.smtp,
hsm: draft.hsm,
};
return (
<>
<Topbar
title="Konfiguration"
subtitle="Runtime-Parameter und externe Anbindungen. Restart-pflichtige Felder sind markiert."
/>
<div className="px-6 lg:px-10 py-7 pb-32 max-w-[1100px] space-y-6">
<SectionCard
icon={TimerReset}
title="Scheduler"
desc="Cron-Ausdruck und Erneuerungsfenster. Wird zur Laufzeit übernommen."
>
<Row>
<Field label="Cron-Ausdruck (6-Field)">
<Input
mono
value={draft.cron_schedule}
onChange={(e) =>
setDraft({ ...draft, cron_schedule: e.target.value })
}
/>
<Hint>Sekunde Minute Stunde Tag Monat Wochentag.</Hint>
</Field>
<Field label="Tagesfenster">
<Input
type="number"
min={1}
max={365}
value={draft.days_window}
onChange={(e) =>
setDraft({ ...draft, days_window: Number(e.target.value) })
}
/>
<Hint>Default 30. SM-PKI CP empfiehlt frühe Erneuerung.</Hint>
</Field>
</Row>
</SectionCard>
<SectionCard
icon={Cable}
title="Sub-CA / SOAP"
desc="Endpunkt der Test-Sub-CA, mTLS-Material gem. TR-03129-4."
>
<Row>
<Field label="Endpoint">
<Input
mono
value={draft.sub_ca.endpoint}
onChange={(e) =>
setDraft({
...draft,
sub_ca: { ...draft.sub_ca, endpoint: e.target.value },
})
}
placeholder="https://test-ca.local/soap"
/>
</Field>
</Row>
<Row>
<Field label="Client-Cert (Pfad)">
<Input
mono
value={draft.sub_ca.client_cert_path ?? ""}
onChange={(e) =>
setDraft({
...draft,
sub_ca: { ...draft.sub_ca, client_cert_path: e.target.value },
})
}
placeholder="/etc/smgw/ca-client.crt.pem"
/>
</Field>
<Field label="Client-Key (Pfad)">
<Input
mono
value={draft.sub_ca.client_key_path ?? ""}
onChange={(e) =>
setDraft({
...draft,
sub_ca: { ...draft.sub_ca, client_key_path: e.target.value },
})
}
placeholder="/etc/smgw/ca-client.key.pem"
/>
</Field>
</Row>
<Row>
<Field label="CA-Bundle (Pfad)">
<Input
mono
value={draft.sub_ca.ca_bundle_path ?? ""}
onChange={(e) =>
setDraft({
...draft,
sub_ca: { ...draft.sub_ca, ca_bundle_path: e.target.value },
})
}
placeholder="/etc/smgw/sub-ca-chain.pem"
/>
</Field>
</Row>
</SectionCard>
<SectionCard
icon={Mailbox}
title="SMTP / Alerts"
desc="Operator-Benachrichtigungen bei Fehlern."
aside={
<Button
variant="outline"
size="sm"
onClick={() => testAlert.mutate()}
disabled={testAlert.isPending}
>
Test-Alert
</Button>
}
>
<Row>
<Field label="Host">
<Input
mono
value={draft.smtp.host}
onChange={(e) =>
setDraft({
...draft,
smtp: { ...draft.smtp, host: e.target.value },
})
}
/>
</Field>
<Field label="Port">
<Input
type="number"
value={draft.smtp.port}
onChange={(e) =>
setDraft({
...draft,
smtp: { ...draft.smtp, port: Number(e.target.value) },
})
}
/>
</Field>
<Field label="STARTTLS">
<div className="flex items-center gap-3 pt-1">
<Switch
checked={draft.smtp.starttls}
onCheckedChange={(v) =>
setDraft({
...draft,
smtp: { ...draft.smtp, starttls: v },
})
}
/>
<span className="text-[12.5px] text-ink-mute">
{draft.smtp.starttls ? "aktiv" : "inaktiv"}
</span>
</div>
</Field>
</Row>
<Row>
<Field label="Absender">
<Input
value={draft.smtp.from}
onChange={(e) =>
setDraft({
...draft,
smtp: { ...draft.smtp, from: e.target.value },
})
}
/>
</Field>
<Field label="Empfänger">
<Input
value={draft.smtp.to}
onChange={(e) =>
setDraft({
...draft,
smtp: { ...draft.smtp, to: e.target.value },
})
}
/>
</Field>
</Row>
</SectionCard>
<SectionCard
icon={Cpu}
title="HSM (PKCS#11)"
desc="SoftHSMv2-Modul, Slot und PIN-Quelle. Produktion: zertifiziertes HSM gem. SM-PKI CP."
>
<Row>
<Field label="Modul-Pfad">
<Input
mono
value={draft.hsm.module_path}
onChange={(e) =>
setDraft({
...draft,
hsm: { ...draft.hsm, module_path: e.target.value },
})
}
/>
</Field>
<Field label="Slot">
<Input
type="number"
value={draft.hsm.slot ?? ""}
onChange={(e) =>
setDraft({
...draft,
hsm: {
...draft.hsm,
slot: e.target.value === "" ? null : Number(e.target.value),
},
})
}
placeholder="auto"
/>
</Field>
<Field label="PIN-Env-Var">
<Input
mono
value={draft.hsm.pin_env_var}
onChange={(e) =>
setDraft({
...draft,
hsm: { ...draft.hsm, pin_env_var: e.target.value },
})
}
/>
</Field>
</Row>
</SectionCard>
<SectionCard
icon={Settings2}
title="Restart-pflichtig"
desc="Diese Felder werden nur beim Boot angewendet."
>
<Row>
<Field label="Bind-Adresse" restartRequired={restartFields.has("bind_addr")}>
<Input mono value={draft.bind_addr} readOnly disabled />
</Field>
<Field
label="Database-URL"
restartRequired={restartFields.has("database_url")}
>
<Input mono value={draft.database_url} readOnly disabled />
</Field>
</Row>
</SectionCard>
</div>
<SaveBar
visible={dirty}
onRevert={() => view.data && setDraft(view.data.config)}
onSave={() => save.mutate(patch)}
saving={save.isPending}
/>
</>
);
}
function SectionCard({
icon: Icon,
title,
desc,
aside,
children,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
desc: string;
aside?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<Card>
<CardHeader>
<div className="flex items-start gap-3">
<div className="size-8 rounded-[8px] bg-overlay grid place-items-center mt-0.5">
<Icon className="size-4 text-ink-mute" />
</div>
<div>
<CardTitle>{title}</CardTitle>
<p className="mt-1 text-[12.5px] text-ink-mute max-w-md leading-relaxed">
{desc}
</p>
</div>
</div>
{aside}
</CardHeader>
<Separator />
<CardBody className="space-y-5">{children}</CardBody>
</Card>
);
}
function Row({ children }: { children: React.ReactNode }) {
return (
<div className="grid gap-5 md:grid-cols-2 lg:[&>*:nth-child(3)]:col-span-1 lg:grid-cols-3">
{children}
</div>
);
}
function Field({
label,
restartRequired,
children,
}: {
label: string;
restartRequired?: boolean;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2">
<Label>{label}</Label>
{restartRequired && <Badge tone="warn">Restart nötig</Badge>}
</div>
{children}
</div>
);
}
function Hint({ children }: { children: React.ReactNode }) {
return <p className="text-[11.5px] text-ink-faint">{children}</p>;
}
function SaveBar({
visible,
onRevert,
onSave,
saving,
}: {
visible: boolean;
onRevert: () => void;
onSave: () => void;
saving: boolean;
}) {
return (
<div
aria-hidden={!visible}
className={cn(
"fixed bottom-5 left-1/2 -translate-x-1/2 z-30 transition-all duration-200",
visible
? "opacity-100 translate-y-0"
: "opacity-0 pointer-events-none translate-y-2",
)}
>
<div className="surface px-4 py-3 flex items-center gap-3 shadow-xl">
<div className="flex items-center gap-2 text-[12.5px]">
<span className="size-1.5 rounded-full bg-warn animate-pulse" />
<span className="text-ink">Ungespeicherte Änderungen</span>
</div>
<Button variant="ghost" size="sm" onClick={onRevert}>
<RotateCcw className="size-3.5" />
Verwerfen
</Button>
<Button variant="primary" size="sm" onClick={onSave} disabled={saving}>
<Save className="size-3.5" />
Speichern
</Button>
</div>
</div>
);
}
+230
View File
@@ -0,0 +1,230 @@
import { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { toast } from "sonner";
import {
Download,
FileLock2,
KeyRound,
ScanLine,
Shield,
Wand2,
} from "lucide-react";
import { Topbar } from "@/components/layout/topbar";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { api, unwrap, type Schemas, ApiError } from "@/lib/api";
export const Route = createFileRoute("/_app/iconfig")({
component: IconfigPage,
});
const PROFILES = [
{ id: "smgw-default", label: "SMGW Default", note: "TR-03109-1 Standardprofil" },
{ id: "lab-debug", label: "Lab Debug", note: "Erweiterte Diagnose-Felder" },
];
function IconfigPage() {
const gateways = useQuery({
queryKey: ["gateways"],
queryFn: () => unwrap(api.GET("/gateways")),
});
const [form, setForm] = useState<Schemas["IconfigRequest"]>({
gateway_id: "",
admin_key_label: "",
profile: PROFILES[0].id,
});
const [preview, setPreview] = useState<string | null>(null);
const previewMut = useMutation({
mutationFn: (body: Schemas["IconfigRequest"]) =>
unwrap(api.POST("/iconfig/preview", { body })),
onSuccess: (d) => setPreview(d.xml),
onError: (e: ApiError) =>
toast.error("Vorschau fehlgeschlagen", { description: e.message }),
});
const buildMut = useMutation({
mutationFn: (body: Schemas["IconfigRequest"]) =>
unwrap(api.POST("/iconfig/build", { body })),
onError: (e: ApiError) =>
toast.error("Build abgelehnt", { description: e.message }),
});
const ready = form.gateway_id.length > 0 && form.admin_key_label.length > 0;
return (
<>
<Topbar
title="iconfig.tar"
subtitle="Initial-Konfiguration gemäß BSI TR-03109-1, signiert via HSM."
/>
<div className="px-6 lg:px-10 py-7 grid gap-6 max-w-[1320px] xl:grid-cols-[480px_1fr]">
<Card>
<CardHeader>
<div className="flex items-start gap-3">
<div className="size-8 rounded-[8px] bg-accent-soft grid place-items-center mt-0.5">
<FileLock2 className="size-4 text-accent" />
</div>
<div>
<CardTitle>Profil zusammensetzen</CardTitle>
<p className="mt-1 text-[12.5px] text-ink-mute leading-relaxed">
Auswahl Gateway, Admin-Schlüssel, Profilvorlage. Vorschau bevor signiert wird.
</p>
</div>
</div>
</CardHeader>
<Separator />
<CardBody className="space-y-5">
<div className="space-y-1.5">
<Label>Gateway</Label>
<select
value={form.gateway_id}
onChange={(e) => setForm({ ...form, gateway_id: e.target.value })}
className="h-9 w-full rounded-[8px] border border-line bg-paper px-3 text-[13px] text-ink focus-ring hover:border-line-strong focus:border-ink mono"
>
<option value=""> bitte wählen </option>
{(gateways.data?.items ?? []).map((g) => (
<option key={g.id} value={g.id}>
{g.id} · {g.serial_number}
</option>
))}
</select>
<p className="text-[11.5px] text-ink-faint">
{gateways.data?.items.length ?? 0} Gateways registriert.
</p>
</div>
<div className="space-y-1.5">
<Label>Admin-Key Label</Label>
<Input
mono
value={form.admin_key_label}
onChange={(e) =>
setForm({ ...form, admin_key_label: e.target.value })
}
placeholder="GW-ADM-01"
/>
<p className="text-[11.5px] text-ink-faint">
PKCS#11-Label im HSM. Wird über sign_xml referenziert.
</p>
</div>
<div className="space-y-2">
<Label>Profil</Label>
<div className="grid gap-2">
{PROFILES.map((p) => {
const selected = form.profile === p.id;
return (
<button
key={p.id}
type="button"
onClick={() => setForm({ ...form, profile: p.id })}
className={`text-left rounded-[10px] border p-3 transition-colors focus-ring ${
selected
? "border-ink bg-overlay"
: "border-line hover:border-line-strong hover:bg-overlay/60"
}`}
>
<div className="flex items-center justify-between">
<span className="text-[13px] font-medium text-ink">
{p.label}
</span>
{selected && <Badge tone="accent">Aktiv</Badge>}
</div>
<p className="mt-1 text-[12px] text-ink-mute">{p.note}</p>
</button>
);
})}
</div>
</div>
<Separator />
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="md"
disabled={!ready || previewMut.isPending}
onClick={() => previewMut.mutate(form)}
>
<ScanLine className="size-3.5" />
Vorschau erzeugen
</Button>
<Button
variant="primary"
size="md"
disabled={!ready || buildMut.isPending}
onClick={() => buildMut.mutate(form)}
>
<Wand2 className="size-3.5" />
Signieren & herunterladen
</Button>
</div>
<div className="surface-inset p-3 text-[11.5px] text-ink-mute leading-relaxed">
<div className="flex items-start gap-2">
<Shield className="size-3.5 text-ink-faint mt-0.5 shrink-0" />
<div>
Signatur erfolgt server-seitig über{" "}
<code className="mono text-[11px] text-ink">HsmPort::sign_xml</code>{" "}
auf kanonisierten Bytes (C14N), nicht auf pretty-printed XML.
</div>
</div>
</div>
</CardBody>
</Card>
<Card>
<CardHeader>
<div className="flex items-start gap-3">
<div className="size-8 rounded-[8px] bg-overlay grid place-items-center mt-0.5">
<KeyRound className="size-4 text-ink-mute" />
</div>
<div>
<CardTitle>Vorschau</CardTitle>
<p className="mt-1 text-[12.5px] text-ink-mute leading-relaxed">
Unsignierter XML-Body. Inhalt wird vor Signatur kanonisiert.
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
disabled={!preview}
onClick={() => preview && downloadXml(preview, form.gateway_id)}
>
<Download className="size-3.5" />
Als XML speichern
</Button>
</CardHeader>
<Separator />
<CardBody className="p-0">
<pre className="m-0 max-h-[600px] overflow-auto p-5 text-[12px] mono whitespace-pre text-ink leading-relaxed bg-paper">
{preview ??
`// Vorschau erscheint hier, sobald „Vorschau erzeugen" geklickt wurde.
// Die Datei wird auf dem Backend gebaut — diese Konsole rendert nur das Ergebnis.`}
</pre>
</CardBody>
</Card>
</div>
</>
);
}
function downloadXml(xml: string, gateway: string) {
const blob = new Blob([xml], { type: "application/xml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `iconfig-${gateway || "preview"}.xml`;
a.click();
URL.revokeObjectURL(url);
}
+381
View File
@@ -0,0 +1,381 @@
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import {
Activity,
AlertCircle,
CalendarClock,
CheckCircle2,
Clock,
Hourglass,
Info,
ShieldAlert,
TriangleAlert,
} from "lucide-react";
import { Topbar } from "@/components/layout/topbar";
import { Card, CardBody, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { api, unwrap, type Schemas } from "@/lib/api";
import { fmtIso, fmtRelative } from "@/lib/format";
import { cn } from "@/lib/utils";
export const Route = createFileRoute("/_app/")({
component: Dashboard,
});
function Dashboard() {
const certs = useQuery({
queryKey: ["certs"],
queryFn: () => unwrap(api.GET("/certs")),
refetchInterval: 60_000,
});
const scheduler = useQuery({
queryKey: ["scheduler"],
queryFn: () => unwrap(api.GET("/scheduler")),
refetchInterval: 10_000,
});
const alerts = useQuery({
queryKey: ["alerts"],
queryFn: () => unwrap(api.GET("/alerts")),
refetchInterval: 30_000,
});
const counts = countByState(certs.data?.items ?? []);
return (
<>
<Topbar
title="Übersicht"
subtitle="Bestand, Erneuerungsplan und letzte Ereignisse der Sub-CA-Anbindung."
/>
<div className="px-6 lg:px-10 py-7 space-y-7 max-w-[1320px]">
<section className="grid gap-4 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
label="Gültig"
value={counts.valid}
hint={`${counts.total} Zertifikate gesamt`}
tone="success"
icon={CheckCircle2}
/>
<StatCard
label="Erneuerung fällig"
value={counts.expiring}
hint={`im ${scheduler.data?.days_window ?? 30}-Tage-Fenster`}
tone="warn"
icon={Hourglass}
emphasise={counts.expiring > 0}
/>
<StatCard
label="Abgelaufen"
value={counts.expired}
hint="Manueller Eingriff nötig"
tone="danger"
icon={TriangleAlert}
/>
<StatCard
label="In Bearbeitung"
value={counts.pending}
hint="Auf CA-Callback wartend"
tone="neutral"
icon={Clock}
/>
</section>
<section className="grid gap-4 lg:grid-cols-[1.4fr_1fr]">
<Card>
<CardHeader>
<div>
<CardTitle>Renewal-Scheduler</CardTitle>
<p className="text-[12.5px] text-ink-mute mt-1">
Periodischer Lauf gem. SM-PKI CP. Manueller Trigger via Topbar.
</p>
</div>
<Badge
tone={scheduler.data?.paused ? "warn" : "success"}
dot
>
{scheduler.data?.paused ? "Pausiert" : "Aktiv"}
</Badge>
</CardHeader>
<CardBody className="grid grid-cols-2 md:grid-cols-3 gap-x-6 gap-y-5">
<KeyValue
label="Cron-Ausdruck"
value={
<code className="mono text-[12.5px] text-ink">
{scheduler.data?.cron_schedule ?? "—"}
</code>
}
/>
<KeyValue
label="Fenster"
value={
<span className="num text-[14px] text-ink">
{scheduler.data?.days_window ?? "—"} Tage
</span>
}
/>
<KeyValue
label="Bearbeitet (zuletzt)"
value={
<span className="num text-[14px] text-ink">
{scheduler.data?.last_handled ?? 0}
</span>
}
/>
<KeyValue
label="Letzter Lauf"
value={
<div className="flex items-center gap-2">
<CalendarClock className="size-3.5 text-ink-faint" />
<span className="text-[13px] text-ink">
{fmtRelative(scheduler.data?.last_run_at)}
</span>
</div>
}
sub={fmtIso(scheduler.data?.last_run_at)}
/>
<KeyValue
label="Status"
value={
scheduler.data?.last_run_ok === null ||
scheduler.data?.last_run_ok === undefined ? (
<span className="text-ink-faint">Noch kein Lauf</span>
) : scheduler.data.last_run_ok ? (
<span className="text-success inline-flex items-center gap-1.5">
<CheckCircle2 className="size-3.5" /> Erfolgreich
</span>
) : (
<span className="text-danger inline-flex items-center gap-1.5">
<ShieldAlert className="size-3.5" /> Fehler
</span>
)
}
sub={scheduler.data?.last_error ?? undefined}
/>
</CardBody>
<CardFooter>
<span>
Single-flight: parallele Läufe werden via Semaphore unterdrückt.
</span>
<Badge tone="outline">tokio-cron-scheduler</Badge>
</CardFooter>
</Card>
<Card>
<CardHeader>
<div>
<CardTitle>Ereignisse</CardTitle>
<p className="text-[12.5px] text-ink-mute mt-1">
Operator-Alerts, jüngste zuerst.
</p>
</div>
<Activity className="size-4 text-ink-faint" />
</CardHeader>
<CardBody className="pt-0">
{alerts.isLoading ? (
<AlertSkeletons />
) : (alerts.data?.items.length ?? 0) === 0 ? (
<EmptyAlerts />
) : (
<ul className="-mx-1">
{alerts.data!.items
.slice()
.reverse()
.slice(0, 6)
.map((a, idx) => (
<li
key={idx}
className="px-1.5 py-3 first:pt-1 last:pb-1 border-b border-line last:border-0"
>
<AlertRow entry={a} />
</li>
))}
</ul>
)}
</CardBody>
</Card>
</section>
</div>
</>
);
}
function countByState(items: Schemas["CertificateDto"][]) {
let valid = 0,
expiring = 0,
expired = 0;
for (const c of items) {
if (c.state === "valid") valid += 1;
else if (c.state === "expiring") expiring += 1;
else if (c.state === "expired") expired += 1;
}
return { valid, expiring, expired, pending: 0, total: items.length };
}
type Tone = "success" | "warn" | "danger" | "neutral";
const TONE_DECOR: Record<Tone, { ring: string; iconBg: string; iconColor: string }> = {
success: {
ring: "before:bg-success/60",
iconBg: "bg-success-soft",
iconColor: "text-success",
},
warn: {
ring: "before:bg-warn/70",
iconBg: "bg-warn-soft",
iconColor: "text-warn",
},
danger: {
ring: "before:bg-danger/70",
iconBg: "bg-danger-soft",
iconColor: "text-danger",
},
neutral: {
ring: "before:bg-line-strong",
iconBg: "bg-overlay",
iconColor: "text-ink-mute",
},
};
function StatCard({
label,
value,
hint,
tone,
icon: Icon,
emphasise = false,
}: {
label: string;
value: number | undefined;
hint: string;
tone: Tone;
icon: React.ComponentType<{ className?: string }>;
emphasise?: boolean;
}) {
const t = TONE_DECOR[tone];
return (
<div
className={cn(
"surface relative overflow-hidden p-5",
"before:content-[''] before:absolute before:inset-y-3 before:left-0 before:w-[3px] before:rounded-r-full",
t.ring,
)}
>
<div className="flex items-start justify-between">
<span className="text-[11px] uppercase tracking-[0.08em] text-ink-faint">
{label}
</span>
<div
className={cn(
"size-7 rounded-[8px] grid place-items-center",
t.iconBg,
)}
>
<Icon className={cn("size-3.5", t.iconColor)} />
</div>
</div>
<div className="mt-3 flex items-baseline gap-2">
<span
className={cn(
"font-serif num text-ink",
emphasise ? "text-[56px] leading-none" : "text-[48px] leading-none",
)}
>
{value ?? <Skeleton className="inline-block w-[60px] h-[40px]" />}
</span>
</div>
<p className="mt-2 text-[12px] text-ink-mute">{hint}</p>
</div>
);
}
function KeyValue({
label,
value,
sub,
}: {
label: string;
value: React.ReactNode;
sub?: string;
}) {
return (
<div className="flex flex-col gap-1">
<span className="text-[10.5px] uppercase tracking-[0.08em] text-ink-faint">
{label}
</span>
<div>{value}</div>
{sub && <span className="text-[11.5px] text-ink-faint mono truncate">{sub}</span>}
</div>
);
}
function AlertRow({ entry }: { entry: Schemas["AlertEntry"] }) {
const tone =
entry.severity === "error"
? "danger"
: entry.severity === "warning"
? "warn"
: "accent";
const Icon =
entry.severity === "error"
? AlertCircle
: entry.severity === "warning"
? TriangleAlert
: Info;
return (
<div className="flex gap-3">
<div
className={cn(
"size-7 shrink-0 rounded-[8px] grid place-items-center",
tone === "danger" && "bg-danger-soft text-danger",
tone === "warn" && "bg-warn-soft text-warn",
tone === "accent" && "bg-accent-soft text-accent",
)}
>
<Icon className="size-3.5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[12.5px] font-medium text-ink truncate">
{entry.subject}
</span>
<span className="ml-auto text-[10.5px] text-ink-faint shrink-0 mono">
{fmtRelative(entry.at)}
</span>
</div>
<p className="text-[12px] text-ink-mute mt-0.5 line-clamp-2">{entry.body}</p>
</div>
</div>
);
}
function EmptyAlerts() {
return (
<div className="py-8 text-center">
<div className="mx-auto size-9 rounded-full bg-overlay grid place-items-center">
<Info className="size-4 text-ink-faint" />
</div>
<p className="mt-3 text-[13px] text-ink">Keine Ereignisse</p>
<p className="text-[12px] text-ink-mute mt-0.5">
Erfolgreiche Läufe und Alerts erscheinen hier.
</p>
</div>
);
}
function AlertSkeletons() {
return (
<div className="space-y-3">
{[0, 1, 2].map((i) => (
<div key={i} className="flex gap-3">
<Skeleton className="size-7 rounded-[8px]" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3 w-2/3" />
<Skeleton className="h-3 w-full" />
</div>
</div>
))}
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
import { Sidebar } from "@/components/layout/sidebar";
import { api } from "@/lib/api";
export const Route = createFileRoute("/_app")({
beforeLoad: async () => {
const { response } = await api.GET("/auth/me");
if (response.status === 401 || response.status === 403) {
throw redirect({ to: "/login" });
}
},
component: AppLayout,
});
function AppLayout() {
return (
<div className="flex min-h-screen">
<Sidebar />
<main className="flex-1 flex flex-col min-w-0">
<Outlet />
</main>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { ShieldCheck, KeyRound, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { api, unwrap, ApiError } from "@/lib/api";
export const Route = createFileRoute("/login")({
component: LoginPage,
});
function LoginPage() {
const navigate = useNavigate();
const [devSubject, setDevSubject] = useState("CN=lab.operator,OU=PKI,O=SMGW");
const [err, setErr] = useState<string | null>(null);
const login = useMutation({
mutationFn: () =>
unwrap(
api.POST("/auth/session", {
body: { dev_subject: devSubject },
}),
),
onError: (e: ApiError) => setErr(e.message),
onSuccess: () => navigate({ to: "/" }),
});
return (
<div className="min-h-screen grid lg:grid-cols-[1.05fr_0.95fr]">
<aside className="relative hidden lg:flex flex-col justify-between p-12 border-r border-line overflow-hidden bg-paper">
<div className="absolute inset-0 grid-bg opacity-60 pointer-events-none" />
<div className="relative z-10 flex items-center gap-3">
<div className="size-10 rounded-[12px] bg-ink text-paper grid place-items-center font-display font-semibold text-[17px] tracking-[-0.02em]">
sm
</div>
<div className="leading-tight">
<div className="font-display text-[16px] font-semibold tracking-[-0.015em] text-ink">
SMGW PKI · Console
</div>
<div className="text-[11px] uppercase tracking-[0.1em] text-ink-faint">
Test / Labor BSI TR-03129-4
</div>
</div>
</div>
<div className="relative z-10 max-w-md space-y-6">
<p className="font-serif italic text-[34px] leading-[1.1] text-ink tracking-[-0.01em]">
Eine ruhige Konsole für eine{" "}
<span className="not-italic font-display font-semibold">
vorsichtige PKI
</span>
.
</p>
<p className="text-[13.5px] leading-relaxed text-ink-mute">
Steuerfläche für den smgw-pki-automator. Erneuerungen, Sub-CA-Status,
iconfig-Generierung und SoftHSM-Diagnose alle Operationen sind
auditiert und an mTLS-Identitäten gebunden.
</p>
<div className="flex flex-wrap gap-2">
<Badge tone="outline">TR-03129-4</Badge>
<Badge tone="outline">TR-03109-1</Badge>
<Badge tone="outline">SM-PKI CP</Badge>
</div>
</div>
<div className="relative z-10 text-[11.5px] text-ink-faint mono">
v0.1.0 · build skeleton · {new Date().getFullYear()}
</div>
</aside>
<section className="flex items-center justify-center p-8">
<div className="w-full max-w-[420px] surface p-7">
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.1em] text-ink-faint">
<ShieldCheck className="size-3.5 text-accent" />
Sitzung
</div>
<h2 className="mt-3 font-display text-[26px] font-semibold tracking-[-0.02em] text-ink">
Anmeldung über mTLS
</h2>
<p className="mt-1.5 text-[13px] leading-relaxed text-ink-mute">
Ihr Client-Zertifikat wird vom Reverse Proxy terminiert und im Header{" "}
<code className="mono text-[12px]">X-Forwarded-Cert-Subject</code>{" "}
übergeben. Im Lab-Modus ist ein Dev-Subject als Fallback erlaubt.
</p>
<form
onSubmit={(e) => {
e.preventDefault();
setErr(null);
login.mutate();
}}
className="mt-7 space-y-4"
>
<div className="space-y-1.5">
<Label htmlFor="dev_subject">Dev-Subject (nur Lab)</Label>
<Input
id="dev_subject"
value={devSubject}
onChange={(e) => setDevSubject(e.target.value)}
mono
placeholder="CN=…"
/>
<p className="text-[11.5px] text-ink-faint">
Wird ignoriert, sobald der Proxy ein gültiges Cert-Subject mitschickt.
</p>
</div>
{err && (
<div className="flex gap-2 items-start surface-inset p-3 text-[12.5px] text-danger">
<AlertTriangle className="size-4 mt-0.5 shrink-0" />
<span>{err}</span>
</div>
)}
<Button
type="submit"
variant="primary"
size="lg"
className="w-full"
disabled={login.isPending}
>
<KeyRound className="size-4" />
Sitzung erstellen
</Button>
</form>
<div className="mt-6 pt-5 border-t border-line text-[11.5px] text-ink-faint leading-relaxed">
Cookies sind <span className="text-ink">HttpOnly</span> und{" "}
<span className="text-ink">SameSite=Strict</span>. Sitzungsdauer 8 h.
</div>
</div>
</section>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}
+36
View File
@@ -0,0 +1,36 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
import path from "node:path";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const apiTarget = env.VITE_API_PROXY_TARGET ?? "http://localhost:8443";
return {
plugins: [
TanStackRouterVite({ target: "react", autoCodeSplitting: true }),
react(),
tailwindcss(),
],
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
server: {
host: "0.0.0.0",
port: 5173,
proxy: {
"/api": {
target: apiTarget,
changeOrigin: true,
secure: false,
},
},
},
build: {
target: "es2022",
sourcemap: true,
},
};
});