From e5a11d3549c250e31c3c44584b613575c174da0d Mon Sep 17 00:00:00 2001 From: claude-dev Date: Wed, 4 Mar 2026 17:53:19 +0100 Subject: [PATCH] Initial commit: AegisSight-Monitor-Verwaltung --- .gitignore | 5 + requirements.txt | 7 + src/auth.py | 61 ++++ src/config.py | 32 ++ src/database.py | 22 ++ src/email_utils/__init__.py | 0 src/email_utils/sender.py | 53 +++ src/email_utils/templates.py | 36 ++ src/main.py | 81 +++++ src/models.py | 71 ++++ src/routers/__init__.py | 0 src/routers/dashboard.py | 70 ++++ src/routers/licenses.py | 129 ++++++++ src/routers/organizations.py | 116 +++++++ src/routers/users.py | 163 +++++++++ src/static/css/style.css | 624 +++++++++++++++++++++++++++++++++++ src/static/dashboard.html | 313 ++++++++++++++++++ src/static/index.html | 77 +++++ src/static/js/app.js | 561 +++++++++++++++++++++++++++++++ 19 files changed, 2421 insertions(+) create mode 100644 .gitignore create mode 100644 requirements.txt create mode 100644 src/auth.py create mode 100644 src/config.py create mode 100644 src/database.py create mode 100644 src/email_utils/__init__.py create mode 100644 src/email_utils/sender.py create mode 100644 src/email_utils/templates.py create mode 100644 src/main.py create mode 100644 src/models.py create mode 100644 src/routers/__init__.py create mode 100644 src/routers/dashboard.py create mode 100644 src/routers/licenses.py create mode 100644 src/routers/organizations.py create mode 100644 src/routers/users.py create mode 100644 src/static/css/style.css create mode 100644 src/static/dashboard.html create mode 100644 src/static/index.html create mode 100644 src/static/js/app.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3c1b57 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +.env +logs/ +.venv/ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5dd6745 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +python-jose[cryptography] +passlib[bcrypt] +aiosqlite +python-multipart +aiosmtplib diff --git a/src/auth.py b/src/auth.py new file mode 100644 index 0000000..cdf720c --- /dev/null +++ b/src/auth.py @@ -0,0 +1,61 @@ +"""Passwort-basierte Authentifizierung fuer das Verwaltungsportal.""" +from datetime import datetime, timedelta, timezone +from jose import jwt, JWTError +import bcrypt as _bcrypt +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from config import JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS + +security = HTTPBearer() + +JWT_ISSUER = "intelsight-portal" +JWT_AUDIENCE = "intelsight-portal" + + +def hash_password(password: str) -> str: + return _bcrypt.hashpw(password.encode("utf-8"), _bcrypt.gensalt()).decode("utf-8") + + +def verify_password(password: str, password_hash: str) -> bool: + return _bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8")) + + +def create_token(admin_id: int, username: str) -> str: + now = datetime.now(timezone.utc) + expire = now + timedelta(hours=JWT_EXPIRE_HOURS) + payload = { + "sub": str(admin_id), + "username": username, + "role": "portal_admin", + "iss": JWT_ISSUER, + "aud": JWT_AUDIENCE, + "iat": now, + "exp": expire, + } + return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + + +def decode_token(token: str) -> dict: + try: + return jwt.decode( + token, + JWT_SECRET, + algorithms=[JWT_ALGORITHM], + issuer=JWT_ISSUER, + audience=JWT_AUDIENCE, + ) + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token ungueltig oder abgelaufen", + ) + + +async def get_current_admin( + credentials: HTTPAuthorizationCredentials = Depends(security), +) -> dict: + payload = decode_token(credentials.credentials) + return { + "id": int(payload["sub"]), + "username": payload["username"], + } diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..baa3ca7 --- /dev/null +++ b/src/config.py @@ -0,0 +1,32 @@ +"""Konfiguration fuer das Verwaltungsportal.""" +import os + +# Pfade +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static") + +# Gemeinsame Datenbank (gleiche wie OSINT-Monitor) +DB_PATH = os.environ.get("DB_PATH", "/mnt/gitea/osint-data/osint.db") + +# JWT (eigener Secret fuer Verwaltungsportal) +JWT_SECRET = os.environ.get("PORTAL_JWT_SECRET") +if not JWT_SECRET: + raise RuntimeError("PORTAL_JWT_SECRET Umgebungsvariable muss gesetzt sein") +JWT_ALGORITHM = "HS256" +JWT_EXPIRE_HOURS = 8 + +# Server +PORT = int(os.environ.get("PORTAL_PORT", "8892")) + +# SMTP (gleiche wie OSINT-Monitor) +SMTP_HOST = os.environ.get("SMTP_HOST", "") +SMTP_PORT = int(os.environ.get("SMTP_PORT", "587")) +SMTP_USER = os.environ.get("SMTP_USER", "") +SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "") +SMTP_FROM_EMAIL = os.environ.get("SMTP_FROM_EMAIL", "noreply@intelsight.de") +SMTP_FROM_NAME = os.environ.get("SMTP_FROM_NAME", "IntelSight Verwaltung") +SMTP_USE_TLS = os.environ.get("SMTP_USE_TLS", "true").lower() == "true" + +# Magic Link Base URL (fuer OSINT-Monitor Einladungen) +MAGIC_LINK_BASE_URL = os.environ.get("MAGIC_LINK_BASE_URL", "https://osint.intelsight.de") +MAGIC_LINK_EXPIRE_MINUTES = 10 diff --git a/src/database.py b/src/database.py new file mode 100644 index 0000000..b9e8cf2 --- /dev/null +++ b/src/database.py @@ -0,0 +1,22 @@ +"""Datenbankverbindung (geteilte DB mit OSINT-Monitor).""" +import aiosqlite +import os +from config import DB_PATH + + +async def get_db() -> aiosqlite.Connection: + """Erstellt eine neue Datenbankverbindung.""" + db = await aiosqlite.connect(DB_PATH) + db.row_factory = aiosqlite.Row + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA foreign_keys=ON") + return db + + +async def db_dependency(): + """FastAPI Dependency fuer Datenbankverbindungen.""" + db = await get_db() + try: + yield db + finally: + await db.close() diff --git a/src/email_utils/__init__.py b/src/email_utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/email_utils/sender.py b/src/email_utils/sender.py new file mode 100644 index 0000000..57a777b --- /dev/null +++ b/src/email_utils/sender.py @@ -0,0 +1,53 @@ +"""Async E-Mail-Versand via SMTP.""" +import logging +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + +import aiosmtplib + +from config import ( + SMTP_HOST, + SMTP_PORT, + SMTP_USER, + SMTP_PASSWORD, + SMTP_FROM_EMAIL, + SMTP_FROM_NAME, + SMTP_USE_TLS, +) + +logger = logging.getLogger("verwaltung.email") + + +async def send_email(to_email: str, subject: str, html_body: str) -> bool: + """Sendet eine HTML-E-Mail. + + Returns: + True bei Erfolg, False bei Fehler. + """ + if not SMTP_HOST: + logger.warning(f"SMTP nicht konfiguriert - E-Mail an {to_email} nicht gesendet: {subject}") + return False + + msg = MIMEMultipart("alternative") + msg["From"] = f"{SMTP_FROM_NAME} <{SMTP_FROM_EMAIL}>" + msg["To"] = to_email + msg["Subject"] = subject + + text_content = f"Betreff: {subject}\n\nBitte oeffnen Sie diese E-Mail in einem HTML-faehigen E-Mail-Client." + msg.attach(MIMEText(text_content, "plain", "utf-8")) + msg.attach(MIMEText(html_body, "html", "utf-8")) + + try: + await aiosmtplib.send( + msg, + hostname=SMTP_HOST, + port=SMTP_PORT, + username=SMTP_USER if SMTP_USER else None, + password=SMTP_PASSWORD if SMTP_PASSWORD else None, + start_tls=SMTP_USE_TLS, + ) + logger.info(f"E-Mail gesendet an {to_email}: {subject}") + return True + except Exception as e: + logger.error(f"E-Mail-Versand fehlgeschlagen an {to_email}: {e}") + return False diff --git a/src/email_utils/templates.py b/src/email_utils/templates.py new file mode 100644 index 0000000..8a21b13 --- /dev/null +++ b/src/email_utils/templates.py @@ -0,0 +1,36 @@ +"""HTML-E-Mail-Vorlagen fuer das Verwaltungsportal.""" + + +def invite_email(username: str, org_name: str, code: str, link: str) -> tuple[str, str]: + """Erzeugt Einladungs-E-Mail fuer neue OSINT-Monitor-Nutzer. + + Returns: + (subject, html_body) + """ + subject = f"Einladung zum IntelSight OSINT Monitor - {org_name}" + html = f""" + + + +
+

