Dateien nach "frontend/src" hochladen
This commit is contained in:
@@ -5,8 +5,26 @@ import CalendarWidget, { CalendarSettings } from './calendar.jsx';
|
||||
import { DateiSettings } from './tools/dateien.jsx';
|
||||
import { useConfirm } from './confirm.jsx';
|
||||
import { HomeIcon, AppsIcon, AdminIcon, MoreIcon, UserIcon, LogOutIcon, ChevronIcon, UpdateIcon, TrashIcon, MessageIcon } from './icons.jsx';
|
||||
import { getOrCreateKeyPair, getPublicKeyJwk, getFingerprint, exportEncrypted, importEncrypted, generateNewKeyPair } from './crypto.js';
|
||||
|
||||
const ICONS = ['🔗','🌐','📊','📁','🛠','📧','🗓','💾','🖥','📡','🔒','📖','🎯','⚡','🏠','🔧','📱','🎮','🚀','💡'];
|
||||
const CONST_ICONS = ['🔗','🌐','📊','📁','🛠','📧','🗓','💾','🖥','📡','🔒','📖','🎯','⚡','🏠','🔧','📱','🎮','🚀','💡'];
|
||||
|
||||
// Schlüsselpaar beim Login generieren und public key registrieren (fire & forget)
|
||||
async function initNachrichtenKeys() {
|
||||
const LS_KEY = 'dd_msg_keypair';
|
||||
let pubJwk;
|
||||
const stored = localStorage.getItem(LS_KEY);
|
||||
if (stored) {
|
||||
try { pubJwk = JSON.parse(stored).pub; } catch { localStorage.removeItem(LS_KEY); }
|
||||
}
|
||||
if (!pubJwk) {
|
||||
const kp = await crypto.subtle.generateKey({ name:'ECDH', namedCurve:'P-256' }, true, ['deriveKey']);
|
||||
pubJwk = await crypto.subtle.exportKey('jwk', kp.publicKey);
|
||||
const priv = await crypto.subtle.exportKey('jwk', kp.privateKey);
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({ pub: pubJwk, priv }));
|
||||
}
|
||||
try { await api('/tools/nachrichten/keys', { body: { public_key: JSON.stringify(pubJwk) } }); } catch {}
|
||||
}
|
||||
|
||||
// ── Responsive Hook ───────────────────────────────────────────────────────────
|
||||
function useIsMobile() {
|
||||
@@ -93,7 +111,7 @@ function Login({ onLogin }) {
|
||||
const [err,setErr]=useState(''); const [busy,setBusy]=useState(false);
|
||||
const go = async () => {
|
||||
setErr(''); setBusy(true);
|
||||
try { const d=await api('/auth/login',{body:{username:u,password:p}}); localStorage.setItem('sk_token',d.token); onLogin(d.user); }
|
||||
try { const d=await api('/auth/login',{body:{username:u,password:p}}); localStorage.setItem('sk_token',d.token); initNachrichtenKeys(); onLogin(d.user); }
|
||||
catch(e) { setErr(e.message); } finally { setBusy(false); }
|
||||
};
|
||||
return (
|
||||
@@ -496,7 +514,7 @@ function QuickLinks({ toast, mobile }) {
|
||||
<div style={{ marginBottom:10 }}>
|
||||
<div style={{ ...S.head, marginBottom:6 }}>ICON</div>
|
||||
<div style={{ display:'flex', flexWrap:'wrap', gap:5 }}>
|
||||
{ICONS.map(ic => (
|
||||
{CONST_ICONS.map(ic => (
|
||||
<button key={ic} onClick={() => setF(f => ({...f, icon:ic}))} style={{
|
||||
width:36, height:36, borderRadius:7, fontSize:18, cursor:'pointer',
|
||||
border:`1px solid ${form.icon===ic ? '#4ecdc4' : 'rgba(255,255,255,0.1)'}`,
|
||||
@@ -1095,6 +1113,184 @@ function UserManagement({ toast }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sicherheit / Key-Management ───────────────────────────────────────────────
|
||||
function SecuritySettings({ toast }) {
|
||||
const [kp, setKp] = useState(null);
|
||||
const [fingerprint, setFp] = useState('');
|
||||
const [expPw, setExpPw] = useState('');
|
||||
const [expPw2, setExpPw2] = useState('');
|
||||
const [showExport, setShowExport] = useState(false);
|
||||
const [impFile, setImpFile] = useState(null);
|
||||
const [impPw, setImpPw] = useState('');
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getOrCreateKeyPair()
|
||||
.then(async k => {
|
||||
setKp(k);
|
||||
setFp(await getFingerprint(k));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const doExport = async () => {
|
||||
if (!kp) return;
|
||||
if (expPw.length < 8) { toast('Passwort mind. 8 Zeichen', 'error'); return; }
|
||||
if (expPw !== expPw2) { toast('Passwörter stimmen nicht überein', 'error'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
const json = await exportEncrypted(kp, expPw);
|
||||
const a = Object.assign(document.createElement('a'), {
|
||||
href: URL.createObjectURL(new Blob([json], { type:'application/json' })),
|
||||
download: 'dickendock-key.json',
|
||||
});
|
||||
a.click(); URL.revokeObjectURL(a.href);
|
||||
setExpPw(''); setExpPw2(''); setShowExport(false);
|
||||
toast('Schlüssel exportiert ✓');
|
||||
} catch { toast('Export fehlgeschlagen', 'error'); }
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const doImport = async () => {
|
||||
if (!impFile || !impPw) { toast('Datei und Passwort erforderlich', 'error'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
const text = await impFile.text();
|
||||
const newKp = await importEncrypted(text, impPw);
|
||||
const jwk = await getPublicKeyJwk(newKp);
|
||||
await api('/tools/nachrichten/keys', { body: { public_key: JSON.stringify(jwk) } });
|
||||
setKp(newKp);
|
||||
setFp(await getFingerprint(newKp));
|
||||
setImpFile(null); setImpPw(''); setShowImport(false);
|
||||
toast('Schlüssel importiert ✓');
|
||||
} catch(e) { toast(e.message, 'error'); }
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const doReset = async () => {
|
||||
if (!window.confirm(
|
||||
'Neues Schlüsselpaar erzeugen?\n\n' +
|
||||
'Alle bisherigen Nachrichten werden vom Server gelöscht und können nicht mehr gelesen werden. ' +
|
||||
'Geräte mit dem alten Schlüssel können keine neuen Nachrichten mehr lesen.'
|
||||
)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const newKp = await generateNewKeyPair();
|
||||
const jwk = await getPublicKeyJwk(newKp);
|
||||
await api('/tools/nachrichten/keys', { body: { public_key: JSON.stringify(jwk) } });
|
||||
await api('/tools/nachrichten/my-messages', { method:'DELETE' });
|
||||
toast('Neues Schlüsselpaar aktiv – App wird neu geladen…');
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
} catch(e) { toast(e.message, 'error'); setBusy(false); }
|
||||
};
|
||||
|
||||
const FpChar = ({ c }) => (
|
||||
<span style={{ background:'rgba(255,255,255,0.06)', border:'1px solid rgba(255,255,255,0.09)',
|
||||
borderRadius:5, padding:'5px 8px', fontFamily:'monospace', fontSize:14, letterSpacing:2,
|
||||
color: c === '-' ? 'rgba(255,255,255,0.2)' : '#4ecdc4' }}>{c}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Fingerprint */}
|
||||
<Sec title="SCHLÜSSEL-FINGERPRINT">
|
||||
<p style={{ color:'rgba(255,255,255,0.42)', fontSize:11, fontFamily:'monospace',
|
||||
marginBottom:12, lineHeight:1.7 }}>
|
||||
Vergleiche diesen Code mit deinem Gesprächspartner um sicherzustellen, dass kein Man-in-the-Middle vorliegt.
|
||||
</p>
|
||||
{fingerprint ? (
|
||||
<div style={{ display:'flex', gap:5, flexWrap:'wrap', marginBottom:8 }}>
|
||||
{fingerprint.split('').map((c, i) => <FpChar key={i} c={c}/>)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:11 }}>Wird geladen…</div>
|
||||
)}
|
||||
<div style={{ color:'rgba(255,255,255,0.25)', fontSize:9, fontFamily:'monospace', marginTop:6 }}>
|
||||
SHA-256(PublicKey) · X25519
|
||||
</div>
|
||||
</Sec>
|
||||
|
||||
{/* Export */}
|
||||
<Sec title="SCHLÜSSEL EXPORTIEREN">
|
||||
<p style={{ color:'rgba(255,255,255,0.42)', fontSize:11, fontFamily:'monospace',
|
||||
marginBottom:12, lineHeight:1.7 }}>
|
||||
Exportiere deinen privaten Schlüssel verschlüsselt als Datei um ihn auf einem anderen Gerät zu nutzen.
|
||||
</p>
|
||||
{!showExport ? (
|
||||
<button onClick={() => setShowExport(true)} disabled={!kp}
|
||||
style={{ ...S.btn('#4ecdc4'), width:'100%', textAlign:'center', padding:'10px 0' }}>
|
||||
↓ Schlüssel exportieren
|
||||
</button>
|
||||
) : (
|
||||
<div>
|
||||
<Field label="EXPORT-PASSWORT (mind. 8 Zeichen)" type="password"
|
||||
value={expPw} onChange={e => setExpPw(e.target.value)}/>
|
||||
<Field label="PASSWORT BESTÄTIGEN" type="password"
|
||||
value={expPw2} onChange={e => setExpPw2(e.target.value)}/>
|
||||
<div style={{ display:'flex', gap:8 }}>
|
||||
<button onClick={doExport} disabled={busy}
|
||||
style={{ ...S.btn('#4ecdc4'), flex:1, textAlign:'center', padding:'10px 0', opacity:busy?0.5:1 }}>
|
||||
{busy ? '…' : '↓ Herunterladen'}
|
||||
</button>
|
||||
<button onClick={() => { setShowExport(false); setExpPw(''); setExpPw2(''); }}
|
||||
style={{ ...S.btn('#ff6b9d', true), padding:'0 14px' }}>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Sec>
|
||||
|
||||
{/* Import */}
|
||||
<Sec title="SCHLÜSSEL IMPORTIEREN">
|
||||
<p style={{ color:'rgba(255,255,255,0.42)', fontSize:11, fontFamily:'monospace',
|
||||
marginBottom:12, lineHeight:1.7 }}>
|
||||
Importiere eine zuvor exportierte Schlüsseldatei. Der aktuelle Schlüssel wird ersetzt.
|
||||
</p>
|
||||
{!showImport ? (
|
||||
<button onClick={() => setShowImport(true)}
|
||||
style={{ ...S.btn('#ffe66d'), width:'100%', textAlign:'center', padding:'10px 0' }}>
|
||||
↑ Schlüssel importieren
|
||||
</button>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ marginBottom:12 }}>
|
||||
<label style={{ ...S.head, display:'block', marginBottom:4 }}>SCHLÜSSELDATEI (.json)</label>
|
||||
<input type="file" accept=".json"
|
||||
onChange={e => setImpFile(e.target.files?.[0] || null)}
|
||||
style={{ ...S.inp, fontSize:13, padding:'9px 10px', cursor:'pointer' }}/>
|
||||
</div>
|
||||
<Field label="EXPORT-PASSWORT" type="password"
|
||||
value={impPw} onChange={e => setImpPw(e.target.value)}/>
|
||||
<div style={{ display:'flex', gap:8 }}>
|
||||
<button onClick={doImport} disabled={busy || !impFile || !impPw}
|
||||
style={{ ...S.btn('#ffe66d'), flex:1, textAlign:'center', padding:'10px 0',
|
||||
opacity: busy || !impFile || !impPw ? 0.4 : 1 }}>
|
||||
{busy ? '…' : '✓ Importieren'}
|
||||
</button>
|
||||
<button onClick={() => { setShowImport(false); setImpFile(null); setImpPw(''); }}
|
||||
style={{ ...S.btn('#ff6b9d', true), padding:'0 14px' }}>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Sec>
|
||||
|
||||
{/* Reset – Danger Zone */}
|
||||
<Sec title="SCHLÜSSEL ZURÜCKSETZEN">
|
||||
<p style={{ color:'rgba(255,107,157,0.6)', fontSize:11, fontFamily:'monospace',
|
||||
marginBottom:12, lineHeight:1.7 }}>
|
||||
⚠ Gefahrenzone: Erzeugt ein neues Schlüsselpaar und löscht alle deine Nachrichten vom Server.
|
||||
Alte Nachrichten sind danach nicht mehr lesbar. Geräte mit dem alten Schlüssel werden ausgesperrt.
|
||||
</p>
|
||||
<button onClick={doReset} disabled={busy}
|
||||
style={{ ...S.btn('#ff6b9d'), width:'100%', textAlign:'center', padding:'10px 0',
|
||||
opacity: busy ? 0.5 : 1 }}>
|
||||
{busy ? '…' : '✕ Neues Schlüsselpaar erzeugen'}
|
||||
</button>
|
||||
</Sec>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Field außerhalb definiert → Tastatur bleibt beim Tippen erhalten
|
||||
const Field = ({ label, type='text', value, onChange, placeholder='', autoCapitalize='off' }) => (
|
||||
<div style={{ marginBottom:12 }}>
|
||||
@@ -1160,7 +1356,7 @@ function AdminPanel({ toast, mobile, user }) {
|
||||
<h1 style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:mobile?17:22, marginBottom:16 }}>Einstellungen</h1>
|
||||
|
||||
<div style={{ display:'flex', gap:6, marginBottom:18, flexWrap:'wrap' }}>
|
||||
{[['profil','Profil'],['dashboard','Dashboard'], ...(user?.role==='admin'?[['benutzer','Benutzer'],['backup','Backup']]:[])]
|
||||
{[['profil','Profil'],['sicherheit','Sicherheit'],['dashboard','Dashboard'], ...(user?.role==='admin'?[['benutzer','Benutzer'],['backup','Backup']]:[])]
|
||||
.map(([k,l]) => (
|
||||
<button key={k} onClick={()=>setSection(k)} style={{
|
||||
padding:'6px 16px', borderRadius:20, fontFamily:'monospace', fontSize:12, cursor:'pointer',
|
||||
@@ -1234,6 +1430,10 @@ function AdminPanel({ toast, mobile, user }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === 'sicherheit' && (
|
||||
<SecuritySettings toast={toast}/>
|
||||
)}
|
||||
|
||||
{section === 'benutzer' && user?.role==='admin' && (
|
||||
<div><UserManagement toast={toast}/></div>
|
||||
)}
|
||||
@@ -1313,6 +1513,17 @@ export default function App() {
|
||||
return()=>clearInterval(t);
|
||||
},[user]);
|
||||
|
||||
// Schlüssel sofort nach Login initialisieren und Public Key registrieren
|
||||
useEffect(()=>{
|
||||
if(!user)return;
|
||||
getOrCreateKeyPair().then(async kp=>{
|
||||
try{
|
||||
const jwk=await getPublicKeyJwk(kp);
|
||||
await api('/tools/nachrichten/keys',{body:{public_key:JSON.stringify(jwk)}});
|
||||
}catch{}
|
||||
}).catch(()=>{});
|
||||
},[user]);
|
||||
|
||||
useEffect(()=>{
|
||||
if(!user)return;
|
||||
const poll=()=>api('/tools/nachrichten/unread').then(d=>setUnreadMsgs(d.count||0)).catch(()=>{});
|
||||
|
||||
Reference in New Issue
Block a user