feat(admin): export/import configuration bundle for migration
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Backend: GET/POST /api/v1/admin/export-config and /import-config Export includes (sensitive values redacted): - system_configs (email/jira settings) - webhook_configs (secrets redacted) - sso_configs (private key redacted) - scoring_config (weights) - test_templates (source=custom only) - users (no passwords/tokens, must_change_password=True on import) Import is idempotent — upsert by natural keys, safe to run multiple times. Frontend: ExportImportSection in SystemPage (admin only) - 'Export Configuration' → downloads aegis-config-YYYY-MM-DD.json - 'Import Configuration' → file picker, sends JSON, shows summary - Visual checklist of what is/isn't included in the export Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,7 @@ from app.routers import osint as osint_router
|
|||||||
from app.routers import webhooks as webhooks_router
|
from app.routers import webhooks as webhooks_router
|
||||||
from app.routers import detection_lifecycle as detection_lifecycle_router
|
from app.routers import detection_lifecycle as detection_lifecycle_router
|
||||||
from app.routers import intel as intel_router
|
from app.routers import intel as intel_router
|
||||||
|
from app.routers import admin_config as admin_config_router
|
||||||
from app.routers import ownership as ownership_router
|
from app.routers import ownership as ownership_router
|
||||||
from app.routers import attack_paths as attack_paths_router
|
from app.routers import attack_paths as attack_paths_router
|
||||||
from app.routers import knowledge as knowledge_router
|
from app.routers import knowledge as knowledge_router
|
||||||
@@ -165,6 +166,7 @@ app.include_router(scores_router.router, prefix="/api/v1")
|
|||||||
app.include_router(operational_metrics_router.router, prefix="/api/v1")
|
app.include_router(operational_metrics_router.router, prefix="/api/v1")
|
||||||
app.include_router(compliance_router.router, prefix="/api/v1")
|
app.include_router(compliance_router.router, prefix="/api/v1")
|
||||||
app.include_router(intel_router.router, prefix="/api/v1")
|
app.include_router(intel_router.router, prefix="/api/v1")
|
||||||
|
app.include_router(admin_config_router.router, prefix="/api/v1")
|
||||||
app.include_router(snapshots_router.router, prefix="/api/v1")
|
app.include_router(snapshots_router.router, prefix="/api/v1")
|
||||||
app.include_router(jira_router.router, prefix="/api/v1")
|
app.include_router(jira_router.router, prefix="/api/v1")
|
||||||
app.include_router(worklogs_router.router, prefix="/api/v1")
|
app.include_router(worklogs_router.router, prefix="/api/v1")
|
||||||
|
|||||||
340
backend/app/routers/admin_config.py
Normal file
340
backend/app/routers/admin_config.py
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
"""Admin configuration export/import — single-file migration bundle.
|
||||||
|
|
||||||
|
GET /admin/export-config — download JSON bundle (admin only)
|
||||||
|
POST /admin/import-config — upload JSON bundle and restore (admin only)
|
||||||
|
|
||||||
|
What is exported (and what is NOT):
|
||||||
|
✓ system_configs — email / jira settings (passwords REDACTED)
|
||||||
|
✓ webhook_configs — notification webhooks (secrets REDACTED)
|
||||||
|
✓ sso_configs — SAML/SSO config (private keys REDACTED)
|
||||||
|
✓ scoring_config — technique scoring weights
|
||||||
|
✓ test_templates — CUSTOM templates only (source='custom')
|
||||||
|
✓ users — username / email / role (no passwords / tokens)
|
||||||
|
✗ atomic/sigma/elastic templates, techniques, tests, campaigns, reports
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth import hash_password
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies.auth import get_current_user, require_role
|
||||||
|
from app.models.scoring_config import ScoringConfig
|
||||||
|
from app.models.sso_config import SsoConfig
|
||||||
|
from app.models.system_config import SystemConfig
|
||||||
|
from app.models.test_template import TestTemplate
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.webhook_config import WebhookConfig
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
# Keys whose values contain secrets and must be redacted in the export
|
||||||
|
_REDACTED_KEYS = {
|
||||||
|
"smtp.password",
|
||||||
|
"jira.api_token",
|
||||||
|
"jira.password",
|
||||||
|
"tempo.api_token",
|
||||||
|
}
|
||||||
|
|
||||||
|
_EXPORT_VERSION = "1.0"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _redact(key: str, value: Any) -> Any:
|
||||||
|
if key in _REDACTED_KEYS:
|
||||||
|
return "[REDACTED]"
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /admin/export-config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export-config")
|
||||||
|
def export_config(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role("admin")),
|
||||||
|
):
|
||||||
|
"""Export all platform configuration as a downloadable JSON bundle."""
|
||||||
|
|
||||||
|
# ── 1. system_configs ────────────────────────────────────────────
|
||||||
|
system_configs = [
|
||||||
|
{
|
||||||
|
"key": r.key,
|
||||||
|
"value": _redact(r.key, r.value),
|
||||||
|
"description": r.description,
|
||||||
|
}
|
||||||
|
for r in db.query(SystemConfig).order_by(SystemConfig.key).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── 2. webhook_configs ───────────────────────────────────────────
|
||||||
|
webhooks = [
|
||||||
|
{
|
||||||
|
"name": w.name,
|
||||||
|
"url": w.url,
|
||||||
|
"secret": "[REDACTED]" if w.secret else None,
|
||||||
|
"events": w.events or [],
|
||||||
|
"is_active": w.is_active,
|
||||||
|
}
|
||||||
|
for w in db.query(WebhookConfig).order_by(WebhookConfig.name).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── 3. SSO config (single row) ───────────────────────────────────
|
||||||
|
sso_row = db.query(SsoConfig).first()
|
||||||
|
sso = None
|
||||||
|
if sso_row:
|
||||||
|
sso = {
|
||||||
|
"is_enabled": sso_row.is_enabled,
|
||||||
|
"provider_name": sso_row.provider_name,
|
||||||
|
"sp_entity_id": sso_row.sp_entity_id,
|
||||||
|
"sp_acs_url": sso_row.sp_acs_url,
|
||||||
|
"sp_slo_url": sso_row.sp_slo_url,
|
||||||
|
"sp_certificate": sso_row.sp_certificate,
|
||||||
|
"sp_private_key": "[REDACTED]", # never export private keys
|
||||||
|
"idp_entity_id": sso_row.idp_entity_id,
|
||||||
|
"idp_sso_url": getattr(sso_row, "idp_sso_url", None),
|
||||||
|
"idp_slo_url": getattr(sso_row, "idp_slo_url", None),
|
||||||
|
"idp_certificate": getattr(sso_row, "idp_certificate", None),
|
||||||
|
"attr_email": getattr(sso_row, "attr_email", None),
|
||||||
|
"attr_username": getattr(sso_row, "attr_username", None),
|
||||||
|
"attr_role": getattr(sso_row, "attr_role", None),
|
||||||
|
"default_role": getattr(sso_row, "default_role", None),
|
||||||
|
"auto_provision": getattr(sso_row, "auto_provision", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 4. Scoring config (single row) ──────────────────────────────
|
||||||
|
sc = db.query(ScoringConfig).first()
|
||||||
|
scoring = None
|
||||||
|
if sc:
|
||||||
|
scoring = {
|
||||||
|
"weight_tests": sc.weight_tests,
|
||||||
|
"weight_detection_rules": sc.weight_detection_rules,
|
||||||
|
"weight_d3fend": sc.weight_d3fend,
|
||||||
|
"weight_recency": sc.weight_recency,
|
||||||
|
"weight_severity": sc.weight_severity,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 5. Custom test templates only ───────────────────────────────
|
||||||
|
templates = [
|
||||||
|
{
|
||||||
|
"mitre_technique_id": t.mitre_technique_id,
|
||||||
|
"name": t.name,
|
||||||
|
"description": t.description,
|
||||||
|
"source": t.source,
|
||||||
|
"source_url": t.source_url,
|
||||||
|
"attack_procedure": t.attack_procedure,
|
||||||
|
"expected_detection": t.expected_detection,
|
||||||
|
"platform": t.platform,
|
||||||
|
"tool_suggested": t.tool_suggested,
|
||||||
|
"severity": t.severity,
|
||||||
|
"suggested_remediation": t.suggested_remediation,
|
||||||
|
"is_active": t.is_active,
|
||||||
|
}
|
||||||
|
for t in db.query(TestTemplate).filter(TestTemplate.source == "custom").all()
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── 6. Users (sanitized — no passwords/tokens) ───────────────────
|
||||||
|
users = [
|
||||||
|
{
|
||||||
|
"username": u.username,
|
||||||
|
"email": u.email if hasattr(u, "email") else None,
|
||||||
|
"role": u.role,
|
||||||
|
"is_active": u.is_active,
|
||||||
|
"must_change_password": True, # force password reset on new instance
|
||||||
|
}
|
||||||
|
for u in db.query(User).order_by(User.username).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
bundle = {
|
||||||
|
"_meta": {
|
||||||
|
"version": _EXPORT_VERSION,
|
||||||
|
"exported_at": datetime.utcnow().isoformat() + "Z",
|
||||||
|
"exported_by": current_user.username,
|
||||||
|
"note": (
|
||||||
|
"Sensitive values (passwords, API tokens, private keys) are REDACTED. "
|
||||||
|
"Re-enter them manually after import. "
|
||||||
|
"User passwords are NOT exported — users must reset passwords on first login."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"system_configs": system_configs,
|
||||||
|
"webhooks": webhooks,
|
||||||
|
"sso": sso,
|
||||||
|
"scoring": scoring,
|
||||||
|
"custom_templates": templates,
|
||||||
|
"users": users,
|
||||||
|
}
|
||||||
|
|
||||||
|
filename = f"aegis-config-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}.json"
|
||||||
|
return JSONResponse(
|
||||||
|
content=bundle,
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||||
|
"X-Export-Version": _EXPORT_VERSION,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /admin/import-config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import-config")
|
||||||
|
async def import_config(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role("admin")),
|
||||||
|
):
|
||||||
|
"""Restore platform configuration from a previously exported JSON bundle.
|
||||||
|
|
||||||
|
Idempotent: safe to run multiple times. Existing records are updated,
|
||||||
|
missing ones are created. REDACTED values are skipped (left as-is).
|
||||||
|
User passwords are set to a random temp value with must_change_password=True.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
bundle = await request.json()
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||||
|
|
||||||
|
meta = bundle.get("_meta", {})
|
||||||
|
version = meta.get("version", "unknown")
|
||||||
|
summary: dict[str, int] = {
|
||||||
|
"system_configs": 0,
|
||||||
|
"webhooks": 0,
|
||||||
|
"custom_templates": 0,
|
||||||
|
"users_created": 0,
|
||||||
|
"users_updated": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 1. system_configs ────────────────────────────────────────────
|
||||||
|
for item in bundle.get("system_configs", []):
|
||||||
|
key = item.get("key")
|
||||||
|
value = item.get("value")
|
||||||
|
if not key or value == "[REDACTED]":
|
||||||
|
continue
|
||||||
|
row = db.query(SystemConfig).filter(SystemConfig.key == key).first()
|
||||||
|
if row:
|
||||||
|
row.value = value
|
||||||
|
row.description = item.get("description") or row.description
|
||||||
|
else:
|
||||||
|
db.add(SystemConfig(key=key, value=value, description=item.get("description")))
|
||||||
|
summary["system_configs"] += 1
|
||||||
|
|
||||||
|
# ── 2. webhooks ──────────────────────────────────────────────────
|
||||||
|
for item in bundle.get("webhooks", []):
|
||||||
|
name = item.get("name")
|
||||||
|
url = item.get("url")
|
||||||
|
if not name or not url:
|
||||||
|
continue
|
||||||
|
existing = db.query(WebhookConfig).filter(WebhookConfig.name == name).first()
|
||||||
|
if existing:
|
||||||
|
existing.url = url
|
||||||
|
existing.events = item.get("events", [])
|
||||||
|
existing.is_active = item.get("is_active", True)
|
||||||
|
existing.failure_count = 0
|
||||||
|
else:
|
||||||
|
db.add(WebhookConfig(
|
||||||
|
name=name,
|
||||||
|
url=url,
|
||||||
|
secret=None, # never restore secrets
|
||||||
|
events=item.get("events", []),
|
||||||
|
is_active=item.get("is_active", True),
|
||||||
|
created_by=current_user.id,
|
||||||
|
failure_count=0,
|
||||||
|
))
|
||||||
|
summary["webhooks"] += 1
|
||||||
|
|
||||||
|
# ── 3. SSO config ────────────────────────────────────────────────
|
||||||
|
sso_data = bundle.get("sso")
|
||||||
|
if sso_data:
|
||||||
|
sso_row = db.query(SsoConfig).first()
|
||||||
|
if sso_row:
|
||||||
|
for field, val in sso_data.items():
|
||||||
|
if val == "[REDACTED]":
|
||||||
|
continue
|
||||||
|
if hasattr(sso_row, field):
|
||||||
|
setattr(sso_row, field, val)
|
||||||
|
else:
|
||||||
|
clean = {k: v for k, v in sso_data.items() if v != "[REDACTED]"}
|
||||||
|
clean.pop("sp_private_key", None)
|
||||||
|
db.add(SsoConfig(**clean))
|
||||||
|
|
||||||
|
# ── 4. Scoring config ────────────────────────────────────────────
|
||||||
|
scoring_data = bundle.get("scoring")
|
||||||
|
if scoring_data:
|
||||||
|
sc = db.query(ScoringConfig).first()
|
||||||
|
if sc:
|
||||||
|
for field, val in scoring_data.items():
|
||||||
|
if hasattr(sc, field) and val is not None:
|
||||||
|
setattr(sc, field, val)
|
||||||
|
else:
|
||||||
|
db.add(ScoringConfig(**scoring_data))
|
||||||
|
|
||||||
|
# ── 5. Custom templates ──────────────────────────────────────────
|
||||||
|
for item in bundle.get("custom_templates", []):
|
||||||
|
name = item.get("name")
|
||||||
|
mitre_id = item.get("mitre_technique_id")
|
||||||
|
if not name or not mitre_id:
|
||||||
|
continue
|
||||||
|
existing = (
|
||||||
|
db.query(TestTemplate)
|
||||||
|
.filter(TestTemplate.name == name, TestTemplate.source == "custom")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
for field, val in item.items():
|
||||||
|
if hasattr(existing, field):
|
||||||
|
setattr(existing, field, val)
|
||||||
|
else:
|
||||||
|
db.add(TestTemplate(**{k: v for k, v in item.items()
|
||||||
|
if k not in ("id", "created_at")}))
|
||||||
|
summary["custom_templates"] += 1
|
||||||
|
|
||||||
|
# ── 6. Users ─────────────────────────────────────────────────────
|
||||||
|
import secrets as _secrets
|
||||||
|
for item in bundle.get("users", []):
|
||||||
|
username = item.get("username")
|
||||||
|
if not username:
|
||||||
|
continue
|
||||||
|
existing = db.query(User).filter(User.username == username).first()
|
||||||
|
if existing:
|
||||||
|
existing.role = item.get("role", existing.role)
|
||||||
|
existing.is_active = item.get("is_active", existing.is_active)
|
||||||
|
summary["users_updated"] += 1
|
||||||
|
else:
|
||||||
|
# Create with random temp password — user must reset on login
|
||||||
|
temp_pw = _secrets.token_urlsafe(16) + "Aa1!"
|
||||||
|
new_user = User(
|
||||||
|
username=username,
|
||||||
|
hashed_password=hash_password(temp_pw),
|
||||||
|
role=item.get("role", "viewer"),
|
||||||
|
is_active=item.get("is_active", True),
|
||||||
|
must_change_password=True,
|
||||||
|
)
|
||||||
|
if item.get("email") and hasattr(User, "email"):
|
||||||
|
new_user.email = item["email"]
|
||||||
|
db.add(new_user)
|
||||||
|
summary["users_created"] += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"imported_from_version": version,
|
||||||
|
"summary": summary,
|
||||||
|
"warnings": [
|
||||||
|
"REDACTED values were skipped — re-enter passwords/tokens manually.",
|
||||||
|
"All imported users have must_change_password=True.",
|
||||||
|
"SSO private key was not restored — re-upload it manually.",
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -18,7 +18,11 @@ import {
|
|||||||
ToggleRight,
|
ToggleRight,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
X,
|
X,
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
|
PackageOpen,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import client from "../api/client";
|
||||||
import {
|
import {
|
||||||
triggerMitreSync,
|
triggerMitreSync,
|
||||||
triggerIntelScan,
|
triggerIntelScan,
|
||||||
@@ -688,6 +692,9 @@ export default function SystemPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Export / Import Configuration */}
|
||||||
|
<ExportImportSection />
|
||||||
|
|
||||||
{/* Version Info */}
|
{/* Version Info */}
|
||||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||||||
<h2 className="mb-4 text-lg font-semibold text-white">Version Information</h2>
|
<h2 className="mb-4 text-lg font-semibold text-white">Version Information</h2>
|
||||||
@@ -732,6 +739,143 @@ export default function SystemPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Export / Import Configuration ───────────────────────────────── */
|
||||||
|
|
||||||
|
function ExportImportSection() {
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [importResult, setImportResult] = useState<{
|
||||||
|
status: string;
|
||||||
|
summary: Record<string, number>;
|
||||||
|
warnings: string[];
|
||||||
|
} | null>(null);
|
||||||
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await client.get("/admin/export-config", {
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(resp.data);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
const date = new Date().toISOString().slice(0, 10);
|
||||||
|
a.download = `aegis-config-${date}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
alert("Export failed: " + (e as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
e.target.value = "";
|
||||||
|
setImportError(null);
|
||||||
|
setImportResult(null);
|
||||||
|
setImporting(true);
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
const resp = await client.post("/admin/import-config", json);
|
||||||
|
setImportResult(resp.data);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg =
|
||||||
|
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ??
|
||||||
|
(err as Error)?.message ??
|
||||||
|
"Import failed";
|
||||||
|
setImportError(String(msg));
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<PackageOpen className="h-5 w-5 text-cyan-400" />
|
||||||
|
<h2 className="text-lg font-semibold text-white">Configuration Export / Import</h2>
|
||||||
|
</div>
|
||||||
|
<p className="mb-5 text-sm text-gray-400">
|
||||||
|
Export all platform configuration to a JSON file for backup or migration.
|
||||||
|
Import restores settings, webhooks, custom templates and users on a fresh instance.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* What is exported */}
|
||||||
|
<div className="mb-5 rounded-lg border border-gray-800 bg-gray-800/30 p-4">
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500">What is included</p>
|
||||||
|
<div className="grid gap-x-8 gap-y-1 text-xs text-gray-400 sm:grid-cols-2">
|
||||||
|
{[
|
||||||
|
["✅", "Email & Jira settings"],
|
||||||
|
["✅", "SSO / SAML configuration"],
|
||||||
|
["✅", "Webhooks"],
|
||||||
|
["✅", "Scoring weights"],
|
||||||
|
["✅", "Custom test templates"],
|
||||||
|
["✅", "Users (roles & status)"],
|
||||||
|
["🔒", "Passwords / API tokens → REDACTED"],
|
||||||
|
["❌", "Tests, campaigns, techniques data"],
|
||||||
|
].map(([icon, text]) => (
|
||||||
|
<div key={text} className="flex items-center gap-1.5">
|
||||||
|
<span>{icon}</span>
|
||||||
|
<span>{text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Export Configuration
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input ref={fileRef} type="file" accept=".json" onChange={handleImportFile} className="hidden" />
|
||||||
|
<button
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={importing}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{importing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||||
|
{importing ? "Importing…" : "Import Configuration"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Import result */}
|
||||||
|
{importResult && (
|
||||||
|
<div className="mt-4 rounded-lg border border-green-500/30 bg-green-500/5 p-4 space-y-2">
|
||||||
|
<p className="text-sm font-medium text-green-400 flex items-center gap-2">
|
||||||
|
<CheckCircle className="h-4 w-4" /> Import completed successfully
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-2 text-xs text-gray-400 sm:grid-cols-3">
|
||||||
|
{Object.entries(importResult.summary).map(([key, val]) => (
|
||||||
|
<div key={key} className="flex items-center justify-between rounded bg-gray-800 px-2 py-1">
|
||||||
|
<span className="capitalize">{key.replace(/_/g, " ")}</span>
|
||||||
|
<span className="font-mono text-cyan-400">{val}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{importResult.warnings.length > 0 && (
|
||||||
|
<ul className="mt-2 space-y-0.5 text-[11px] text-amber-400/80 list-disc list-inside">
|
||||||
|
{importResult.warnings.map((w) => <li key={w}>{w}</li>)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{importError && (
|
||||||
|
<div className="mt-4 rounded-lg border border-red-500/30 bg-red-900/20 p-3 text-sm text-red-400">
|
||||||
|
⚠ {importError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Create Template Form (inline modal) ──────────────────────────── */
|
/* ── Create Template Form (inline modal) ──────────────────────────── */
|
||||||
|
|
||||||
function CreateTemplateForm({
|
function CreateTemplateForm({
|
||||||
|
|||||||
Reference in New Issue
Block a user