IntelSight OSINT Monitor

+ +

Hallo {username},

+ +

Sie wurden zur Organisation {org_name} im IntelSight OSINT Monitor eingeladen.

+ +

Klicken Sie auf den Link, um Ihren Zugang zu aktivieren:

+ +
+
{code}
+
+ +
+ Einladung annehmen +
+ +

Dieser Link ist 10 Minuten gueltig.

+
+ +""" + return subject, html diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..4d69710 --- /dev/null +++ b/src/main.py @@ -0,0 +1,81 @@ +"""Verwaltungsportal - FastAPI Anwendung.""" +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Depends, HTTPException, status +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse + +from config import STATIC_DIR, PORT +from database import db_dependency +from auth import verify_password, create_token +from models import LoginRequest, TokenResponse +from routers import organizations, licenses, users, dashboard + +import aiosqlite + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s [%(name)s] %(message)s", +) +logger = logging.getLogger("verwaltung") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("Verwaltungsportal gestartet auf Port %s", PORT) + yield + logger.info("Verwaltungsportal beendet") + + +app = FastAPI( + title="IntelSight Verwaltungsportal", + version="1.0.0", + lifespan=lifespan, +) + +# --- Routen --- +app.include_router(organizations.router) +app.include_router(licenses.router) +app.include_router(users.router) +app.include_router(dashboard.router) + + +# --- Login --- +@app.post("/api/auth/login", response_model=TokenResponse) +async def login( + data: LoginRequest, + db: aiosqlite.Connection = Depends(db_dependency), +): + cursor = await db.execute( + "SELECT id, username, password_hash FROM portal_admins WHERE username = ?", + (data.username,), + ) + admin = await cursor.fetchone() + if not admin or not verify_password(data.password, admin["password_hash"]): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Ungueltige Zugangsdaten", + ) + + token = create_token(admin["id"], admin["username"]) + return TokenResponse(access_token=token, username=admin["username"]) + + +# --- Statische Dateien --- +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + +@app.get("/") +async def index(): + return FileResponse(f"{STATIC_DIR}/index.html") + + +@app.get("/dashboard") +async def dashboard_page(): + return FileResponse(f"{STATIC_DIR}/dashboard.html") + + +if __name__ == "__main__": + import uvicorn + uvicorn.run("main:app", host="0.0.0.0", port=PORT, reload=True) diff --git a/src/models.py b/src/models.py new file mode 100644 index 0000000..58b731d --- /dev/null +++ b/src/models.py @@ -0,0 +1,71 @@ +"""Pydantic Models fuer das Verwaltungsportal.""" +from pydantic import BaseModel, Field +from typing import Optional + + +class LoginRequest(BaseModel): + username: str + password: str + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + username: str + + +class OrgCreate(BaseModel): + name: str = Field(min_length=1, max_length=200) + slug: str = Field(min_length=1, max_length=100, pattern="^[a-z0-9-]+$") + + +class OrgUpdate(BaseModel): + name: Optional[str] = Field(default=None, max_length=200) + is_active: Optional[bool] = None + + +class OrgResponse(BaseModel): + id: int + name: str + slug: str + is_active: bool + user_count: int = 0 + license_status: str = "" + license_type: str = "" + created_at: str + + +class LicenseCreate(BaseModel): + organization_id: int + license_type: str = Field(pattern="^(trial|annual|permanent)$") + max_users: int = Field(default=5, ge=1, le=1000) + duration_days: Optional[int] = Field(default=None, ge=1, le=3650) + + +class LicenseResponse(BaseModel): + id: int + organization_id: int + license_type: str + max_users: int + valid_from: str + valid_until: Optional[str] + status: str + notes: Optional[str] + created_at: str + + +class UserCreate(BaseModel): + email: str = Field(min_length=3, max_length=200) + username: str = Field(min_length=1, max_length=100) + role: str = Field(default="member", pattern="^(org_admin|member)$") + + +class UserResponse(BaseModel): + id: int + email: str + username: str + organization_id: int + role: str + is_active: bool + last_login_at: Optional[str] + created_at: str diff --git a/src/routers/__init__.py b/src/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/routers/dashboard.py b/src/routers/dashboard.py new file mode 100644 index 0000000..ff7b680 --- /dev/null +++ b/src/routers/dashboard.py @@ -0,0 +1,70 @@ +"""Dashboard-Statistiken.""" +from fastapi import APIRouter, Depends +from auth import get_current_admin +from database import db_dependency +import aiosqlite + +router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) + + +@router.get("/stats") +async def get_stats( + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Gesamtstatistiken fuer das Dashboard.""" + cursor = await db.execute("SELECT COUNT(*) as cnt FROM organizations") + org_count = (await cursor.fetchone())["cnt"] + + cursor = await db.execute("SELECT COUNT(*) as cnt FROM organizations WHERE is_active = 1") + active_orgs = (await cursor.fetchone())["cnt"] + + cursor = await db.execute("SELECT COUNT(*) as cnt FROM users WHERE is_active = 1") + active_users = (await cursor.fetchone())["cnt"] + + cursor = await db.execute("SELECT COUNT(*) as cnt FROM users") + total_users = (await cursor.fetchone())["cnt"] + + cursor = await db.execute("SELECT COUNT(*) as cnt FROM licenses WHERE status = 'active'") + active_licenses = (await cursor.fetchone())["cnt"] + + cursor = await db.execute("SELECT COUNT(*) as cnt FROM incidents") + total_incidents = (await cursor.fetchone())["cnt"] + + cursor = await db.execute("SELECT COUNT(*) as cnt FROM incidents WHERE status = 'active'") + active_incidents = (await cursor.fetchone())["cnt"] + + # Bald ablaufende Lizenzen (30 Tage) + cursor = await db.execute( + """SELECT l.id, l.organization_id, l.license_type, l.valid_until, l.max_users, + o.name as org_name + FROM licenses l + JOIN organizations o ON o.id = l.organization_id + WHERE l.status = 'active' + AND l.valid_until IS NOT NULL + AND l.valid_until < datetime('now', '+30 days') + ORDER BY l.valid_until""" + ) + expiring = [dict(row) for row in await cursor.fetchall()] + + # Letzte Aktivitaeten (neue Orgs, neue Nutzer) + cursor = await db.execute( + "SELECT 'org' as type, name as label, created_at FROM organizations ORDER BY created_at DESC LIMIT 5" + ) + recent_orgs = [dict(row) for row in await cursor.fetchall()] + + cursor = await db.execute( + """SELECT 'user' as type, u.username || ' (' || o.name || ')' as label, u.created_at + FROM users u JOIN organizations o ON o.id = u.organization_id + ORDER BY u.created_at DESC LIMIT 5""" + ) + recent_users = [dict(row) for row in await cursor.fetchall()] + + return { + "organizations": {"total": org_count, "active": active_orgs}, + "users": {"total": total_users, "active": active_users}, + "licenses": {"active": active_licenses}, + "incidents": {"total": total_incidents, "active": active_incidents}, + "expiring_licenses": expiring, + "recent_activity": sorted(recent_orgs + recent_users, key=lambda x: x["created_at"], reverse=True)[:10], + } diff --git a/src/routers/licenses.py b/src/routers/licenses.py new file mode 100644 index 0000000..04b9404 --- /dev/null +++ b/src/routers/licenses.py @@ -0,0 +1,129 @@ +"""Lizenz-CRUD.""" +from datetime import datetime, timedelta, timezone +from fastapi import APIRouter, Depends, HTTPException, status +from models import LicenseCreate, LicenseResponse +from auth import get_current_admin +from database import db_dependency +import aiosqlite + +router = APIRouter(prefix="/api/licenses", tags=["licenses"]) + + +@router.get("", response_model=list[LicenseResponse]) +async def list_licenses( + org_id: int = None, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + if org_id: + cursor = await db.execute( + "SELECT * FROM licenses WHERE organization_id = ? ORDER BY created_at DESC", + (org_id,), + ) + else: + cursor = await db.execute("SELECT * FROM licenses ORDER BY created_at DESC") + return [dict(row) for row in await cursor.fetchall()] + + +@router.post("", response_model=LicenseResponse, status_code=status.HTTP_201_CREATED) +async def create_license( + data: LicenseCreate, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + # Org pruefen + cursor = await db.execute( + "SELECT id FROM organizations WHERE id = ?", (data.organization_id,) + ) + if not await cursor.fetchone(): + raise HTTPException(status_code=404, detail="Organisation nicht gefunden") + + # Bestehende aktive Lizenz widerrufen + await db.execute( + "UPDATE licenses SET status = 'revoked' WHERE organization_id = ? AND status = 'active'", + (data.organization_id,), + ) + + now = datetime.now(timezone.utc) + valid_from = now.isoformat() + valid_until = None + + if data.license_type == "permanent": + valid_until = None + elif data.duration_days: + valid_until = (now + timedelta(days=data.duration_days)).isoformat() + elif data.license_type == "trial": + valid_until = (now + timedelta(days=14)).isoformat() + elif data.license_type == "annual": + valid_until = (now + timedelta(days=365)).isoformat() + + cursor = await db.execute( + """INSERT INTO licenses (organization_id, license_type, max_users, valid_from, valid_until, status) + VALUES (?, ?, ?, ?, ?, 'active')""", + (data.organization_id, data.license_type, data.max_users, valid_from, valid_until), + ) + await db.commit() + + cursor = await db.execute("SELECT * FROM licenses WHERE id = ?", (cursor.lastrowid,)) + return dict(await cursor.fetchone()) + + +@router.put("/{license_id}/revoke") +async def revoke_license( + license_id: int, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + cursor = await db.execute("SELECT * FROM licenses WHERE id = ?", (license_id,)) + lic = await cursor.fetchone() + if not lic: + raise HTTPException(status_code=404, detail="Lizenz nicht gefunden") + + await db.execute("UPDATE licenses SET status = 'revoked' WHERE id = ?", (license_id,)) + await db.commit() + return {"ok": True} + + +@router.put("/{license_id}/extend") +async def extend_license( + license_id: int, + days: int = 365, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + cursor = await db.execute("SELECT * FROM licenses WHERE id = ?", (license_id,)) + lic = await cursor.fetchone() + if not lic: + raise HTTPException(status_code=404, detail="Lizenz nicht gefunden") + + if lic["valid_until"]: + base = datetime.fromisoformat(lic["valid_until"]) + else: + base = datetime.now(timezone.utc) + + new_until = (base + timedelta(days=days)).isoformat() + await db.execute( + "UPDATE licenses SET valid_until = ?, status = 'active' WHERE id = ?", + (new_until, license_id), + ) + await db.commit() + return {"ok": True, "valid_until": new_until} + + +@router.get("/expiring") +async def get_expiring_licenses( + days: int = 30, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Lizenzen die in den naechsten X Tagen ablaufen.""" + cursor = await db.execute( + """SELECT l.*, o.name as org_name FROM licenses l + JOIN organizations o ON o.id = l.organization_id + WHERE l.status = 'active' + AND l.valid_until IS NOT NULL + AND l.valid_until < datetime('now', '+' || ? || ' days') + ORDER BY l.valid_until""", + (days,), + ) + return [dict(row) for row in await cursor.fetchall()] diff --git a/src/routers/organizations.py b/src/routers/organizations.py new file mode 100644 index 0000000..58142a7 --- /dev/null +++ b/src/routers/organizations.py @@ -0,0 +1,116 @@ +"""Organisations-CRUD.""" +from datetime import datetime, timezone +from fastapi import APIRouter, Depends, HTTPException, status +from models import OrgCreate, OrgUpdate, OrgResponse +from auth import get_current_admin +from database import db_dependency +import aiosqlite + +router = APIRouter(prefix="/api/orgs", tags=["organizations"]) + + +async def _enrich_org(db: aiosqlite.Connection, row: aiosqlite.Row) -> dict: + org = dict(row) + cursor = await db.execute( + "SELECT COUNT(*) as cnt FROM users WHERE organization_id = ? AND is_active = 1", + (org["id"],), + ) + org["user_count"] = (await cursor.fetchone())["cnt"] + + cursor = await db.execute( + "SELECT license_type, status FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1", + (org["id"],), + ) + lic = await cursor.fetchone() + org["license_status"] = lic["status"] if lic else "none" + org["license_type"] = lic["license_type"] if lic else "" + return org + + +@router.get("", response_model=list[OrgResponse]) +async def list_organizations( + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + cursor = await db.execute("SELECT * FROM organizations ORDER BY created_at DESC") + rows = await cursor.fetchall() + return [await _enrich_org(db, row) for row in rows] + + +@router.post("", response_model=OrgResponse, status_code=status.HTTP_201_CREATED) +async def create_organization( + data: OrgCreate, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + # Slug-Duplikat pruefen + cursor = await db.execute("SELECT id FROM organizations WHERE slug = ?", (data.slug,)) + if await cursor.fetchone(): + raise HTTPException(status_code=400, detail="Slug bereits vergeben") + + now = datetime.now(timezone.utc).isoformat() + cursor = await db.execute( + "INSERT INTO organizations (name, slug, is_active, created_at, updated_at) VALUES (?, ?, 1, ?, ?)", + (data.name, data.slug, now, now), + ) + await db.commit() + + cursor = await db.execute("SELECT * FROM organizations WHERE id = ?", (cursor.lastrowid,)) + return await _enrich_org(db, await cursor.fetchone()) + + +@router.get("/{org_id}", response_model=OrgResponse) +async def get_organization( + org_id: int, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + cursor = await db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,)) + row = await cursor.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Organisation nicht gefunden") + return await _enrich_org(db, row) + + +@router.put("/{org_id}", response_model=OrgResponse) +async def update_organization( + org_id: int, + data: OrgUpdate, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + cursor = await db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,)) + row = await cursor.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Organisation nicht gefunden") + + updates = {} + if data.name is not None: + updates["name"] = data.name + if data.is_active is not None: + updates["is_active"] = 1 if data.is_active else 0 + + if updates: + updates["updated_at"] = datetime.now(timezone.utc).isoformat() + set_clause = ", ".join(f"{k} = ?" for k in updates) + values = list(updates.values()) + [org_id] + await db.execute(f"UPDATE organizations SET {set_clause} WHERE id = ?", values) + await db.commit() + + cursor = await db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,)) + return await _enrich_org(db, await cursor.fetchone()) + + +@router.delete("/{org_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_organization( + org_id: int, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + cursor = await db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,)) + if not await cursor.fetchone(): + raise HTTPException(status_code=404, detail="Organisation nicht gefunden") + + # Kaskadierendes Loeschen + await db.execute("DELETE FROM organizations WHERE id = ?", (org_id,)) + await db.commit() diff --git a/src/routers/users.py b/src/routers/users.py new file mode 100644 index 0000000..b97b372 --- /dev/null +++ b/src/routers/users.py @@ -0,0 +1,163 @@ +"""Nutzer-Verwaltung pro Organisation.""" +import secrets +import string +from datetime import datetime, timedelta, timezone +from fastapi import APIRouter, Depends, HTTPException, status +from models import UserCreate, UserResponse +from auth import get_current_admin +from database import db_dependency +from config import MAGIC_LINK_BASE_URL, MAGIC_LINK_EXPIRE_MINUTES +import aiosqlite + +router = APIRouter(prefix="/api/users", tags=["users"]) + + +@router.get("", response_model=list[UserResponse]) +async def list_users( + org_id: int = None, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + if org_id: + cursor = await db.execute( + "SELECT * FROM users WHERE organization_id = ? ORDER BY created_at", + (org_id,), + ) + else: + cursor = await db.execute("SELECT * FROM users ORDER BY organization_id, created_at") + return [dict(row) for row in await cursor.fetchall()] + + +@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +async def create_user( + data: UserCreate, + org_id: int = None, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + if not org_id: + raise HTTPException(status_code=400, detail="org_id Parameter erforderlich") + + email = data.email.lower().strip() + + # Org pruefen + cursor = await db.execute("SELECT id, name FROM organizations WHERE id = ?", (org_id,)) + org = await cursor.fetchone() + if not org: + raise HTTPException(status_code=404, detail="Organisation nicht gefunden") + + # Nutzer-Limit pruefen + cursor = await db.execute( + "SELECT max_users FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1", + (org_id,), + ) + lic = await cursor.fetchone() + if lic: + cursor = await db.execute( + "SELECT COUNT(*) as cnt FROM users WHERE organization_id = ? AND is_active = 1", + (org_id,), + ) + current = (await cursor.fetchone())["cnt"] + if current >= lic["max_users"]: + raise HTTPException(status_code=400, detail=f"Nutzer-Limit erreicht ({current}/{lic['max_users']})") + + # E-Mail-Duplikat + cursor = await db.execute("SELECT id FROM users WHERE LOWER(email) = ?", (email,)) + if await cursor.fetchone(): + raise HTTPException(status_code=400, detail="E-Mail bereits vergeben") + + now = datetime.now(timezone.utc).isoformat() + cursor = await db.execute( + """INSERT INTO users (email, username, password_hash, organization_id, role, is_active, created_at) + VALUES (?, ?, '', ?, ?, 1, ?)""", + (email, data.username, org_id, data.role, now), + ) + user_id = cursor.lastrowid + + # Magic Link fuer Einladung erstellen + token = secrets.token_urlsafe(48) + code = ''.join(secrets.choice(string.digits) for _ in range(6)) + expires_at = (datetime.now(timezone.utc) + timedelta(hours=48)).isoformat() + + await db.execute( + """INSERT INTO magic_links (email, token, code, purpose, user_id, expires_at) + VALUES (?, ?, ?, 'invite', ?, ?)""", + (email, token, code, user_id, expires_at), + ) + await db.commit() + + # Einladungs-E-Mail senden + try: + from email_utils.sender import send_email + from email_utils.templates import invite_email + link = f"{MAGIC_LINK_BASE_URL}/auth/verify?token={token}" + subject, html = invite_email(data.username, org["name"], code, link) + await send_email(email, subject, html) + except Exception: + pass # E-Mail-Fehler nicht fatal + + cursor = await db.execute("SELECT * FROM users WHERE id = ?", (user_id,)) + return dict(await cursor.fetchone()) + + +@router.put("/{user_id}/deactivate") +async def deactivate_user( + user_id: int, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + cursor = await db.execute("SELECT id FROM users WHERE id = ?", (user_id,)) + if not await cursor.fetchone(): + raise HTTPException(status_code=404, detail="Nutzer nicht gefunden") + + await db.execute("UPDATE users SET is_active = 0 WHERE id = ?", (user_id,)) + await db.commit() + return {"ok": True} + + +@router.put("/{user_id}/activate") +async def activate_user( + user_id: int, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + cursor = await db.execute("SELECT id FROM users WHERE id = ?", (user_id,)) + if not await cursor.fetchone(): + raise HTTPException(status_code=404, detail="Nutzer nicht gefunden") + + await db.execute("UPDATE users SET is_active = 1 WHERE id = ?", (user_id,)) + await db.commit() + return {"ok": True} + + +@router.put("/{user_id}/role") +async def change_role( + user_id: int, + role: str = "member", + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + if role not in ("org_admin", "member"): + raise HTTPException(status_code=400, detail="Ungueltige Rolle") + + cursor = await db.execute("SELECT id FROM users WHERE id = ?", (user_id,)) + if not await cursor.fetchone(): + raise HTTPException(status_code=404, detail="Nutzer nicht gefunden") + + await db.execute("UPDATE users SET role = ? WHERE id = ?", (role, user_id)) + await db.commit() + return {"ok": True} + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user( + user_id: int, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + cursor = await db.execute("SELECT id FROM users WHERE id = ?", (user_id,)) + if not await cursor.fetchone(): + raise HTTPException(status_code=404, detail="Nutzer nicht gefunden") + + await db.execute("DELETE FROM users WHERE id = ?", (user_id,)) + await db.commit() diff --git a/src/static/css/style.css b/src/static/css/style.css new file mode 100644 index 0000000..a29339b --- /dev/null +++ b/src/static/css/style.css @@ -0,0 +1,624 @@ +/* AegisSight Dark Theme - Verwaltungsportal */ +:root { + --bg-primary: #0f172a; + --bg-secondary: #1e293b; + --bg-tertiary: #334155; + --text-primary: #e2e8f0; + --text-secondary: #94a3b8; + --text-muted: #64748b; + --accent: #f0b429; + --accent-hover: #d4a017; + --success: #22c55e; + --warning: #f59e0b; + --danger: #ef4444; + --info: #3b82f6; + --border: #334155; + --radius: 8px; + --radius-lg: 12px; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + min-height: 100vh; +} + +/* --- Login Page --- */ +.login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 20px; +} + +.login-container { + width: 100%; + max-width: 400px; +} + +.login-card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 40px 32px; +} + +.login-header { + text-align: center; + margin-bottom: 32px; +} + +.login-header h1 { + color: var(--accent); + font-size: 28px; + font-weight: 700; + margin-bottom: 4px; +} + +.login-header .subtitle { + color: var(--text-secondary); + font-size: 14px; +} + +/* --- Forms --- */ +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 6px; +} + +input[type="text"], +input[type="password"], +input[type="email"], +input[type="number"], +select, +textarea { + width: 100%; + padding: 10px 12px; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-primary); + font-size: 14px; + outline: none; + transition: border-color 0.2s; +} + +input:focus, select:focus, textarea:focus { + border-color: var(--accent); +} + +.error-msg { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.3); + color: #fca5a5; + padding: 10px 12px; + border-radius: var(--radius); + font-size: 13px; + margin-bottom: 16px; +} + +/* --- Buttons --- */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 16px; + border: none; + border-radius: var(--radius); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + text-decoration: none; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: var(--accent); + color: var(--bg-primary); +} +.btn-primary:hover:not(:disabled) { + background: var(--accent-hover); +} + +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); +} +.btn-secondary:hover:not(:disabled) { + background: #475569; +} + +.btn-danger { + background: rgba(239, 68, 68, 0.2); + color: #fca5a5; + border: 1px solid rgba(239, 68, 68, 0.3); +} +.btn-danger:hover:not(:disabled) { + background: rgba(239, 68, 68, 0.3); +} + +.btn-success { + background: rgba(34, 197, 94, 0.2); + color: #86efac; + border: 1px solid rgba(34, 197, 94, 0.3); +} +.btn-success:hover:not(:disabled) { + background: rgba(34, 197, 94, 0.3); +} + +.btn-small { + padding: 4px 10px; + font-size: 12px; +} + +.btn-full { + width: 100%; + padding: 12px; + font-size: 15px; +} + +/* --- Dashboard Layout --- */ +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 24px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 100; +} + +.app-header .logo { + color: var(--accent); + font-size: 18px; + font-weight: 700; +} + +.app-header .logo span { + color: var(--text-secondary); + font-weight: 400; + font-size: 13px; + margin-left: 8px; +} + +.header-right { + display: flex; + align-items: center; + gap: 16px; +} + +.header-user { + color: var(--text-secondary); + font-size: 13px; +} + +.app-content { + max-width: 1200px; + margin: 0 auto; + padding: 24px; +} + +/* --- Navigation Tabs --- */ +.nav-tabs { + display: flex; + gap: 4px; + margin-bottom: 24px; + border-bottom: 1px solid var(--border); + padding-bottom: 0; +} + +.nav-tab { + padding: 10px 20px; + background: none; + border: none; + color: var(--text-secondary); + font-size: 14px; + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: all 0.2s; +} + +.nav-tab:hover { + color: var(--text-primary); +} + +.nav-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +/* --- Stats Cards --- */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.stat-card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 20px; +} + +.stat-card .stat-label { + font-size: 12px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; +} + +.stat-card .stat-value { + font-size: 28px; + font-weight: 700; + color: var(--text-primary); +} + +.stat-card .stat-sub { + font-size: 12px; + color: var(--text-secondary); + margin-top: 4px; +} + +/* --- Cards --- */ +.card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + margin-bottom: 16px; +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} + +.card-header h2 { + font-size: 16px; + font-weight: 600; +} + +.card-body { + padding: 20px; +} + +/* --- Tables --- */ +.table-wrap { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th { + text-align: left; + padding: 10px 12px; + font-size: 12px; + font-weight: 500; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid var(--border); +} + +td { + padding: 12px; + font-size: 14px; + border-bottom: 1px solid rgba(51, 65, 85, 0.5); + vertical-align: middle; +} + +tr:hover td { + background: rgba(51, 65, 85, 0.2); +} + +/* --- Badges --- */ +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.badge-active { + background: rgba(34, 197, 94, 0.2); + color: #86efac; +} + +.badge-inactive { + background: rgba(239, 68, 68, 0.2); + color: #fca5a5; +} + +.badge-trial { + background: rgba(59, 130, 246, 0.2); + color: #93c5fd; +} + +.badge-annual { + background: rgba(168, 85, 247, 0.2); + color: #d8b4fe; +} + +.badge-permanent { + background: rgba(245, 158, 11, 0.2); + color: #fcd34d; +} + +.badge-expired { + background: rgba(239, 68, 68, 0.2); + color: #fca5a5; +} + +.badge-revoked { + background: rgba(100, 116, 139, 0.2); + color: #94a3b8; +} + +.badge-none { + background: rgba(100, 116, 139, 0.15); + color: #64748b; +} + +/* --- Modal --- */ +.modal-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 1000; + align-items: center; + justify-content: center; + padding: 20px; +} + +.modal-overlay.active { + display: flex; +} + +.modal { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + width: 100%; + max-width: 480px; + max-height: 80vh; + overflow-y: auto; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} + +.modal-header h3 { + font-size: 16px; + font-weight: 600; +} + +.modal-close { + background: none; + border: none; + color: var(--text-secondary); + font-size: 20px; + cursor: pointer; + padding: 4px; +} + +.modal-body { + padding: 20px; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 16px 20px; + border-top: 1px solid var(--border); +} + +/* --- Section views --- */ +.section { + display: none; +} + +.section.active { + display: block; +} + +/* --- Action bar --- */ +.action-bar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + flex-wrap: wrap; + gap: 12px; +} + +.search-input { + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 8px 12px; + color: var(--text-primary); + font-size: 13px; + width: 240px; + outline: none; +} + +.search-input:focus { + border-color: var(--accent); +} + +/* --- Expiring licenses --- */ +.expiring-list { + list-style: none; +} + +.expiring-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 0; + border-bottom: 1px solid rgba(51, 65, 85, 0.5); + font-size: 14px; +} + +.expiring-item:last-child { + border-bottom: none; +} + +.expiring-date { + color: var(--warning); + font-size: 13px; + font-weight: 500; +} + +/* --- Recent activity --- */ +.activity-item { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 0; + font-size: 13px; +} + +.activity-icon { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + flex-shrink: 0; +} + +.activity-icon.org { + background: rgba(59, 130, 246, 0.2); + color: #93c5fd; +} + +.activity-icon.user { + background: rgba(34, 197, 94, 0.2); + color: #86efac; +} + +/* --- Org detail panel --- */ +.detail-panel { + display: none; +} + +.detail-panel.active { + display: block; +} + +.detail-back { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + background: none; + border: none; + margin-bottom: 16px; +} + +.detail-back:hover { + color: var(--text-primary); +} + +/* --- Responsive --- */ +@media (max-width: 768px) { + .app-content { + padding: 16px; + } + + .stats-grid { + grid-template-columns: 1fr 1fr; + } + + .nav-tabs { + overflow-x: auto; + } + + .action-bar { + flex-direction: column; + align-items: stretch; + } + + .search-input { + width: 100%; + } +} + +/* --- Utility --- */ +.text-muted { color: var(--text-muted); } +.text-secondary { color: var(--text-secondary); } +.text-accent { color: var(--accent); } +.text-success { color: var(--success); } +.text-danger { color: var(--danger); } +.text-warning { color: var(--warning); } +.mt-8 { margin-top: 8px; } +.mt-16 { margin-top: 16px; } +.mb-8 { margin-bottom: 8px; } +.mb-16 { margin-bottom: 16px; } +.hidden { display: none !important; } + +/* --- Loading spinner --- */ +.spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* --- Confirm dialog --- */ +.confirm-text { + font-size: 14px; + line-height: 1.6; + margin-bottom: 8px; +} diff --git a/src/static/dashboard.html b/src/static/dashboard.html new file mode 100644 index 0000000..c51eea7 --- /dev/null +++ b/src/static/dashboard.html @@ -0,0 +1,313 @@ + + + + + + IntelSight Verwaltung + + + + +
+ +
+ + +
+
+ +
+ + + + +
+
+ +
+
+
+

