Files
IT-Nexus/frontend/src/pages/AiPage.jsx
Simon Grüssing 81b1c326fc
Some checks failed
IT Nexus Deploy / Build Frontend (push) Has been cancelled
IT Nexus Deploy / Deploy to Production (push) Has been cancelled
Security: WS-Rollenprüfung, JWT-Cookie statt localStorage, XSS/SSRF-Fixes, RDP-Consent-Secret
- WebSocket Shell/RDP: Rollenprüfung statt nur JWT-Gültigkeit (war: jeder eingeloggte User konnte fremde Agents per Shell/RDP übernehmen)
- JWT_SECRET: Server bricht ab statt mit unsicherem Default weiterzulaufen
- Auth: Token läuft jetzt über httpOnly-Cookie statt localStorage (XSS-Schutz gegen Session-Diebstahl)
- WS-Auth: Token nicht mehr als URL-Query-Param (landete in nginx-Logs), sondern als erste Message bzw. automatisch via Cookie
- Frontend: toter Rollen-Check (isSuperAdmin/isAdmin ohne Funktionsaufruf) in AgentDetailPage gefixt
- XSS: DOMPurify-Sanitizing für alle marked.parse()-Renderstellen (KI-Antworten, Kommentare, Knowledge Base)
- E-Mail: HTML-Escaping für alle ticket-gesteuerten Felder (auch über öffentliche Ticket-Route erreichbar)
- SSRF-Schutz beim Knowledge-Base-URL-Import (blockt private/Loopback-Adressen)
- TV-Dashboard: Shared-Key statt komplett offenem Endpoint
- Striktes Rate-Limit auf /login, must_change_password serverseitig erzwungen
- Agent (C#) v2.7.0: RDP-Consent/Disconnect/Capture verlangen jetzt ein Pro-Session-Secret (war: jeder lokale Prozess konnte Consent vortäuschen), DataDir-ACL für agent.log/status.json
- FIDO-PINs AES-256-GCM-verschlüsselt statt Klartext, Retention-Job für alte patch_commands/audit_log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 13:27:17 +02:00

373 lines
22 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef } from 'react';
import { useAuth } from '../context/AuthContext';
import aiService from '../services/aiService';
import { toast } from 'react-toastify';
import { renderMd } from '../utils/sanitizeMarkdown';
const WELCOME_MSG = {
role: 'assistant',
content: 'Hallo! Ich bin dein KI-Assistent für IT-Support. Du kannst mich nach Lösungen für bekannte Probleme, Troubleshooting-Schritten oder Ticket-Einschätzungen fragen.',
};
const CATEGORY_LIST = ['Software', 'Hardware', 'Allgemein', 'SelectLine'];
export default function AiPage() {
const { isAdmin } = useAuth();
const [activeTab, setActiveTab] = useState('chat');
// Chat State
const [messages, setMessages] = useState([WELCOME_MSG]);
const [input, setInput] = useState('');
const [chatLoading, setChatLoading] = useState(false);
const [configured, setConfigured] = useState(true);
const bottomRef = useRef(null);
const inputRef = useRef(null);
// Knowledge Base State
const [kbEntries, setKbEntries] = useState([]);
const [kbLoading, setKbLoading] = useState(false);
const [showKbForm, setShowKbForm] = useState(false);
const [kbForm, setKbForm] = useState({ problem: '', solution: '', category: 'Allgemein', tags: '' });
const [kbSaving, setKbSaving] = useState(false);
useEffect(() => {
aiService.getStatus()
.then(s => setConfigured(s.configured))
.catch(() => setConfigured(false));
}, []);
useEffect(() => {
if (activeTab === 'kb') loadKb();
}, [activeTab]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
async function loadKb() {
setKbLoading(true);
try {
const data = await aiService.getKnowledgeBase();
setKbEntries(data);
} catch (err) {
toast.error('Wissensdatenbank konnte nicht geladen werden');
} finally {
setKbLoading(false);
}
}
async function sendMessage() {
const text = input.trim();
if (!text || chatLoading) return;
const userMsg = { role: 'user', content: text };
const newMessages = [...messages, userMsg];
setMessages(newMessages);
setInput('');
setChatLoading(true);
try {
const contextMsgs = newMessages.filter(m => m !== WELCOME_MSG).slice(-20);
const reply = await aiService.chat(contextMsgs);
setMessages(prev => [...prev, { role: 'assistant', content: reply }]);
} catch (err) {
setMessages(prev => [...prev, {
role: 'assistant',
content: `Fehler: ${err.response?.data?.message || err.message}`,
isError: true,
}]);
} finally {
setChatLoading(false);
setTimeout(() => inputRef.current?.focus(), 50);
}
}
async function saveKbEntry() {
if (!kbForm.problem.trim() || !kbForm.solution.trim()) {
toast.error('Problem und Lösung sind pflichtfelder');
return;
}
setKbSaving(true);
try {
await aiService.addKnowledgeEntry(kbForm);
toast.success('Eintrag gespeichert');
setKbForm({ problem: '', solution: '', category: 'Allgemein', tags: '' });
setShowKbForm(false);
loadKb();
} catch (err) {
toast.error('Fehler beim Speichern');
} finally {
setKbSaving(false);
}
}
async function deleteKbEntry(id) {
if (!window.confirm('Eintrag wirklich löschen?')) return;
try {
await aiService.deleteKnowledgeEntry(id);
toast.success('Eintrag gelöscht');
setKbEntries(prev => prev.filter(e => e.id !== id));
} catch {
toast.error('Fehler beim Löschen');
}
}
const TAB = (active) => ({
padding: '10px 16px',
border: 'none',
background: 'transparent',
cursor: 'pointer',
fontSize: '0.875rem',
fontWeight: active ? 600 : 400,
color: active ? 'var(--cereda-primary)' : 'var(--text-muted)',
borderBottom: active ? '2px solid var(--cereda-primary)' : '2px solid transparent',
});
return (
<div className="main-content">
<div style={{ maxWidth: 960, margin: '0 auto' }}>
{/* Header */}
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 4 }}>
<span style={{ fontSize: 28 }}>🤖</span>
<h1 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 700 }}>KI-Assistent</h1>
{!configured && (
<span style={{ fontSize: 12, padding: '2px 8px', borderRadius: 6, background: '#fef3c7', color: '#92400e', border: '1px solid #fde68a' }}>
Nicht konfiguriert
</span>
)}
</div>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.875rem' }}>
Claude claude-sonnet-4-6 · IT-Support-Assistent mit Wissensdatenbank
</p>
</div>
{!configured && (
<div className="card" style={{ padding: '16px 20px', marginBottom: 20, background: '#fef3c7', border: '1px solid #fde68a' }}>
<strong> ANTHROPIC_API_KEY fehlt</strong>
<p style={{ margin: '4px 0 0', fontSize: '0.875rem', color: '#92400e' }}>
Füge <code>ANTHROPIC_API_KEY=sk-ant-...</code> in der docker-compose.yml als Umgebungsvariable hinzu und deploye neu.
</p>
</div>
)}
<div className="card" style={{ overflow: 'hidden' }}>
{/* Tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-color)', padding: '0 8px' }}>
<button style={TAB(activeTab === 'chat')} onClick={() => setActiveTab('chat')}>
💬 Chat
</button>
<button style={TAB(activeTab === 'kb')} onClick={() => setActiveTab('kb')}>
📚 Wissensdatenbank ({kbEntries.length})
</button>
</div>
{/* Chat Tab */}
{activeTab === 'chat' && (
<div style={{ display: 'flex', flexDirection: 'column', height: 560 }}>
{/* Messages */}
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 12 }}>
{messages.map((msg, i) => (
<div key={i} style={{ display: 'flex', justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start' }}>
<div style={{
maxWidth: '75%',
padding: '10px 14px',
borderRadius: msg.role === 'user' ? '14px 14px 2px 14px' : '14px 14px 14px 2px',
background: msg.role === 'user'
? 'linear-gradient(135deg, var(--cereda-primary), #8b5cf6)'
: msg.isError ? '#fee2e2' : 'var(--bg-tertiary)',
color: msg.role === 'user' ? '#fff' : msg.isError ? '#991b1b' : 'var(--text-primary)',
fontSize: '0.875rem',
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
}}>
{msg.role === 'assistant'
? <div className="md-content" dangerouslySetInnerHTML={renderMd(msg.content)} />
: msg.content}
</div>
</div>
))}
{chatLoading && (
<div style={{ display: 'flex' }}>
<div style={{ padding: '10px 16px', borderRadius: '14px 14px 14px 2px', background: 'var(--bg-tertiary)', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
KI denkt nach
</div>
</div>
)}
<div ref={bottomRef} />
</div>
{/* Input */}
<div style={{ padding: '12px 16px', borderTop: '1px solid var(--border-color)', display: 'flex', gap: 10, alignItems: 'flex-end' }}>
<textarea
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }}
placeholder="Nachricht eingeben… (Enter zum Senden, Shift+Enter für Zeilenumbruch)"
disabled={chatLoading || !configured}
rows={2}
style={{
flex: 1, resize: 'none', padding: '10px 12px', borderRadius: 8,
border: '1px solid var(--border-color)', fontSize: '0.875rem',
background: 'var(--bg-primary)', color: 'var(--text-primary)',
outline: 'none', fontFamily: 'inherit',
}}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'stretch' }}>
<button
onClick={sendMessage}
disabled={chatLoading || !input.trim() || !configured}
className="btn btn-primary"
style={{ height: 38, padding: '0 20px', whiteSpace: 'nowrap' }}
>
Senden
</button>
<button
onClick={() => setMessages([WELCOME_MSG])}
className="btn btn-secondary"
style={{ height: 38, padding: '0 20px', fontSize: '0.75rem', whiteSpace: 'nowrap' }}
>
Leeren
</button>
</div>
</div>
</div>
)}
{/* Knowledge Base Tab */}
{activeTab === 'kb' && (
<div style={{ padding: '20px 24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<div>
<h3 style={{ margin: 0, fontSize: '1rem' }}>Bekannte Probleme & Lösungen</h3>
<p style={{ margin: '2px 0 0', fontSize: '0.8125rem', color: 'var(--text-muted)' }}>
Diese Einträge werden als Kontext an den KI-Assistenten weitergegeben.
</p>
</div>
{isAdmin() && (
<button className="btn btn-primary" onClick={() => setShowKbForm(f => !f)}>
+ Eintrag hinzufügen
</button>
)}
</div>
{/* Add Form */}
{showKbForm && isAdmin() && (
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 10, padding: 20, marginBottom: 20 }}>
<h4 style={{ margin: '0 0 16px', fontSize: '0.9375rem' }}>Neuer Eintrag</h4>
<div style={{ display: 'grid', gap: 12 }}>
<div>
<label style={{ fontSize: '0.75rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)', display: 'block', marginBottom: 4 }}>Problem *</label>
<textarea
value={kbForm.problem}
onChange={e => setKbForm(f => ({ ...f, problem: e.target.value }))}
placeholder="Beschreibe das Problem"
rows={2}
style={{ width: '100%', padding: '8px 10px', borderRadius: 6, border: '1px solid var(--border-color)', fontSize: '0.875rem', background: 'var(--bg-primary)', color: 'var(--text-primary)', resize: 'vertical', boxSizing: 'border-box' }}
/>
</div>
<div>
<label style={{ fontSize: '0.75rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)', display: 'block', marginBottom: 4 }}>Lösung *</label>
<textarea
value={kbForm.solution}
onChange={e => setKbForm(f => ({ ...f, solution: e.target.value }))}
placeholder="Beschreibe die Lösung Schritt für Schritt"
rows={4}
style={{ width: '100%', padding: '8px 10px', borderRadius: 6, border: '1px solid var(--border-color)', fontSize: '0.875rem', background: 'var(--bg-primary)', color: 'var(--text-primary)', resize: 'vertical', boxSizing: 'border-box' }}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label style={{ fontSize: '0.75rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)', display: 'block', marginBottom: 4 }}>Kategorie</label>
<select
value={kbForm.category}
onChange={e => setKbForm(f => ({ ...f, category: e.target.value }))}
style={{ width: '100%', padding: '8px 10px', borderRadius: 6, border: '1px solid var(--border-color)', fontSize: '0.875rem', background: 'var(--bg-primary)', color: 'var(--text-primary)' }}
>
{CATEGORY_LIST.map(c => <option key={c}>{c}</option>)}
</select>
</div>
<div>
<label style={{ fontSize: '0.75rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)', display: 'block', marginBottom: 4 }}>Tags</label>
<input
type="text"
value={kbForm.tags}
onChange={e => setKbForm(f => ({ ...f, tags: e.target.value }))}
placeholder="z.B. VPN, Outlook, Drucker"
style={{ width: '100%', padding: '8px 10px', borderRadius: 6, border: '1px solid var(--border-color)', fontSize: '0.875rem', background: 'var(--bg-primary)', color: 'var(--text-primary)', boxSizing: 'border-box' }}
/>
</div>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button className="btn btn-secondary" onClick={() => setShowKbForm(false)}>Abbrechen</button>
<button className="btn btn-primary" onClick={saveKbEntry} disabled={kbSaving}>
{kbSaving ? 'Speichern…' : 'Speichern'}
</button>
</div>
</div>
</div>
)}
{/* Entries */}
{kbLoading ? (
<div style={{ textAlign: 'center', padding: '3rem 0', color: 'var(--text-muted)' }}>Lade</div>
) : kbEntries.length === 0 ? (
<div style={{ textAlign: 'center', padding: '3rem 0', color: 'var(--text-muted)' }}>
<div style={{ fontSize: '2.5rem', marginBottom: 8 }}>📚</div>
<p style={{ margin: 0 }}>Noch keine Einträge vorhanden.</p>
{isAdmin() && <p style={{ margin: '4px 0 0', fontSize: '0.8125rem' }}>Füge bekannte Probleme & Lösungen hinzu, damit die KI besser helfen kann.</p>}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{kbEntries.map(entry => (
<div key={entry.id} style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 10, padding: '14px 18px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span style={{ fontSize: '0.6875rem', fontWeight: 700, padding: '2px 8px', borderRadius: 4, background: 'var(--bg-tertiary)', color: 'var(--text-muted)' }}>
{entry.category}
</span>
{entry.tags && (
<span style={{ fontSize: '0.6875rem', color: 'var(--text-muted)' }}>
🏷 {entry.tags}
</span>
)}
</div>
<div style={{ marginBottom: 8 }}>
<span style={{ fontSize: '0.6875rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)' }}>Problem: </span>
<span style={{ fontSize: '0.875rem', color: 'var(--text-primary)', whiteSpace: 'pre-wrap' }}>{entry.problem}</span>
</div>
<div>
<span style={{ fontSize: '0.6875rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--success)' }}>Lösung: </span>
<span style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{entry.solution}</span>
</div>
</div>
{isAdmin() && (
<button
onClick={() => deleteKbEntry(entry.id)}
style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid var(--border-color)', background: 'transparent', color: 'var(--danger)', cursor: 'pointer', fontSize: '0.8125rem', flexShrink: 0 }}
title="Löschen"
>
🗑
</button>
)}
</div>
<div style={{ marginTop: 8, fontSize: '0.75rem', color: 'var(--text-muted)' }}>
Erstellt von {entry.created_by_username || 'System'} · {new Date(entry.created_at).toLocaleDateString('de-DE')}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}