import React, { useEffect, useState, useRef } from 'react'; import { User as UserIcon, Bell, Key, Trash2, Download, ArrowLeft, Check, Mail, MessageSquare, Globe, Send, AlertCircle, Palette, Upload, Pencil, Copy, } from 'lucide-react'; import { AuthStatus, User, UserTheme } from '../../lib/types'; import { ApiKeysPanel } from './ApiKeysPanel'; import { ServerBackupsCard } from './ServerBackupsCard'; import { saveBlob } from '../../lib/download'; import { api, clearToken, triggerHaptic } from '../../lib/api'; import { passwordProblem } from '../../lib/password'; import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme'; import themeTemplateCss from '../../themes/theme-template.css?raw'; import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar'; import { LegalLinks } from '../../components/LegalLinks'; import { AboutModal } from '../../components/AboutModal'; import { ImportCard } from './ImportCard'; import { SessionsPanel } from './SessionsPanel'; import { FormSheet } from '../../components/FormSheet'; interface UserSettingsViewProps { currentUser: User; activeTheme: ThemeId; themes: ThemeOption[]; // built-in + custom customThemes: UserTheme[]; onThemeChange: (themeId: ThemeId) => void; onCustomThemesChange: (themes: UserTheme[], activate?: ThemeId) => void; onUserUpdated: (u: User) => void; onBack: () => void; } type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks'; export const UserSettingsView: React.FC = ({ currentUser, activeTheme, themes, customThemes, onThemeChange, onCustomThemesChange, onUserUpdated, onBack, }) => { const [section, setSection] = useState('profile'); const [legal, setLegal] = useState(); // --- Danger zone --- const [dangerAction, setDangerAction] = useState<'data' | 'account' | null>(null); const [dangerPassword, setDangerPassword] = useState(''); const [dangerError, setDangerError] = useState(null); const [dangerBusy, setDangerBusy] = useState(false); const [dangerDone, setDangerDone] = useState(null); const [isLastAdmin, setIsLastAdmin] = useState(false); useEffect(() => { api.getMe().then((me) => setIsLastAdmin(Boolean(me.isLastAdmin))).catch(() => {}); }, []); const openDanger = (action: 'data' | 'account') => { setDangerAction(action); setDangerPassword(''); setDangerError(null); setDangerDone(null); }; const handleDangerSubmit = async (e: React.FormEvent) => { e.preventDefault(); setDangerBusy(true); setDangerError(null); try { if (dangerAction === 'account') { await api.deleteOwnAccount(dangerPassword); // The account is gone: drop the session and show the login screen clearToken(); window.location.reload(); return; } await api.purgeOwnData(dangerPassword); setDangerAction(null); setDangerDone('Alle deine Daten wurden gelöscht.'); onUserUpdated(currentUser); } catch (err: any) { setDangerError(err.message || 'Aktion fehlgeschlagen'); } finally { setDangerBusy(false); } }; const [showAbout, setShowAbout] = useState(false); // The public demo sends nothing and keeps the shared accounts intact (FLAW-33) const [demoMode, setDemoMode] = useState(false); useEffect(() => { api .getAuthStatus() .then((st) => { setLegal(st.legal); setDemoMode(Boolean(st.demoMode)); }) .catch(() => {}); }, []); // --- Custom Theme Editor State --- const [themeEditId, setThemeEditId] = useState(null); // null = new theme const [themeName, setThemeName] = useState(''); const [themeCss, setThemeCss] = useState(''); const [themeMsg, setThemeMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); const [savingTheme, setSavingTheme] = useState(false); const [deletingThemeId, setDeletingThemeId] = useState(null); const themeFileRef = useRef(null); const resetThemeEditor = () => { setThemeEditId(null); setThemeName(''); setThemeCss(''); }; const handleThemeFile = async (file?: File) => { if (!file) return; const text = await file.text(); setThemeCss(text); if (!themeName.trim()) setThemeName(file.name.replace(/\.css$/i, '').replace(/^theme-/, '')); setThemeMsg(null); }; const handleDownloadTemplate = () => { const url = URL.createObjectURL(new Blob([themeTemplateCss], { type: 'text/css' })); const a = document.createElement('a'); a.href = url; a.download = 'theme-template.css'; a.click(); URL.revokeObjectURL(url); }; const handleCopyTemplate = async () => { try { await navigator.clipboard.writeText(themeTemplateCss); setThemeMsg({ type: 'ok', text: 'Vorlage in die Zwischenablage kopiert.' }); } catch { setThemeMsg({ type: 'err', text: 'Kopieren nicht möglich – nutze „Vorlage einfügen“ oder den Download.' }); } }; // Saving activates the theme right away (injected live, no reload) const handleSaveTheme = async (e: React.FormEvent) => { e.preventDefault(); setSavingTheme(true); setThemeMsg(null); try { const saved = await api.saveTheme({ id: themeEditId || undefined, name: themeName.trim(), css: themeCss }); const next = themeEditId ? customThemes.map((t) => (t.id === saved.id ? saved : t)) : [...customThemes, saved]; onCustomThemesChange(next, customThemeId(saved)); setThemeMsg({ type: 'ok', text: `Theme „${saved.name}“ gespeichert und aktiviert.` }); resetThemeEditor(); } catch (err: any) { setThemeMsg({ type: 'err', text: err.message || 'Theme konnte nicht gespeichert werden' }); } finally { setSavingTheme(false); } }; const handleEditTheme = (t: UserTheme) => { setThemeEditId(t.id); setThemeName(t.name); setThemeCss(t.css); setThemeMsg(null); }; const handleDeleteTheme = async (t: UserTheme) => { try { await api.deleteTheme(t.id); setDeletingThemeId(null); if (themeEditId === t.id) resetThemeEditor(); // Deleting the active theme falls back to the default one onCustomThemesChange( customThemes.filter((x) => x.id !== t.id), activeTheme === customThemeId(t) ? 'dark' : undefined ); } catch (err: any) { setThemeMsg({ type: 'err', text: err.message || 'Theme konnte nicht gelöscht werden' }); } }; // --- Profile State --- const [displayName, setDisplayName] = useState(currentUser.displayName || ''); const [email, setEmail] = useState(currentUser.email || ''); const [avatarUrl, setAvatarUrl] = useState(currentUser.avatarUrl || ''); const [newPassword, setNewPassword] = useState(''); const [currentPassword, setCurrentPassword] = useState(''); const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); const [savingProfile, setSavingProfile] = useState(false); const [revokingSessions, setRevokingSessions] = useState(false); const [isProcessingAvatar, setIsProcessingAvatar] = useState(false); const fileInputRef = useRef(null); // --- Multi-Channel Alarms State --- const [alarmChannels, setAlarmChannels] = useState( Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : [] ); const [alarmEmail, setAlarmEmail] = useState(currentUser.alarmEmail || currentUser.email || ''); const [discordWebhookUrl, setDiscordWebhookUrl] = useState(currentUser.discordWebhookUrl || ''); const [customWebhookUrl, setCustomWebhookUrl] = useState(currentUser.customWebhookUrl || ''); const [notifyBeforeMinutes, setNotifyBeforeMinutes] = useState( currentUser.notifyBeforeMinutes ?? 30 ); const [alarmMsg, setAlarmMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); const [savingAlarms, setSavingAlarms] = useState(false); const [testingChannel, setTestingChannel] = useState(null); useEffect(() => { setDisplayName(currentUser.displayName || ''); setEmail(currentUser.email || ''); setAvatarUrl(currentUser.avatarUrl || ''); setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []); setAlarmEmail(currentUser.alarmEmail || currentUser.email || ''); setDiscordWebhookUrl(currentUser.discordWebhookUrl || ''); setCustomWebhookUrl(currentUser.customWebhookUrl || ''); setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30); }, [currentUser]); const toggleAlarmChannel = (ch: string) => { setAlarmChannels((prev) => prev.includes(ch) ? prev.filter((item) => item !== ch) : [...prev, ch] ); }; // --- Handlers: Avatar --- const handleAvatarFileSelected = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; setIsProcessingAvatar(true); setProfileMsg(null); try { const dataUrl = await resizeImageToDataUrl(file, 256); setAvatarUrl(dataUrl); triggerHaptic(10); } catch (err: any) { setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Laden des Bildes' }); } finally { setIsProcessingAvatar(false); if (fileInputRef.current) fileInputRef.current.value = ''; } }; const handleSelectPresetAvatar = (preset: string) => { setAvatarUrl(preset); triggerHaptic(10); }; const handleRemoveAvatar = () => { setAvatarUrl(''); triggerHaptic(10); }; // Changing the password or the login address needs the current password (FLAW-30) const needsCurrentPassword = newPassword.trim() !== '' || email.trim().toLowerCase() !== (currentUser.email || '').toLowerCase(); // --- Handlers: Profile --- const handleSaveProfile = async (e: React.FormEvent) => { e.preventDefault(); setProfileMsg(null); setSavingProfile(true); try { const patch: any = { displayName: displayName.trim(), email: email.trim(), avatarUrl: avatarUrl.trim(), }; if (newPassword.trim()) { const problem = passwordProblem(newPassword); if (problem) { setProfileMsg({ type: 'err', text: problem }); return; } // Not trimmed: login compares the password exactly as typed patch.password = newPassword; } if (needsCurrentPassword) patch.currentPassword = currentPassword; const updated = await api.updateSettings(patch); onUserUpdated(updated); setNewPassword(''); setCurrentPassword(''); setProfileMsg({ type: 'ok', text: 'Profil und Anmeldedaten erfolgreich gespeichert.' }); } catch (err: any) { setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' }); } finally { setSavingProfile(false); } }; const handleRevokeAllSessions = async () => { if (!window.confirm('Möchtest du wirklich alle aktiven Sitzungen auf allen Geräten beenden? Du wirst dabei auch auf diesem Gerät abgemeldet.')) { return; } setRevokingSessions(true); try { await api.revokeAllSessions(); await api.logout(); window.location.reload(); } catch (err: any) { setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Beenden der Sitzungen' }); setRevokingSessions(false); } }; // --- Handlers: Alarms --- const handleSaveAlarms = async (e: React.FormEvent) => { e.preventDefault(); setAlarmMsg(null); setSavingAlarms(true); try { const updated = await api.updateSettings({ alarmChannels, alarmEmail: alarmEmail.trim(), discordWebhookUrl: discordWebhookUrl.trim(), customWebhookUrl: customWebhookUrl.trim(), notifyBeforeMinutes: Number(notifyBeforeMinutes), }); onUserUpdated(updated); setAlarmMsg({ type: 'ok', text: 'Alarm-Kanäle und Vorwarnzeit erfolgreich gespeichert.' }); } catch (err: any) { setAlarmMsg({ type: 'err', text: err.message || 'Fehler beim Speichern der Alarme' }); } finally { setSavingAlarms(false); } }; const handleTestChannel = async (channel: 'email' | 'discord' | 'webhook') => { setAlarmMsg(null); let target = ''; if (channel === 'email') { target = (alarmEmail || currentUser.email || '').trim(); if (!target) { setAlarmMsg({ type: 'err', text: 'Bitte gib zuerst eine Empfänger-E-Mail-Adresse an.' }); return; } } else if (channel === 'discord') { target = discordWebhookUrl.trim(); if (!target) { setAlarmMsg({ type: 'err', text: 'Bitte gib zuerst eine Discord Webhook URL ein.' }); return; } } else if (channel === 'webhook') { target = customWebhookUrl.trim(); if (!target) { setAlarmMsg({ type: 'err', text: 'Bitte gib zuerst eine eigene Webhook URL ein.' }); return; } } setTestingChannel(channel); try { await api.testAlarmChannel(channel, target); const label = channel === 'email' ? 'E-Mail' : channel === 'discord' ? 'Discord' : 'Eigener Webhook'; setAlarmMsg({ type: 'ok', text: `Test-Alarm über "${label}" wurde erfolgreich vom Server versendet!`, }); } catch (err: any) { setAlarmMsg({ type: 'err', text: `Test fehlgeschlagen: ${err.message || 'Unbekannter Fehler'}`, }); } finally { setTestingChannel(null); } }; // --- Handlers: Export --- // Works on mobile browsers too: the link must be in the document and the URL alive until the click ran const [exportingBackup, setExportingBackup] = useState(false); const handleExportData = async () => { setExportingBackup(true); try { saveBlob(await api.exportBackupZip(), `mindsift-backup-${new Date().toISOString().slice(0, 10)}.zip`); } catch (err: any) { setVaultError(err.message || 'Backup fehlgeschlagen'); } finally { setExportingBackup(false); } }; const [exportingVault, setExportingVault] = useState(false); const [vaultError, setVaultError] = useState(null); const handleExportVault = async () => { setExportingVault(true); setVaultError(null); try { saveBlob(await api.exportMarkdownVault(), `mindsift-vault-${new Date().toISOString().slice(0, 10)}.zip`); } catch (err: any) { setVaultError(err.message || 'Export fehlgeschlagen'); } finally { setExportingVault(false); } }; const roleBadgeLabel = currentUser.role === 'admin' ? 'Server-Admin' : 'Mitglied'; return (
{/* Top Page Header */}

Kontoeinstellungen

Persönliches Profil, Design-Themes, Aufgaben-Alarme, Private Webhooks & Daten-Backup

Rolle: {roleBadgeLabel}
{/* Main 2-Column Layout */}
{/* Left Navigation Sidebar */} {/* Right Content Area */}
{/* ================================================================= */} {/* SECTION 1: PROFIL & SICHERHEIT */} {/* ================================================================= */} {section === 'profile' && (

Profil & Anmeldedaten

Verwalte deinen Anzeigenamen, deine E-Mail-Adresse und dein persönliches Passwort.

{profileMsg && (
{profileMsg.type === 'ok' ? ( ) : ( )} {profileMsg.text}
)}
{/* Avatar Management */}
{isProcessingAvatar && (
Lädt…
)}
{avatarUrl && ( )}
{/* Emoji Presets */}
Oder schnelles Emoji wählen:
{['🦊', '🐱', '🐶', '🐼', '🦁', '🦉', '🚀', '☕', '⚡', '🎨', '🌟', '🎯', '💡'].map( (preset) => ( ) )}

Wird überall kreisförmig dargestellt (z. B. Menüleiste oben, Freigabelisten, Aufgaben).

setDisplayName(e.target.value)} required className="w-full h-9 px-3 input-field" />
setEmail(e.target.value)} required disabled={demoMode} title={demoMode ? 'In der Demo deaktiviert' : undefined} className="w-full h-9 px-3 input-field disabled:opacity-60" />
setNewPassword(e.target.value)} placeholder={demoMode ? 'In der Demo deaktiviert' : 'Mindestens 8 Zeichen'} autoComplete="new-password" disabled={demoMode} className="w-full h-9 px-3 input-field disabled:opacity-60" />
{needsCurrentPassword && (
setCurrentPassword(e.target.value)} required autoComplete="current-password" className="w-full h-9 px-3 input-field" />
)}
{/* Sessions & Security */}