Bald ablaufende Lizenzen

+
+
+
    +
  • Laden...
  • +
+
+
+ +
+
+

Letzte Aktivitaet

+
+
+
Laden...
+
+
+
+
+ + +
+ +
+
+ + +
+
+
+ + + + + + + + + + + + +
NameSlugNutzerLizenzStatusAktionen
+
+
+
+ + +
+ +
+ + + + +
+
+ + +
+
+
+ + + + + + + + + + + +
E-MailNameRolleStatusAktionen
+
+
+
+ + +
+
+ + +
+
+
+ + + + + + + + + + + + +
TypMax NutzerGueltig abGueltig bisStatusAktionen
+
+
+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+ + +
+
+
+

Bald ablaufende Lizenzen

+ +
+
+ + + + + + + + + + + +
OrganisationTypMax NutzerLaeuft abAktionen
+
+
+
+
+ + + + + + + + + + + + + + + + diff --git a/src/static/index.html b/src/static/index.html new file mode 100644 index 0000000..a5e4491 --- /dev/null +++ b/src/static/index.html @@ -0,0 +1,77 @@ + + + + + + IntelSight Verwaltung - Login + + + +
+ +
+ + + + diff --git a/src/static/js/app.js b/src/static/js/app.js new file mode 100644 index 0000000..90f7564 --- /dev/null +++ b/src/static/js/app.js @@ -0,0 +1,561 @@ +/* Verwaltungsportal - Frontend Logic */ +"use strict"; + +const API = { + token: localStorage.getItem("token"), + + async request(path, opts = {}) { + const headers = { "Content-Type": "application/json" }; + if (this.token) headers["Authorization"] = `Bearer ${this.token}`; + const res = await fetch(path, { ...opts, headers }); + if (res.status === 401) { + localStorage.removeItem("token"); + localStorage.removeItem("username"); + window.location.href = "/"; + return; + } + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.detail || `Fehler ${res.status}`); + } + if (res.status === 204) return null; + return res.json(); + }, + + get(path) { return this.request(path); }, + post(path, body) { return this.request(path, { method: "POST", body: JSON.stringify(body) }); }, + put(path, body) { return this.request(path, { method: "PUT", body: body ? JSON.stringify(body) : undefined }); }, + del(path) { return this.request(path, { method: "DELETE" }); }, +}; + +// --- State --- +let currentOrgId = null; +let orgsCache = []; + +// --- Init --- +document.addEventListener("DOMContentLoaded", () => { + if (!API.token) { window.location.href = "/"; return; } + + document.getElementById("headerUser").textContent = localStorage.getItem("username") || ""; + document.getElementById("logoutBtn").addEventListener("click", logout); + + setupNavTabs(); + setupOrgDetailTabs(); + setupForms(); + loadDashboard(); + loadOrgs(); +}); + +function logout() { + localStorage.removeItem("token"); + localStorage.removeItem("username"); + window.location.href = "/"; +} + +// --- Navigation --- +function setupNavTabs() { + document.querySelectorAll(".nav-tabs:not(#orgDetailTabs) .nav-tab").forEach(tab => { + tab.addEventListener("click", () => { + const section = tab.dataset.section; + document.querySelectorAll(".nav-tabs:not(#orgDetailTabs) .nav-tab").forEach(t => t.classList.remove("active")); + tab.classList.add("active"); + document.querySelectorAll(".app-content > .section").forEach(s => s.classList.remove("active")); + document.getElementById(`sec-${section}`).classList.add("active"); + + if (section === "licenses") loadExpiringLicenses(); + }); + }); +} + +function setupOrgDetailTabs() { + document.querySelectorAll("#orgDetailTabs .nav-tab").forEach(tab => { + tab.addEventListener("click", () => { + const subtab = tab.dataset.subtab; + document.querySelectorAll("#orgDetailTabs .nav-tab").forEach(t => t.classList.remove("active")); + tab.classList.add("active"); + document.querySelectorAll("#orgDetail > .section").forEach(s => s.classList.remove("active")); + document.getElementById(`sub-${subtab}`).classList.add("active"); + }); + }); + + document.getElementById("orgBackBtn").addEventListener("click", () => { + document.getElementById("orgListView").style.display = ""; + document.getElementById("orgDetail").classList.remove("active"); + currentOrgId = null; + }); +} + +// --- Dashboard --- +async function loadDashboard() { + try { + const stats = await API.get("/api/dashboard/stats"); + + document.getElementById("statsGrid").innerHTML = ` +
+
Organisationen
+
${stats.organizations.total}
+
${stats.organizations.active} aktiv
+
+
+
Nutzer
+
${stats.users.total}
+
${stats.users.active} aktiv
+
+
+
Aktive Lizenzen
+
${stats.licenses.active}
+
+
+
Vorfaelle
+
${stats.incidents.total}
+
${stats.incidents.active} aktiv
+
+ `; + + // Expiring licenses + const expList = document.getElementById("expiringList"); + if (stats.expiring_licenses.length === 0) { + expList.innerHTML = '
  • Keine ablaufenden Lizenzen
  • '; + } else { + expList.innerHTML = stats.expiring_licenses.map(l => ` +
  • + ${esc(l.org_name)} ${l.license_type} + ${formatDate(l.valid_until)} +
  • + `).join(""); + } + + // Recent activity + const actEl = document.getElementById("recentActivity"); + if (stats.recent_activity.length === 0) { + actEl.innerHTML = '
    Keine Aktivitaet
    '; + } else { + actEl.innerHTML = stats.recent_activity.map(a => ` +
    +
    ${a.type === "org" ? "O" : "U"}
    +
    +
    ${esc(a.label)}
    +
    ${formatDate(a.created_at)}
    +
    +
    + `).join(""); + } + } catch (err) { + console.error("Dashboard laden fehlgeschlagen:", err); + } +} + +// --- Organizations --- +async function loadOrgs() { + try { + orgsCache = await API.get("/api/orgs"); + renderOrgTable(orgsCache); + } catch (err) { + console.error("Orgs laden fehlgeschlagen:", err); + } +} + +function renderOrgTable(orgs) { + const tbody = document.getElementById("orgTable"); + if (orgs.length === 0) { + tbody.innerHTML = 'Keine Organisationen'; + return; + } + tbody.innerHTML = orgs.map(o => ` + + ${esc(o.name)} + ${esc(o.slug)} + ${o.user_count} + ${o.license_type || "Keine"} + ${o.is_active ? "Aktiv" : "Inaktiv"} + + + + + `).join(""); +} + +// Search filter +document.addEventListener("DOMContentLoaded", () => { + const searchEl = document.getElementById("orgSearch"); + if (searchEl) { + searchEl.addEventListener("input", () => { + const q = searchEl.value.toLowerCase(); + const filtered = orgsCache.filter(o => + o.name.toLowerCase().includes(q) || o.slug.toLowerCase().includes(q) + ); + renderOrgTable(filtered); + }); + } +}); + +// --- Open Org Detail --- +async function openOrg(orgId) { + currentOrgId = orgId; + document.getElementById("orgListView").style.display = "none"; + document.getElementById("orgDetail").classList.add("active"); + + // Reset to users tab + document.querySelectorAll("#orgDetailTabs .nav-tab").forEach(t => t.classList.remove("active")); + document.querySelector('#orgDetailTabs .nav-tab[data-subtab="users"]').classList.add("active"); + document.querySelectorAll("#orgDetail > .section").forEach(s => s.classList.remove("active")); + document.getElementById("sub-users").classList.add("active"); + + try { + const org = await API.get(`/api/orgs/${orgId}`); + document.getElementById("orgDetailHeader").innerHTML = ` +

    ${esc(org.name)} ${org.is_active ? "Aktiv" : "Inaktiv"}

    +
    Slug: ${esc(org.slug)} | Nutzer: ${org.user_count} | Lizenz: ${org.license_type || "Keine"}
    + `; + + document.getElementById("editOrgName").value = org.name; + document.getElementById("editOrgActive").value = org.is_active ? "true" : "false"; + + loadOrgUsers(orgId); + loadOrgLicenses(orgId); + } catch (err) { + console.error("Org laden fehlgeschlagen:", err); + } +} + +// --- Org Users --- +async function loadOrgUsers(orgId) { + try { + const users = await API.get(`/api/users?org_id=${orgId}`); + const licenses = await API.get(`/api/licenses?org_id=${orgId}`); + const activeLic = licenses.find(l => l.status === "active"); + const activeUsers = users.filter(u => u.is_active).length; + + document.getElementById("userLimitInfo").textContent = activeLic + ? `${activeUsers} / ${activeLic.max_users} Nutzer` + : "Keine aktive Lizenz"; + + const tbody = document.getElementById("userTable"); + if (users.length === 0) { + tbody.innerHTML = 'Keine Nutzer'; + return; + } + tbody.innerHTML = users.map(u => ` + + ${esc(u.email)} + ${esc(u.username)} + + + + ${u.is_active ? "Aktiv" : "Inaktiv"} + + ${u.is_active + ? `` + : `` + } + + + + `).join(""); + } catch (err) { + console.error("Nutzer laden fehlgeschlagen:", err); + } +} + +async function changeRole(userId, role) { + try { + await API.put(`/api/users/${userId}/role?role=${role}`); + } catch (err) { + alert(err.message); + if (currentOrgId) loadOrgUsers(currentOrgId); + } +} + +async function toggleUser(userId, activate) { + try { + await API.put(`/api/users/${userId}/${activate ? "activate" : "deactivate"}`); + if (currentOrgId) loadOrgUsers(currentOrgId); + } catch (err) { + alert(err.message); + } +} + +function confirmDeleteUser(userId, email) { + showConfirm( + "Nutzer loeschen", + `Soll der Nutzer "${email}" endgueltig geloescht werden?`, + async () => { + try { + await API.del(`/api/users/${userId}`); + if (currentOrgId) loadOrgUsers(currentOrgId); + } catch (err) { + alert(err.message); + } + } + ); +} + +// --- Org Licenses --- +async function loadOrgLicenses(orgId) { + try { + const licenses = await API.get(`/api/licenses?org_id=${orgId}`); + const tbody = document.getElementById("licenseTable"); + if (licenses.length === 0) { + tbody.innerHTML = 'Keine Lizenzen'; + return; + } + tbody.innerHTML = licenses.map(l => ` + + ${l.license_type} + ${l.max_users} + ${formatDate(l.valid_from)} + ${l.valid_until ? formatDate(l.valid_until) : "Unbegrenzt"} + ${l.status} + + ${l.status === "active" ? ` + + + ` : ""} + + + `).join(""); + } catch (err) { + console.error("Lizenzen laden fehlgeschlagen:", err); + } +} + +async function extendLicense(licId) { + const days = prompt("Um wie viele Tage verlaengern?", "365"); + if (!days) return; + try { + await API.put(`/api/licenses/${licId}/extend?days=${parseInt(days)}`); + if (currentOrgId) loadOrgLicenses(currentOrgId); + } catch (err) { + alert(err.message); + } +} + +function confirmRevokeLicense(licId) { + showConfirm( + "Lizenz widerrufen", + "Soll die Lizenz wirklich widerrufen werden? Nutzer koennen dann nur noch lesen.", + async () => { + try { + await API.put(`/api/licenses/${licId}/revoke`); + if (currentOrgId) loadOrgLicenses(currentOrgId); + } catch (err) { + alert(err.message); + } + } + ); +} + +// --- Expiring Licenses (global view) --- +async function loadExpiringLicenses() { + const days = document.getElementById("expiringDays").value; + try { + const licenses = await API.get(`/api/licenses/expiring?days=${days}`); + const tbody = document.getElementById("expiringTable"); + if (licenses.length === 0) { + tbody.innerHTML = `Keine ablaufenden Lizenzen in den naechsten ${days} Tagen`; + return; + } + tbody.innerHTML = licenses.map(l => ` + + ${esc(l.org_name)} + ${l.license_type} + ${l.max_users} + ${formatDate(l.valid_until)} + + + + + `).join(""); + } catch (err) { + console.error("Ablaufende Lizenzen laden fehlgeschlagen:", err); + } +} + +document.addEventListener("DOMContentLoaded", () => { + const sel = document.getElementById("expiringDays"); + if (sel) sel.addEventListener("change", loadExpiringLicenses); +}); + +function switchToOrg(orgId) { + // Switch to orgs tab and open detail + document.querySelectorAll(".nav-tabs:not(#orgDetailTabs) .nav-tab").forEach(t => t.classList.remove("active")); + document.querySelector('.nav-tab[data-section="orgs"]').classList.add("active"); + document.querySelectorAll(".app-content > .section").forEach(s => s.classList.remove("active")); + document.getElementById("sec-orgs").classList.add("active"); + openOrg(orgId); +} + +// --- Forms --- +function setupForms() { + // New Org + document.getElementById("newOrgBtn").addEventListener("click", () => openModal("modalNewOrg")); + document.getElementById("newOrgForm").addEventListener("submit", async (e) => { + e.preventDefault(); + const errEl = document.getElementById("newOrgError"); + errEl.style.display = "none"; + try { + await API.post("/api/orgs", { + name: document.getElementById("newOrgName").value, + slug: document.getElementById("newOrgSlug").value, + }); + closeModal("modalNewOrg"); + document.getElementById("newOrgForm").reset(); + loadOrgs(); + loadDashboard(); + } catch (err) { + errEl.textContent = err.message; + errEl.style.display = "block"; + } + }); + + // Auto-generate slug from name + document.getElementById("newOrgName").addEventListener("input", (e) => { + const slug = e.target.value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + document.getElementById("newOrgSlug").value = slug; + }); + + // New User + document.getElementById("newUserBtn").addEventListener("click", () => openModal("modalNewUser")); + document.getElementById("newUserForm").addEventListener("submit", async (e) => { + e.preventDefault(); + const errEl = document.getElementById("newUserError"); + errEl.style.display = "none"; + try { + await API.post(`/api/users?org_id=${currentOrgId}`, { + email: document.getElementById("newUserEmail").value, + username: document.getElementById("newUserName").value, + role: document.getElementById("newUserRole").value, + }); + closeModal("modalNewUser"); + document.getElementById("newUserForm").reset(); + loadOrgUsers(currentOrgId); + } catch (err) { + errEl.textContent = err.message; + errEl.style.display = "block"; + } + }); + + // New License + document.getElementById("newLicenseBtn").addEventListener("click", () => { + document.getElementById("newLicenseForm").reset(); + openModal("modalNewLicense"); + }); + + document.getElementById("newLicType").addEventListener("change", (e) => { + const durationGroup = document.getElementById("durationGroup"); + if (e.target.value === "permanent") { + durationGroup.style.display = "none"; + } else { + durationGroup.style.display = ""; + document.getElementById("newLicDuration").value = e.target.value === "trial" ? "14" : "365"; + } + }); + + document.getElementById("newLicenseForm").addEventListener("submit", async (e) => { + e.preventDefault(); + const errEl = document.getElementById("newLicError"); + errEl.style.display = "none"; + const licType = document.getElementById("newLicType").value; + const body = { + organization_id: currentOrgId, + license_type: licType, + max_users: parseInt(document.getElementById("newLicMaxUsers").value), + }; + if (licType !== "permanent") { + body.duration_days = parseInt(document.getElementById("newLicDuration").value); + } + try { + await API.post("/api/licenses", body); + closeModal("modalNewLicense"); + loadOrgLicenses(currentOrgId); + loadDashboard(); + } catch (err) { + errEl.textContent = err.message; + errEl.style.display = "block"; + } + }); + + // Org Edit + document.getElementById("orgEditForm").addEventListener("submit", async (e) => { + e.preventDefault(); + try { + await API.put(`/api/orgs/${currentOrgId}`, { + name: document.getElementById("editOrgName").value, + is_active: document.getElementById("editOrgActive").value === "true", + }); + openOrg(currentOrgId); + loadOrgs(); + loadDashboard(); + } catch (err) { + alert(err.message); + } + }); + + // Delete Org + document.getElementById("deleteOrgBtn").addEventListener("click", () => { + showConfirm( + "Organisation loeschen", + "Soll die Organisation mit allen Nutzern und Lizenzen endgueltig geloescht werden? Diese Aktion kann nicht rueckgaengig gemacht werden.", + async () => { + try { + await API.del(`/api/orgs/${currentOrgId}`); + document.getElementById("orgListView").style.display = ""; + document.getElementById("orgDetail").classList.remove("active"); + currentOrgId = null; + loadOrgs(); + loadDashboard(); + } catch (err) { + alert(err.message); + } + } + ); + }); +} + +// --- Modal helpers --- +function openModal(id) { + document.getElementById(id).classList.add("active"); +} + +function closeModal(id) { + document.getElementById(id).classList.remove("active"); +} + +// Confirm dialog +let confirmCallback = null; + +function showConfirm(title, text, callback) { + document.getElementById("confirmTitle").textContent = title; + document.getElementById("confirmText").textContent = text; + confirmCallback = callback; + openModal("modalConfirm"); +} + +document.addEventListener("DOMContentLoaded", () => { + document.getElementById("confirmOkBtn").addEventListener("click", async () => { + closeModal("modalConfirm"); + if (confirmCallback) await confirmCallback(); + confirmCallback = null; + }); +}); + +// --- Utilities --- +function esc(str) { + if (!str) return ""; + const div = document.createElement("div"); + div.textContent = str; + return div.innerHTML; +} + +function formatDate(iso) { + if (!iso) return "-"; + try { + const d = new Date(iso); + return d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }); + } catch { + return iso; + } +}