diff --git a/requirements.txt b/requirements.txt
index 7b944e4..ddcb6b7 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,7 +1,7 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
python-jose[cryptography]
-passlib[bcrypt]
+bcrypt
aiosqlite
feedparser
httpx
diff --git a/setup_users.py b/setup_users.py
deleted file mode 100644
index 0071fed..0000000
--- a/setup_users.py
+++ /dev/null
@@ -1,63 +0,0 @@
-"""Erstellt die initialen Nutzer für den OSINT Lagemonitor."""
-import asyncio
-import os
-import sys
-import secrets
-import string
-
-sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
-
-from database import init_db, get_db
-from auth import hash_password
-
-
-def generate_password(length=16):
- """Generiert ein sicheres Passwort."""
- alphabet = string.ascii_letters + string.digits + "!@#$%&*"
- return ''.join(secrets.choice(alphabet) for _ in range(length))
-
-
-async def main():
- await init_db()
- db = await get_db()
-
- users = [
- {"username": "rac00n", "password": generate_password()},
- {"username": "ch33tah", "password": generate_password()},
- ]
-
- print("\n=== OSINT Lagemonitor - Nutzer-Setup ===\n")
-
- for user in users:
- cursor = await db.execute(
- "SELECT id FROM users WHERE username = ?", (user["username"],)
- )
- existing = await cursor.fetchone()
-
- if existing:
- # Passwort aktualisieren
- pw_hash = hash_password(user["password"])
- await db.execute(
- "UPDATE users SET password_hash = ? WHERE username = ?",
- (pw_hash, user["username"]),
- )
- print(f" Nutzer '{user['username']}' - Passwort aktualisiert")
- else:
- pw_hash = hash_password(user["password"])
- await db.execute(
- "INSERT INTO users (username, password_hash) VALUES (?, ?)",
- (user["username"], pw_hash),
- )
- print(f" Nutzer '{user['username']}' - Erstellt")
-
- print(f" Passwort: {user['password']}")
- print()
-
- await db.commit()
- await db.close()
-
- print("WICHTIG: Passwörter jetzt notieren! Sie werden nicht erneut angezeigt.\n")
-
-
-if __name__ == "__main__":
- asyncio.run(main())
diff --git a/src/agents/analyzer.py b/src/agents/analyzer.py
index 0f406a5..2f42cd1 100644
--- a/src/agents/analyzer.py
+++ b/src/agents/analyzer.py
@@ -1,5 +1,4 @@
"""Analyzer-Agent: Analysiert, übersetzt und fasst Meldungen zusammen."""
-import asyncio
import json
import logging
import re
diff --git a/src/agents/factchecker.py b/src/agents/factchecker.py
index 8ae2073..13969d9 100644
--- a/src/agents/factchecker.py
+++ b/src/agents/factchecker.py
@@ -1,5 +1,4 @@
"""Factchecker-Agent: Prüft Fakten gegen mehrere unabhängige Quellen."""
-import asyncio
import json
import logging
import re
diff --git a/src/agents/orchestrator.py b/src/agents/orchestrator.py
index 10c40b9..52438f3 100644
--- a/src/agents/orchestrator.py
+++ b/src/agents/orchestrator.py
@@ -10,7 +10,6 @@ from urllib.parse import urlparse, urlunparse
from agents.claude_client import UsageAccumulator
from source_rules import (
- DOMAIN_CATEGORY_MAP,
_detect_category,
_extract_domain,
discover_source,
diff --git a/src/agents/researcher.py b/src/agents/researcher.py
index 3decddc..0adeeb4 100644
--- a/src/agents/researcher.py
+++ b/src/agents/researcher.py
@@ -1,5 +1,4 @@
"""Researcher-Agent: Sucht nach Informationen via Claude WebSearch."""
-import asyncio
import json
import logging
import re
diff --git a/src/auth.py b/src/auth.py
index 2a1d6d4..37dca52 100644
--- a/src/auth.py
+++ b/src/auth.py
@@ -3,24 +3,13 @@ import secrets
import string
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, MAGIC_LINK_EXPIRE_MINUTES
+from config import JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS
security = HTTPBearer()
-def hash_password(password: str) -> str:
- """Passwort hashen mit bcrypt."""
- return _bcrypt.hashpw(password.encode("utf-8"), _bcrypt.gensalt()).decode("utf-8")
-
-
-def verify_password(password: str, password_hash: str) -> bool:
- """Passwort gegen Hash pruefen."""
- return _bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
-
-
JWT_ISSUER = "intelsight-osint"
JWT_AUDIENCE = "intelsight-osint"
@@ -84,18 +73,6 @@ async def get_current_user(
}
-async def require_org_member(
- current_user: dict = Depends(get_current_user),
-) -> dict:
- """FastAPI Dependency: Erfordert Org-Mitgliedschaft."""
- if not current_user.get("tenant_id"):
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail="Keine Organisation zugeordnet",
- )
- return current_user
-
-
def generate_magic_token() -> str:
"""Generiert einen 64-Zeichen URL-safe Token."""
return secrets.token_urlsafe(48)
diff --git a/src/config.py b/src/config.py
index 05c281c..8588c50 100644
--- a/src/config.py
+++ b/src/config.py
@@ -21,17 +21,11 @@ JWT_EXPIRE_HOURS = 24
# Claude CLI
CLAUDE_PATH = os.environ.get("CLAUDE_PATH", "/home/claude-dev/.claude/local/claude")
-CLAUDE_MAX_CONCURRENT = 1
CLAUDE_TIMEOUT = 300 # Sekunden (Claude mit WebSearch braucht oft 2-3 Min)
# Ausgabesprache (Lagebilder, Faktenchecks, Zusammenfassungen)
OUTPUT_LANGUAGE = "Deutsch"
-# Auto-Refresh
-REFRESH_MIN_INTERVAL = 10 # Minuten
-REFRESH_MAX_INTERVAL = 10080 # 1 Woche
-REFRESH_DEFAULT_INTERVAL = 15
-
# RSS-Feeds (Fallback, primär aus DB geladen)
RSS_FEEDS = {
"deutsch": [
diff --git a/src/database.py b/src/database.py
index 61eec40..c03b5c0 100644
--- a/src/database.py
+++ b/src/database.py
@@ -52,6 +52,7 @@ CREATE TABLE IF NOT EXISTS magic_links (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
+-- Hinweis: portal_admins wird von der Verwaltungs-App (Admin-Portal) genutzt, die dieselbe DB teilt.
CREATE TABLE IF NOT EXISTS portal_admins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
@@ -213,14 +214,6 @@ async def init_db():
await db.commit()
logger.info("Migration: tenant_id zu incidents hinzugefuegt")
- # Migration: E-Mail-Benachrichtigungs-Praeferenzen pro Lage
- if "notify_email_summary" not in columns:
- await db.execute("ALTER TABLE incidents ADD COLUMN notify_email_summary INTEGER DEFAULT 0")
- await db.execute("ALTER TABLE incidents ADD COLUMN notify_email_new_articles INTEGER DEFAULT 0")
- await db.execute("ALTER TABLE incidents ADD COLUMN notify_email_status_change INTEGER DEFAULT 0")
- await db.commit()
- logger.info("Migration: E-Mail-Benachrichtigungs-Spalten zu incidents hinzugefuegt")
-
# Migration: Token-Spalten fuer refresh_log
cursor = await db.execute("PRAGMA table_info(refresh_log)")
rl_columns = [row[1] for row in await cursor.fetchall()]
@@ -246,19 +239,6 @@ async def init_db():
await db.execute("ALTER TABLE refresh_log ADD COLUMN tenant_id INTEGER REFERENCES organizations(id)")
await db.commit()
- # Migration: reliability_score entfernen (falls noch vorhanden)
- cursor = await db.execute("PRAGMA table_info(incidents)")
- inc_columns = [row[1] for row in await cursor.fetchall()]
- if "reliability_score" in inc_columns:
- await db.execute("ALTER TABLE incidents DROP COLUMN reliability_score")
- await db.commit()
-
- cursor = await db.execute("PRAGMA table_info(incident_snapshots)")
- snap_columns = [row[1] for row in await cursor.fetchall()]
- if "reliability_score" in snap_columns:
- await db.execute("ALTER TABLE incident_snapshots DROP COLUMN reliability_score")
- await db.commit()
-
# Migration: notifications-Tabelle (fuer bestehende DBs)
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='notifications'")
if not await cursor.fetchone():
@@ -340,14 +320,6 @@ async def init_db():
await db.execute("ALTER TABLE users ADD COLUMN last_login_at TIMESTAMP")
await db.commit()
- # Migration: E-Mail-Benachrichtigungs-Praeferenzen fuer users
- if "notify_email_summary" not in user_columns:
- await db.execute("ALTER TABLE users ADD COLUMN notify_email_summary INTEGER DEFAULT 0")
- await db.execute("ALTER TABLE users ADD COLUMN notify_email_new_articles INTEGER DEFAULT 0")
- await db.execute("ALTER TABLE users ADD COLUMN notify_email_status_change INTEGER DEFAULT 0")
- await db.commit()
- logger.info("Migration: E-Mail-Benachrichtigungs-Spalten zu users hinzugefuegt")
-
# Migration: tenant_id fuer articles
cursor = await db.execute("PRAGMA table_info(articles)")
art_columns = [row[1] for row in await cursor.fetchall()]
diff --git a/src/email_utils/templates.py b/src/email_utils/templates.py
index 93107fd..83f2b7d 100644
--- a/src/email_utils/templates.py
+++ b/src/email_utils/templates.py
@@ -34,41 +34,6 @@ def magic_link_login_email(username: str, code: str, link: str) -> tuple[str, st
return subject, html
-def invite_email(username: str, org_name: str, code: str, link: str) -> tuple[str, str]:
- """Erzeugt Einladungs-E-Mail fuer neue Nutzer.
-
- Returns:
- (subject, html_body)
- """
- subject = f"Einladung zum AegisSight Monitor - {org_name}"
- html = f"""
-
-
-
-
-
AegisSight Monitor
-
-
Hallo {username},
-
-
Sie wurden zur Organisation {org_name} im AegisSight Monitor eingeladen.
-
-
Klicken Sie auf den Link, um Ihren Zugang zu aktivieren:
-
-
-
-
-
-
Dieser Link ist 48 Stunden gueltig.
-
-
-"""
- return subject, html
-
-
def incident_notification_email(
username: str,
incident_title: str,
diff --git a/src/models.py b/src/models.py
index fed049e..0d9e389 100644
--- a/src/models.py
+++ b/src/models.py
@@ -4,11 +4,6 @@ from typing import Optional
from datetime import datetime
-# Auth (Legacy)
-class LoginRequest(BaseModel):
- username: str
- password: str
-
# Auth (Magic Link)
class MagicLinkRequest(BaseModel):
@@ -34,10 +29,6 @@ class TokenResponse(BaseModel):
username: str
-class UserResponse(BaseModel):
- id: int
- username: str
-
class UserMeResponse(BaseModel):
id: int
@@ -97,32 +88,6 @@ class IncidentResponse(BaseModel):
source_count: int = 0
-# Articles
-class ArticleResponse(BaseModel):
- id: int
- incident_id: int
- headline: str
- headline_de: Optional[str]
- source: str
- source_url: Optional[str]
- content_original: Optional[str]
- content_de: Optional[str]
- language: str
- published_at: Optional[str]
- collected_at: str
- verification_status: str
-
-
-# Fact Checks
-class FactCheckResponse(BaseModel):
- id: int
- incident_id: int
- claim: str
- status: str
- sources_count: int
- evidence: Optional[str]
- is_notification: bool
- checked_at: str
# Sources (Quellenverwaltung)
@@ -191,18 +156,6 @@ class DomainActionRequest(BaseModel):
notes: Optional[str] = None
-# Refresh-Log
-class RefreshLogResponse(BaseModel):
- id: int
- started_at: str
- completed_at: Optional[str] = None
- articles_found: int = 0
- status: str
- trigger_type: str = "manual"
- retry_count: int = 0
- error_message: Optional[str] = None
- duration_seconds: Optional[float] = None
-
# Notifications
class NotificationResponse(BaseModel):
@@ -239,7 +192,3 @@ class FeedbackRequest(BaseModel):
message: str = Field(min_length=10, max_length=5000)
-class WSMessage(BaseModel):
- type: str # new_article, status_update, notification, refresh_complete
- incident_id: int
- data: dict
diff --git a/src/routers/auth.py b/src/routers/auth.py
index 76297e0..66e81d8 100644
--- a/src/routers/auth.py
+++ b/src/routers/auth.py
@@ -15,7 +15,6 @@ from auth import (
get_current_user,
generate_magic_token,
generate_magic_code,
- verify_password,
)
from database import db_dependency
from config import TIMEZONE, MAGIC_LINK_EXPIRE_MINUTES, MAGIC_LINK_BASE_URL
diff --git a/src/rss_parser.py b/src/rss_parser.py
deleted file mode 100644
index d5be675..0000000
--- a/src/rss_parser.py
+++ /dev/null
@@ -1,102 +0,0 @@
-"""RSS-Feed Parser: Durchsucht vorkonfigurierte Feeds nach relevanten Meldungen."""
-import asyncio
-import logging
-import feedparser
-import httpx
-from datetime import datetime, timezone
-from config import RSS_FEEDS
-
-logger = logging.getLogger("osint.rss")
-
-
-class RSSParser:
- """Durchsucht RSS-Feeds nach relevanten Artikeln."""
-
- # Stoppwörter die bei der RSS-Suche ignoriert werden
- STOP_WORDS = {
- "und", "oder", "der", "die", "das", "ein", "eine", "in", "im", "am", "an",
- "auf", "für", "mit", "von", "zu", "zum", "zur", "bei", "nach", "vor",
- "über", "unter", "ist", "sind", "hat", "the", "and", "for", "with", "from",
- }
-
- async def search_feeds(self, search_term: str) -> list[dict]:
- """Durchsucht alle konfigurierten RSS-Feeds nach einem Suchbegriff."""
- all_articles = []
- # Stoppwörter und kurze Wörter (< 3 Zeichen) filtern
- search_words = [
- w for w in search_term.lower().split()
- if w not in self.STOP_WORDS and len(w) >= 3
- ]
- if not search_words:
- search_words = search_term.lower().split()[:2]
-
- tasks = []
- for category, feeds in RSS_FEEDS.items():
- for feed_config in feeds:
- tasks.append(self._fetch_feed(feed_config, search_words))
-
- results = await asyncio.gather(*tasks, return_exceptions=True)
-
- for result in results:
- if isinstance(result, Exception):
- logger.warning(f"Feed-Fehler: {result}")
- continue
- all_articles.extend(result)
-
- logger.info(f"RSS-Suche nach '{search_term}': {len(all_articles)} Treffer")
- return all_articles
-
- async def _fetch_feed(self, feed_config: dict, search_words: list[str]) -> list[dict]:
- """Einzelnen RSS-Feed abrufen und durchsuchen."""
- name = feed_config["name"]
- url = feed_config["url"]
- articles = []
-
- try:
- async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
- response = await client.get(url, headers={
- "User-Agent": "OSINT-Monitor/1.0 (News Aggregator)"
- })
- response.raise_for_status()
-
- feed = await asyncio.to_thread(feedparser.parse, response.text)
-
- for entry in feed.entries[:50]:
- title = entry.get("title", "")
- summary = entry.get("summary", "")
- text = f"{title} {summary}".lower()
-
- # Prüfe ob mindestens ein Suchwort vorkommt
- if any(word in text for word in search_words):
- published = None
- if hasattr(entry, "published_parsed") and entry.published_parsed:
- try:
- published = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc).isoformat()
- except (TypeError, ValueError):
- pass
-
- articles.append({
- "headline": title,
- "headline_de": title if self._is_german(title) else None,
- "source": name,
- "source_url": entry.get("link", ""),
- "content_original": summary[:1000] if summary else None,
- "content_de": summary[:1000] if summary and self._is_german(summary) else None,
- "language": "de" if self._is_german(title) else "en",
- "published_at": published,
- })
-
- except Exception as e:
- logger.debug(f"Feed {name} ({url}): {e}")
-
- return articles
-
- def _is_german(self, text: str) -> bool:
- """Einfache Heuristik ob ein Text deutsch ist."""
- german_words = {"der", "die", "das", "und", "ist", "von", "mit", "für", "auf", "ein",
- "eine", "den", "dem", "des", "sich", "wird", "nach", "bei", "auch",
- "über", "wie", "aus", "hat", "zum", "zur", "als", "noch", "mehr",
- "nicht", "aber", "oder", "sind", "vor", "einem", "einer", "wurde"}
- words = set(text.lower().split())
- matches = words & german_words
- return len(matches) >= 2
diff --git a/src/static/components.js b/src/static/components.js
deleted file mode 100644
index f1a2831..0000000
--- a/src/static/components.js
+++ /dev/null
@@ -1,444 +0,0 @@
-/**
- * UI-Komponenten für das Dashboard.
- */
-const UI = {
- /**
- * Sidebar-Eintrag für eine Lage rendern.
- */
- renderIncidentItem(incident, isActive) {
- const isRefreshing = App._refreshingIncidents && App._refreshingIncidents.has(incident.id);
- const dotClass = isRefreshing ? 'refreshing' : (incident.status === 'active' ? 'active' : 'archived');
- const activeClass = isActive ? 'active' : '';
- const creator = incident.created_by_username || '';
-
- return `
-
-
-
-
${this.escape(incident.title)}
-
${incident.article_count} Artikel · ${this.escape(creator)}
-
- ${incident.visibility === 'private' ? '
PRIVAT' : ''}
- ${incident.type === 'research' ? '
RECH' : ''}
- ${incident.refresh_mode === 'auto' ? '
AUTO' : ''}
-
- `;
- },
-
- /**
- * Faktencheck-Eintrag rendern.
- */
- factCheckLabels: {
- confirmed: 'Bestätigt durch mehrere Quellen',
- unconfirmed: 'Nicht unabhängig bestätigt',
- contradicted: 'Widerlegt',
- developing: 'Faktenlage noch im Fluss',
- established: 'Gesicherter Fakt (3+ Quellen)',
- disputed: 'Umstrittener Sachverhalt',
- unverified: 'Nicht unabhängig verifizierbar',
- },
-
- factCheckChipLabels: {
- confirmed: 'Bestätigt',
- unconfirmed: 'Unbestätigt',
- contradicted: 'Widerlegt',
- developing: 'Unklar',
- established: 'Gesichert',
- disputed: 'Umstritten',
- unverified: 'Ungeprüft',
- },
-
- factCheckIcons: {
- confirmed: '✓',
- unconfirmed: '?',
- contradicted: '✗',
- developing: '↻',
- established: '✓',
- disputed: '⚠',
- unverified: '?',
- },
-
- /**
- * Faktencheck-Filterleiste rendern.
- */
- renderFactCheckFilters(factchecks) {
- // Welche Stati kommen tatsächlich vor + Zähler
- const statusCounts = {};
- factchecks.forEach(fc => {
- statusCounts[fc.status] = (statusCounts[fc.status] || 0) + 1;
- });
- const statusOrder = ['confirmed', 'established', 'developing', 'unconfirmed', 'unverified', 'disputed', 'contradicted'];
- const usedStatuses = statusOrder.filter(s => statusCounts[s]);
- if (usedStatuses.length <= 1) return '';
-
- const items = usedStatuses.map(status => {
- const icon = this.factCheckIcons[status] || '?';
- const chipLabel = this.factCheckChipLabels[status] || status;
- const count = statusCounts[status];
- return ``;
- }).join('');
-
- return `
- `;
- },
-
- renderFactCheck(fc) {
- const urls = (fc.evidence || '').match(/https?:\/\/[^\s,)]+/g) || [];
- const count = urls.length;
- return `
-
-
${this.factCheckIcons[fc.status] || '?'}
-
${this.factCheckLabels[fc.status] || fc.status}
-
-
${this.escape(fc.claim)}
-
- ${count} Quelle${count !== 1 ? 'n' : ''}
-
-
${this.renderEvidence(fc.evidence || '')}
-
-
- `;
- },
-
- /**
- * Evidence mit erklärenden Text UND Quellen-Chips rendern.
- */
- renderEvidence(text) {
- if (!text) return 'Keine Belege';
-
- const urls = text.match(/https?:\/\/[^\s,)]+/g) || [];
- if (urls.length === 0) {
- return `${this.escape(text)}`;
- }
-
- // Erklärenden Text extrahieren (URLs entfernen)
- let explanation = text;
- urls.forEach(url => { explanation = explanation.replace(url, '').trim(); });
- // Aufräumen: Klammern, mehrfache Kommas/Leerzeichen
- explanation = explanation.replace(/\(\s*\)/g, '');
- explanation = explanation.replace(/,\s*,/g, ',');
- explanation = explanation.replace(/\s+/g, ' ').trim();
- explanation = explanation.replace(/[,.:;]+$/, '').trim();
-
- // Chips für jede URL
- const chips = urls.map(url => {
- let label;
- try { label = new URL(url).hostname.replace('www.', ''); } catch { label = url; }
- return `${this.escape(label)}`;
- }).join('');
-
- const explanationHtml = explanation
- ? `${this.escape(explanation)}`
- : '';
-
- return `${explanationHtml}${chips}
`;
- },
-
- /**
- * Verifizierungs-Badge.
- */
- verificationBadge(status) {
- const map = {
- verified: { class: 'badge-verified', text: 'Verifiziert' },
- unverified: { class: 'badge-unverified', text: 'Offen' },
- contradicted: { class: 'badge-contradicted', text: 'Widerlegt' },
- };
- const badge = map[status] || map.unverified;
- return `${badge.text}`;
- },
-
- /**
- * Toast-Benachrichtigung anzeigen.
- */
- showToast(message, type = 'info', duration = 5000) {
- const container = document.getElementById('toast-container');
- const toast = document.createElement('div');
- toast.className = `toast toast-${type}`;
- toast.setAttribute('role', 'status');
- toast.innerHTML = `${this.escape(message)}`;
- container.appendChild(toast);
-
- setTimeout(() => {
- toast.style.opacity = '0';
- toast.style.transform = 'translateX(100%)';
- toast.style.transition = 'all 0.3s ease';
- setTimeout(() => toast.remove(), 300);
- }, duration);
- },
-
- /**
- * Fortschrittsanzeige einblenden und Status setzen.
- */
- showProgress(status) {
- const bar = document.getElementById('progress-bar');
- if (!bar) return;
- bar.style.display = 'block';
-
- const steps = {
- queued: { active: 0, label: 'In Warteschlange...' },
- researching: { active: 1, label: 'Recherchiert Quellen...' },
- deep_researching: { active: 1, label: 'Tiefenrecherche läuft...' },
- analyzing: { active: 2, label: 'Analysiert Meldungen...' },
- factchecking: { active: 3, label: 'Faktencheck läuft...' },
- };
-
- const step = steps[status] || steps.queued;
- const stepIds = ['step-researching', 'step-analyzing', 'step-factchecking'];
-
- stepIds.forEach((id, i) => {
- const el = document.getElementById(id);
- if (!el) return;
- el.className = 'progress-step';
- if (i + 1 < step.active) el.classList.add('done');
- else if (i + 1 === step.active) el.classList.add('active');
- });
-
- const fill = document.getElementById('progress-fill');
- const percent = step.active === 0 ? 5 : Math.round((step.active / 3) * 100);
- if (fill) {
- fill.style.width = percent + '%';
- }
-
- // ARIA-Werte auf der Progressbar aktualisieren
- bar.setAttribute('aria-valuenow', String(percent));
- bar.setAttribute('aria-valuetext', step.label);
-
- const label = document.getElementById('progress-label');
- if (label) label.textContent = step.label;
- },
-
- /**
- * Fortschrittsanzeige ausblenden.
- */
- hideProgress() {
- const bar = document.getElementById('progress-bar');
- if (bar) bar.style.display = 'none';
- },
-
- /**
- * Zusammenfassung mit Inline-Zitaten und Quellenverzeichnis rendern.
- */
- renderSummary(summary, sourcesJson, incidentType) {
- if (!summary) return 'Noch keine Zusammenfassung.';
-
- let sources = [];
- try { sources = JSON.parse(sourcesJson || '[]'); } catch(e) {}
-
- // Markdown-Rendering
- let html = this.escape(summary);
-
- // ## Überschriften
- html = html.replace(/^## (.+)$/gm, '$1
');
- // **Fettdruck**
- html = html.replace(/\*\*(.+?)\*\*/g, '$1');
- // Listen (- Item)
- html = html.replace(/^- (.+)$/gm, '$1');
- html = html.replace(/(.*<\/li>\n?)+/gs, '');
- // Zeilenumbrüche (aber nicht nach Headings/Listen)
- html = html.replace(/\n(?!<)/g, '
');
- // Überflüssige
nach Block-Elementen entfernen + doppelte
zusammenfassen
- html = html.replace(/<\/h3>(
)+/g, '');
- html = html.replace(/<\/ul>(
)+/g, '');
- html = html.replace(/(
){2,}/g, '
');
-
- // Inline-Zitate [1], [2] etc. als klickbare Links rendern
- if (sources.length > 0) {
- html = html.replace(/\[(\d+)\]/g, (match, num) => {
- const src = sources.find(s => s.nr === parseInt(num));
- if (src && src.url) {
- return `[${num}]`;
- }
- return match;
- });
- }
-
- return `${html}
`;
- },
-
- /**
- * Quellenübersicht für eine Lage rendern.
- */
- renderSourceOverview(articles) {
- if (!articles || articles.length === 0) return '';
-
- // Nach Quelle aggregieren
- const sourceMap = {};
- articles.forEach(a => {
- const name = a.source || 'Unbekannt';
- if (!sourceMap[name]) {
- sourceMap[name] = { count: 0, languages: new Set(), urls: [] };
- }
- sourceMap[name].count++;
- sourceMap[name].languages.add(a.language || 'de');
- if (a.source_url) sourceMap[name].urls.push(a.source_url);
- });
-
- const sources = Object.entries(sourceMap)
- .sort((a, b) => b[1].count - a[1].count);
-
- // Sprach-Statistik
- const langCount = {};
- articles.forEach(a => {
- const lang = (a.language || 'de').toUpperCase();
- langCount[lang] = (langCount[lang] || 0) + 1;
- });
-
- const langChips = Object.entries(langCount)
- .sort((a, b) => b[1] - a[1])
- .map(([lang, count]) => `${lang} ${count}`)
- .join('');
-
- let html = ``;
-
- html += '';
- sources.forEach(([name, data]) => {
- const langs = [...data.languages].map(l => l.toUpperCase()).join('/');
- html += `
- ${this.escape(name)}
- ${langs}
- ${data.count}
-
`;
- });
- html += '
';
-
- return html;
- },
-
- /**
- * Kategorie-Labels.
- */
- _categoryLabels: {
- 'nachrichtenagentur': 'Agentur',
- 'oeffentlich-rechtlich': 'ÖR',
- 'qualitaetszeitung': 'Qualität',
- 'behoerde': 'Behörde',
- 'fachmedien': 'Fach',
- 'think-tank': 'Think Tank',
- 'international': 'Intl.',
- 'regional': 'Regional',
- 'sonstige': 'Sonstige',
- },
-
- /**
- * Domain-Gruppe rendern (aufklappbar mit Feeds).
- */
- renderSourceGroup(domain, feeds, isExcluded, excludedNotes) {
- const catLabel = this._categoryLabels[feeds[0]?.category] || feeds[0]?.category || '';
- const feedCount = feeds.filter(f => f.source_type !== 'excluded').length;
- const hasMultiple = feedCount > 1;
- const displayName = domain || feeds[0]?.name || 'Unbekannt';
- const escapedDomain = this.escape(domain);
-
- if (isExcluded) {
- // Gesperrte Domain
- const notesHtml = excludedNotes ? ` ${this.escape(excludedNotes)}` : '';
- return `
-
-
`;
- }
-
- // Aktive Domain-Gruppe
- const toggleAttr = hasMultiple ? `onclick="App.toggleGroup('${escapedDomain}')" role="button" tabindex="0" aria-expanded="false"` : '';
- const toggleIcon = hasMultiple ? '▶' : '';
-
- let feedRows = '';
- if (hasMultiple) {
- const realFeeds = feeds.filter(f => f.source_type !== 'excluded');
- feedRows = ``;
- realFeeds.forEach((feed, i) => {
- const isLast = i === realFeeds.length - 1;
- const connector = isLast ? '\u2514\u2500' : '\u251C\u2500';
- const typeLabel = feed.source_type === 'rss_feed' ? 'RSS' : 'Web';
- const urlDisplay = feed.url ? this._shortenUrl(feed.url) : '';
- feedRows += `
- ${connector}
- ${this.escape(feed.name)}
- ${typeLabel}
- ${this.escape(urlDisplay)}
-
-
`;
- });
- feedRows += '
';
- }
-
- const feedCountBadge = feedCount > 0
- ? `${feedCount} Feed${feedCount !== 1 ? 's' : ''}`
- : '';
-
- return `
-
- ${feedRows}
-
`;
- },
-
- /**
- * URL kürzen für die Anzeige in Feed-Zeilen.
- */
- _shortenUrl(url) {
- try {
- const u = new URL(url);
- let path = u.pathname;
- if (path.length > 40) path = path.substring(0, 37) + '...';
- return u.hostname + path;
- } catch {
- return url.length > 50 ? url.substring(0, 47) + '...' : url;
- }
- },
-
- /**
- * URLs in Evidence-Text als kompakte Hostname-Chips rendern (Legacy-Fallback).
- */
- renderEvidenceChips(text) {
- return this.renderEvidence(text);
- },
-
- /**
- * URLs in Evidence-Text als klickbare Links rendern (Legacy).
- */
- linkifyEvidence(text) {
- if (!text) return '';
- const escaped = this.escape(text);
- return escaped.replace(
- /(https?:\/\/[^\s,)]+)/g,
- '$1'
- );
- },
-
- /**
- * HTML escapen.
- */
- escape(str) {
- if (!str) return '';
- const div = document.createElement('div');
- div.textContent = str;
- return div.innerHTML;
- },
-};
diff --git a/src/static/css/style.css b/src/static/css/style.css
index 185388c..e1fb3bf 100644
--- a/src/static/css/style.css
+++ b/src/static/css/style.css
@@ -341,6 +341,17 @@ a:hover {
color: var(--error);
}
+.login-success {
+ display: none;
+ background: var(--tint-success);
+ border: 1px solid rgba(16, 185, 129, 0.3);
+ border-radius: var(--radius);
+ padding: var(--sp-lg) var(--sp-xl);
+ margin-bottom: var(--sp-xl);
+ font-size: 13px;
+ color: var(--success);
+}
+
/* === Buttons === */
.btn {
display: inline-flex;
@@ -466,6 +477,88 @@ a:hover {
color: var(--text-secondary);
font-weight: 500;
}
+/* --- Org & License Info in Header --- */
+.header-user-info {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 2px;
+ line-height: 1.3;
+}
+
+.header-user-top {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-md);
+}
+
+.header-org-name {
+ font-size: 11px;
+ color: var(--text-tertiary);
+ font-weight: 400;
+ max-width: 200px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.header-license-badge {
+ display: inline-block;
+ font-size: 10px;
+ font-weight: 600;
+ padding: 1px 7px;
+ border-radius: 9999px;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ line-height: 1.6;
+ white-space: nowrap;
+}
+
+.header-license-badge.license-trial {
+ background: var(--warning-bg, #fef3c7);
+ color: var(--warning-text, #92400e);
+ border: 1px solid var(--warning-border, #fcd34d);
+}
+
+.header-license-badge.license-annual {
+ background: var(--success-bg, #d1fae5);
+ color: var(--success-text, #065f46);
+ border: 1px solid var(--success-border, #6ee7b7);
+}
+
+.header-license-badge.license-permanent {
+ background: var(--info-bg, #dbeafe);
+ color: var(--info-text, #1e40af);
+ border: 1px solid var(--info-border, #93c5fd);
+}
+
+.header-license-badge.license-expired {
+ background: var(--danger-bg, #fee2e2);
+ color: var(--danger-text, #991b1b);
+ border: 1px solid var(--danger-border, #fca5a5);
+}
+
+.header-license-badge.license-unknown {
+ background: var(--bg-tertiary, #f3f4f6);
+ color: var(--text-tertiary, #6b7280);
+ border: 1px solid var(--border-color, #d1d5db);
+}
+
+.header-license-warning {
+ display: none;
+ font-size: 11px;
+ color: var(--danger-text, #991b1b);
+ background: var(--danger-bg, #fee2e2);
+ border: 1px solid var(--danger-border, #fca5a5);
+ border-radius: var(--radius);
+ padding: 3px 10px;
+ white-space: nowrap;
+}
+
+.header-license-warning.visible {
+ display: inline-block;
+}
+
/* === Sidebar === */
.sidebar {
@@ -3165,6 +3258,23 @@ a:hover {
}
/* Delete Button */
+.source-edit-btn {
+ background: none;
+ border: none;
+ color: var(--text-disabled);
+ cursor: pointer;
+ font-size: 13px;
+ padding: 2px 6px;
+ border-radius: var(--radius);
+ transition: color 0.2s, background 0.2s;
+ line-height: 1;
+}
+
+.source-edit-btn:hover {
+ color: var(--accent);
+ background: var(--tint-accent);
+}
+
.source-delete-btn {
background: none;
border: none;
diff --git a/src/static/dashboard.html b/src/static/dashboard.html
index 0b624ce..f609f0a 100644
--- a/src/static/dashboard.html
+++ b/src/static/dashboard.html
@@ -26,7 +26,14 @@
@@ -90,7 +97,7 @@