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:

- -
-
{code}
-
- -
- Einladung annehmen -
- -

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 ` -
${items}
`; - }, - - renderFactCheck(fc) { - const urls = (fc.evidence || '').match(/https?:\/\/[^\s,)]+/g) || []; - const count = urls.length; - return ` -
- - ${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 += `${articles.length} Artikel aus ${sources.length} Quellen`; - html += `
    ${langChips}
    `; - 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 `
    -
    -
    - ${this.escape(displayName)}${notesHtml} -
    - Gesperrt -
    - - -
    -
    -
    `; - } - - // 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 `
    -
    - ${toggleIcon} -
    - ${this.escape(displayName)} -
    - ${catLabel} - ${feedCountBadge} -
    - - -
    -
    - ${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 @@
    - +
    @@ -501,7 +508,7 @@