init
This commit is contained in:
+17
@@ -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
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,7 @@
|
||||
target/
|
||||
.git/
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -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
|
||||
Generated
+3399
File diff suppressed because it is too large
Load Diff
@@ -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"] }
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(¬_before_s, &Rfc3339).map_err(parse_err)?,
|
||||
not_after: OffsetDateTime::parse(¬_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));
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod clock;
|
||||
pub mod db;
|
||||
pub mod hsm;
|
||||
pub mod mail;
|
||||
pub mod sub_ca;
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod iconfig;
|
||||
pub mod soap_req;
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Gateway {
|
||||
pub id: String,
|
||||
pub serial_number: String,
|
||||
pub admin_key_label: String,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod certificate;
|
||||
pub mod gateway;
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -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",
|
||||
))
|
||||
}
|
||||
@@ -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"],
|
||||
}))
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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",
|
||||
))
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
use axum::http::StatusCode;
|
||||
|
||||
pub async fn handler() -> (StatusCode, &'static str) {
|
||||
(StatusCode::OK, "ok")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod inbound;
|
||||
pub mod outbound;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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>>;
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod callback;
|
||||
pub mod renew;
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
.git/
|
||||
.idea/
|
||||
.vscode/
|
||||
*.log
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 geproxied
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
Vendored
+740
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
Vendored
+11
@@ -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;
|
||||
}
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>()
|
||||
@@ -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 />,
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user