diff --git a/frontend/src/tools/nachrichten.jsx b/frontend/src/tools/nachrichten.jsx new file mode 100644 index 0000000..63545db --- /dev/null +++ b/frontend/src/tools/nachrichten.jsx @@ -0,0 +1,360 @@ +import { useState, useEffect, useRef } from 'react'; +import { api, S } from '../lib.js'; +import { UserIcon, TrashIcon } from '../icons.jsx'; + +// ── E2E Crypto ──────────────────────────────────────────────────────────────── +const LS_KEY = 'dd_msg_keypair'; + +async function getOrCreateKeyPair() { + const stored = localStorage.getItem(LS_KEY); + if (stored) { + try { + const { pub, priv } = JSON.parse(stored); + const publicKey = await crypto.subtle.importKey('jwk', pub, { name:'ECDH', namedCurve:'P-256' }, true, []); + const privateKey = await crypto.subtle.importKey('jwk', priv, { name:'ECDH', namedCurve:'P-256' }, true, ['deriveKey']); + return { publicKey, privateKey, pubJwk: pub }; + } catch { localStorage.removeItem(LS_KEY); } + } + const kp = await crypto.subtle.generateKey({ name:'ECDH', namedCurve:'P-256' }, true, ['deriveKey']); + const pub = await crypto.subtle.exportKey('jwk', kp.publicKey); + const priv = await crypto.subtle.exportKey('jwk', kp.privateKey); + localStorage.setItem(LS_KEY, JSON.stringify({ pub, priv })); + return { publicKey: kp.publicKey, privateKey: kp.privateKey, pubJwk: pub }; +} + +async function deriveSharedKey(myPrivate, theirPublicJwk) { + const theirKey = await crypto.subtle.importKey( + 'jwk', theirPublicJwk, { name:'ECDH', namedCurve:'P-256' }, false, [] + ); + return crypto.subtle.deriveKey( + { name:'ECDH', public: theirKey }, + myPrivate, + { name:'AES-GCM', length:256 }, + false, ['encrypt','decrypt'] + ); +} + +async function encryptMsg(sharedKey, text) { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const enc = await crypto.subtle.encrypt({ name:'AES-GCM', iv }, sharedKey, new TextEncoder().encode(text)); + return { + encrypted_content: btoa(String.fromCharCode(...new Uint8Array(enc))), + iv: btoa(String.fromCharCode(...iv)), + }; +} + +async function decryptMsg(sharedKey, encrypted_content, ivB64) { + const enc = Uint8Array.from(atob(encrypted_content), c => c.charCodeAt(0)); + const ivArr = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0)); + const dec = await crypto.subtle.decrypt({ name:'AES-GCM', iv: ivArr }, sharedKey, enc); + return new TextDecoder().decode(dec); +} + +// ── Main Component ──────────────────────────────────────────────────────────── +export default function Nachrichten({ toast, mobile }) { + const [kp, setKp] = useState(null); + const [users, setUsers] = useState([]); + const [activeUser, setActiveUser] = useState(null); + const [messages, setMessages] = useState([]); + const [sharedKey, setSharedKey] = useState(null); + const [decrypted, setDecrypted] = useState({}); + const [text, setText] = useState(''); + const [sending, setSending] = useState(false); + const bottomRef = useRef(null); + const pollRef = useRef(null); + + // Init key pair + register public key + useEffect(() => { + getOrCreateKeyPair().then(async k => { + setKp(k); + try { await api('/tools/nachrichten/keys', { body: { public_key: JSON.stringify(k.pubJwk) } }); } + catch {} + }); + loadUsers(); + }, []); + + const loadUsers = async () => { + try { setUsers(await api('/tools/nachrichten/users')); } catch {} + }; + + // Poll messages in active conversation + useEffect(() => { + clearInterval(pollRef.current); + if (!activeUser) return; + loadMessages(activeUser.id); + pollRef.current = setInterval(() => loadMessages(activeUser.id), 15000); + return () => clearInterval(pollRef.current); + }, [activeUser?.id]); + + // Derive shared key when conversation or own key changes + useEffect(() => { + setSharedKey(null); + setDecrypted({}); + if (!kp || !activeUser?.public_key) return; + try { + deriveSharedKey(kp.privateKey, JSON.parse(activeUser.public_key)) + .then(setSharedKey) + .catch(() => {}); + } catch {} + }, [activeUser?.id, kp]); + + // Decrypt messages whenever messages or sharedKey changes + useEffect(() => { + if (!sharedKey || !messages.length) { setDecrypted({}); return; } + let cancelled = false; + (async () => { + const result = {}; + for (const m of messages) { + if (cancelled) return; + try { result[m.id] = await decryptMsg(sharedKey, m.encrypted_content, m.iv); } + catch { result[m.id] = '🔒 Nicht entschlüsselbar'; } + } + if (!cancelled) setDecrypted(result); + })(); + return () => { cancelled = true; }; + }, [messages, sharedKey]); + + // Scroll to bottom on new messages + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior:'smooth' }); + }, [messages.length]); + + const loadMessages = async (userId) => { + try { + const msgs = await api(`/tools/nachrichten/messages/${userId}`); + setMessages(msgs); + await loadUsers(); + } catch {} + }; + + const selectUser = (u) => { + setActiveUser(u); + setMessages([]); + setDecrypted({}); + }; + + const send = async () => { + if (!text.trim() || !kp || !activeUser) return; + if (!activeUser.public_key) { toast('Empfänger hat noch kein Schlüsselpaar', 'error'); return; } + if (!sharedKey) { toast('Schlüssel wird noch vorbereitet…', 'error'); return; } + setSending(true); + try { + const { encrypted_content, iv } = await encryptMsg(sharedKey, text.trim()); + await api(`/tools/nachrichten/messages/${activeUser.id}`, { body: { encrypted_content, iv } }); + setText(''); + await loadMessages(activeUser.id); + } catch (e) { toast(e.message, 'error'); } + setSending(false); + }; + + const deleteMsg = async (id) => { + try { + await api(`/tools/nachrichten/messages/${id}`, { method:'DELETE' }); + setMessages(p => p.filter(m => m.id !== id)); + } catch (e) { toast(e.message, 'error'); } + }; + + const deleteConversation = async () => { + try { + await api(`/tools/nachrichten/conversation/${activeUser.id}`, { method:'DELETE' }); + setMessages([]); + await loadUsers(); + } catch (e) { toast(e.message, 'error'); } + }; + + const showList = !mobile || !activeUser; + const showChat = !mobile || !!activeUser; + + const containerH = mobile + ? 'calc(100dvh - 56px - env(safe-area-inset-bottom, 0px))' + : '100vh'; + + return ( +
+ + {/* ── Conversation List ────────────────────────────────────────── */} + {showList && ( +
+
+
NACHRICHTEN
+
+
+ {users.length === 0 ? ( +
+ Keine anderen Benutzer +
+ ) : users.map(u => ( + + ))} +
+
+ )} + + {/* ── Chat Panel ───────────────────────────────────────────────── */} + {showChat && ( +
+ {!activeUser ? ( +
+
+
+ Benutzer auswählen +
+
+ ) : ( + <> + {/* Header */} +
+ {mobile && ( + + )} +
+ +
+
+
{activeUser.username}
+
+ 🔒 E2E VERSCHLÜSSELT +
+
+ +
+ + {/* Messages */} +
+ {!activeUser.public_key && ( +
+ ⚠ Dieser Benutzer hat das Nachrichten-Tool noch nicht geöffnet und besitzt noch kein Schlüsselpaar. +
+ )} + {messages.length === 0 && activeUser.public_key && ( +
+ Noch keine Nachrichten – schreib als Erste(r) +
+ )} + {messages.map(m => { + const isMine = !!m.is_mine; + const txt = decrypted[m.id]; + return ( +
+ {!isMine && ( +
+ +
+ )} +
+
+ {txt === undefined ? ( + + ) : ( + {txt} + )} +
+
+ + {new Date(m.created_at).toLocaleTimeString('de-DE',{ hour:'2-digit', minute:'2-digit' })} + + +
+
+
+ ); + })} +
+
+ + {/* Input */} +
+ {!activeUser.public_key ? ( +
+ Senden nicht möglich – kein Schlüssel verfügbar +
+ ) : ( +
+