Aktive Sitzungen & Sicherheit

Geräte, auf denen du angemeldet bist. Unbenutzte Sitzungen enden nach 30 Tagen; eine Passwortänderung meldet alle anderen Geräte ab.

Alle Sitzungen sofort beenden

Invalidiert alle ausgestellten Tokens auf allen Geräten und Browsern.

{/* Danger zone: delete own data or the whole account (password required) */}

Gefahrenzone

Alle eigenen Daten löschen

Löscht deine Notizen, Ordner, Aufgaben, Gedanken und Listen. Konto, Passwort und Einstellungen bleiben. Mit dir geteilte Inhalte anderer bleiben erhalten.

Konto unwiderruflich löschen

{isLastAdmin ? 'Der letzte Server-Administrator kann nicht gelöscht werden. Bitte ernenne vorher ein anderes Konto zum Administrator.' : 'Löscht dein Konto mit allen Daten. Du wirst danach abgemeldet.'}

{dangerDone &&

{dangerDone}

}
)} {/* ================================================================= */} {/* SECTION 2: DARSTELLUNG & THEMES (SINGLE-FILE CSS THEMING) */} {/* ================================================================= */} {section === 'appearance' && (

Darstellung & Single-File CSS Themes

Jedes Theme ist eine einzige, eigenständige CSS-Datei, in der alle Farben, Oberflächen, Kontraste und Ecken-Rundungen in 6 festen Gruppen gebündelt sind.

{themes.map((t) => { const isSelected = activeTheme === t.id; const custom = customThemes.find((c) => customThemeId(c) === t.id); return (
{custom && (
{deletingThemeId === custom.id ? ( <> Theme wirklich löschen? ) : ( <> )}
)}
); })}
{/* Custom theme: upload or paste CSS, based on the template */}

{themeEditId ? 'Eigenes Theme bearbeiten' : 'Eigenes Theme hinzufügen'}

Ein Theme ist CSS mit 6 Blöcken: 1. Akzentfarbe, 2. Oberflächen,{' '} 3. Textfarben, 4. Rahmen & Schatten, 5. Statusfarben und{' '} 6. Rundungen. Am einfachsten startest du mit der Vorlage und änderst die Werte. Gespeicherte Themes liegen auf dem Server und stehen dir auf allen Geräten zur Verfügung.

setThemeName(e.target.value)} placeholder="Name, z. B. Wald" maxLength={60} className="flex-1 min-w-0 h-8 px-2.5 input-field" /> { handleThemeFile(e.target.files?.[0]); e.target.value = ''; }} />