Files
MindSift/frontend/src/features/settings/UserSettingsView.tsx
T
sandrodachandClaude Opus 5.5 1af3a6e90a
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s
feat(auth): server-side sessions and current password for account changes (FLAW-30, FLAW-31)
- Every login is a session (device, IP, last use); the token's jti is its ID.
  Sessions end on logout, per device from the profile, or after 30 idle days.
- Tokens older than a day are renewed via the X-Session-Token header.
- Changing password or login e-mail needs the current password; a new
  password logs out all other devices and mails the old address.
- Demo reset keeps the demo visitors' sessions.
- revoked_tokens is replaced by sessions (migration 4); everyone is logged
  out once after the upgrade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 19:25:22 +01:00

1311 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<UserSettingsViewProps> = ({
currentUser,
activeTheme,
themes,
customThemes,
onThemeChange,
onCustomThemesChange,
onUserUpdated,
onBack,
}) => {
const [section, setSection] = useState<UserSettingsSection>('profile');
const [legal, setLegal] = useState<AuthStatus['legal']>();
// --- Danger zone ---
const [dangerAction, setDangerAction] = useState<'data' | 'account' | null>(null);
const [dangerPassword, setDangerPassword] = useState('');
const [dangerError, setDangerError] = useState<string | null>(null);
const [dangerBusy, setDangerBusy] = useState(false);
const [dangerDone, setDangerDone] = useState<string | null>(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);
useEffect(() => {
api.getAuthStatus().then((st) => setLegal(st.legal)).catch(() => {});
}, []);
// --- Custom Theme Editor State ---
const [themeEditId, setThemeEditId] = useState<string | null>(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<string | null>(null);
const themeFileRef = useRef<HTMLInputElement>(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<HTMLInputElement>(null);
// --- Multi-Channel Alarms State ---
const [alarmChannels, setAlarmChannels] = useState<string[]>(
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<number>(
currentUser.notifyBeforeMinutes ?? 30
);
const [alarmMsg, setAlarmMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [savingAlarms, setSavingAlarms] = useState(false);
const [testingChannel, setTestingChannel] = useState<string | null>(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<HTMLInputElement>) => {
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<string | null>(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 (
<div className="w-full space-y-4">
{/* Top Page Header */}
<div className="flex items-center justify-between border-b border-border pb-3">
<div className="flex items-center gap-3">
<button
onClick={onBack}
className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5"
title="Zurück zur Übersicht"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Zurück</span>
</button>
<div>
<h1 className="text-base font-bold text-main tracking-tight">Kontoeinstellungen</h1>
<p className="text-xs text-muted">
Persönliches Profil, Design-Themes, Aufgaben-Alarme, Private Webhooks & Daten-Backup
</p>
</div>
</div>
<span className="px-2.5 py-1 bg-panel border border-border text-xs text-muted font-medium">
Rolle: <strong className="text-accent">{roleBadgeLabel}</strong>
</span>
</div>
{/* Main 2-Column Layout */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 items-start">
{/* Left Navigation Sidebar */}
<aside className="panel-surface divide-y divide-border overflow-hidden">
<button
onClick={() => setSection('profile')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'profile'
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
: 'text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<UserIcon className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Profil & Sicherheit</p>
<p className="text-[10px] text-faint">Name, E-Mail & Passwort</p>
</div>
</button>
<button
onClick={() => setSection('appearance')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'appearance'
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
: 'text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<Palette className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Darstellung & Themes</p>
<p className="text-[10px] text-faint">Farben, Kontraste & Rundungen</p>
</div>
</button>
<button
onClick={() => setSection('alarms')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'alarms'
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
: 'text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<Bell className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Alarme</p>
<p className="text-[10px] text-faint">E-Mail, Discord & Webhooks</p>
</div>
</button>
<button
onClick={() => setSection('webhooks')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'webhooks'
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
: 'text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<Key className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Private Webhooks & Export</p>
<p className="text-[10px] text-faint">API-Keys, Curl & JSON-Backup</p>
</div>
</button>
</aside>
{/* Right Content Area */}
<div className="md:col-span-3 panel-surface p-5 text-xs">
{/* ================================================================= */}
{/* SECTION 1: PROFIL & SICHERHEIT */}
{/* ================================================================= */}
{section === 'profile' && (
<div className="space-y-5 max-w-2xl">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">Profil & Anmeldedaten</h2>
<p className="text-muted mt-0.5">
Verwalte deinen Anzeigenamen, deine E-Mail-Adresse und dein persönliches Passwort.
</p>
</div>
{profileMsg && (
<div
className={`flex items-center gap-2 ${
profileMsg.type === 'ok' ? 'alert-ok' : 'alert-err'
}`}
>
{profileMsg.type === 'ok' ? (
<Check className="w-4 h-4 text-status-ok flex-shrink-0" />
) : (
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
)}
<span>{profileMsg.text}</span>
</div>
)}
<form onSubmit={handleSaveProfile} className="space-y-5">
{/* Avatar Management */}
<div className="p-3.5 bg-panel-elevated/40 border border-border rounded-box space-y-3">
<label className="block text-muted font-medium">Profilbild</label>
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-4">
<div className="relative group shrink-0">
<UserAvatar
user={{ displayName, email, avatarUrl }}
size="xl"
className="border-2 border-border/80 shadow-md ring-2 ring-primary/20"
/>
{isProcessingAvatar && (
<div className="absolute inset-0 bg-base-100/70 rounded-full flex items-center justify-center text-[10px] font-medium text-main">
Lädt…
</div>
)}
</div>
<div className="flex-1 space-y-2.5 text-center sm:text-left">
<div className="flex flex-wrap items-center justify-center sm:justify-start gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleAvatarFileSelected}
className="hidden"
id="avatar-upload-input"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isProcessingAvatar}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5"
>
<Upload className="w-3.5 h-3.5" />
<span>Bild hochladen</span>
</button>
{avatarUrl && (
<button
type="button"
onClick={handleRemoveAvatar}
className="btn-ghost h-8 px-2.5 text-xs text-status-err hover:bg-status-err/10 flex items-center gap-1"
title="Profilbild entfernen"
>
<Trash2 className="w-3.5 h-3.5" />
<span>Entfernen</span>
</button>
)}
</div>
{/* Emoji Presets */}
<div className="space-y-1">
<span className="text-[11px] text-muted">Oder schnelles Emoji wählen:</span>
<div className="flex flex-wrap items-center justify-center sm:justify-start gap-1.5 pt-0.5">
{['🦊', '🐱', '🐶', '🐼', '🦁', '🦉', '🚀', '☕', '⚡', '🎨', '🌟', '🎯', '💡'].map(
(preset) => (
<button
key={preset}
type="button"
onClick={() => handleSelectPresetAvatar(preset)}
className={`w-7 h-7 rounded-full text-sm flex items-center justify-center transition-transform hover:scale-110 active:scale-95 ${
avatarUrl === preset
? 'bg-primary/20 border-2 border-primary scale-105'
: 'bg-panel hover:bg-panel-hover border border-border'
}`}
title={`Emoji ${preset}`}
>
{preset}
</button>
)
)}
</div>
</div>
<p className="text-[10px] text-faint">
Wird überall kreisförmig dargestellt (z.&nbsp;B. Menüleiste oben, Freigabelisten, Aufgaben).
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-muted font-medium mb-1">Anzeigename</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
required
className="w-full h-9 px-3 input-field"
/>
</div>
<div>
<label className="block text-muted font-medium mb-1">
E-Mail-Adresse (Login)
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full h-9 px-3 input-field"
/>
</div>
</div>
<div>
<label className="block text-muted font-medium mb-1">
Neues Passwort (optional — leer lassen für keine Änderung)
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Mindestens 8 Zeichen"
autoComplete="new-password"
className="w-full h-9 px-3 input-field"
/>
</div>
{needsCurrentPassword && (
<div>
<label className="block text-muted font-medium mb-1">
Aktuelles Passwort (zur Bestätigung)
</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
autoComplete="current-password"
className="w-full h-9 px-3 input-field"
/>
</div>
)}
<div className="pt-2 border-t border-border flex justify-end">
<button
type="submit"
disabled={savingProfile}
className="btn-primary h-9 px-5 text-xs font-semibold"
>
{savingProfile ? 'Speichere…' : 'Profil speichern'}
</button>
</div>
</form>
{/* Sessions & Security */}
<div className="pt-5 border-t border-border space-y-3">
<div>
<h3 className="text-xs font-bold text-main">Aktive Sitzungen & Sicherheit</h3>
<p className="text-[11px] text-muted">
Geräte, auf denen du angemeldet bist. Unbenutzte Sitzungen enden nach 30 Tagen; eine Passwortänderung meldet alle anderen Geräte ab.
</p>
</div>
<SessionsPanel />
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3.5 rounded-card border border-border bg-canvas">
<div className="text-xs">
<p className="font-semibold text-main">Alle Sitzungen sofort beenden</p>
<p className="text-[11px] text-faint">
Invalidiert alle ausgestellten Tokens auf allen Geräten und Browsern.
</p>
</div>
<button
type="button"
onClick={handleRevokeAllSessions}
disabled={revokingSessions}
className="btn-danger h-8 px-3 text-xs shrink-0 cursor-pointer"
>
{revokingSessions ? 'Beende…' : 'Alle Sitzungen beenden'}
</button>
</div>
</div>
{/* Danger zone: delete own data or the whole account (password required) */}
<div className="p-4 border border-status-err/40 bg-status-err/5 rounded-card space-y-3">
<h3 className="font-bold text-status-err flex items-center gap-1.5">
<AlertCircle className="w-4 h-4" />
<span>Gefahrenzone</span>
</h3>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<p className="text-main font-medium">Alle eigenen Daten löschen</p>
<p className="text-[11px] text-muted">
Löscht deine Notizen, Ordner, Aufgaben, Gedanken und Listen. Konto, Passwort und Einstellungen bleiben.
Mit dir geteilte Inhalte anderer bleiben erhalten.
</p>
</div>
<button
type="button"
onClick={() => openDanger('data')}
className="btn-danger h-11 sm:h-8 px-3 text-xs shrink-0"
>
Daten löschen
</button>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-3 border-t border-status-err/20">
<div>
<p className="text-main font-medium">Konto unwiderruflich löschen</p>
<p className="text-[11px] text-muted">
{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.'}
</p>
</div>
<button
type="button"
onClick={() => openDanger('account')}
disabled={isLastAdmin}
className="btn-danger h-11 sm:h-8 px-3 text-xs shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
>
Konto löschen
</button>
</div>
{dangerDone && <p className="text-[11px] text-status-ok">{dangerDone}</p>}
</div>
</div>
)}
{/* ================================================================= */}
{/* SECTION 2: DARSTELLUNG & THEMES (SINGLE-FILE CSS THEMING) */}
{/* ================================================================= */}
{section === 'appearance' && (
<div className="space-y-5">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">
Darstellung & Single-File CSS Themes
</h2>
<p className="text-muted mt-0.5">
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.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{themes.map((t) => {
const isSelected = activeTheme === t.id;
const custom = customThemes.find((c) => customThemeId(c) === t.id);
return (
<div key={t.id} className="flex flex-col">
<button
type="button"
onClick={() => onThemeChange(t.id)}
className={`flex-1 p-4 text-left border transition-all flex flex-col justify-between gap-3 ${
isSelected
? 'bg-canvas border-accent shadow-sm'
: 'bg-canvas border-border hover:border-strong'
}`}
>
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-bold text-sm text-main flex items-center gap-2">
<span>{t.name}</span>
{isSelected && <span className="badge-accent">Aktiv</span>}
</p>
<p className="text-xs text-muted mt-0.5">{t.subtitle}</p>
</div>
</div>
{/* Mini Color & Geometry Swatch */}
<div className="flex items-center justify-between pt-2 border-t border-border">
<div className="flex items-center gap-1.5">
<span
className="w-5 h-5 border border-strong inline-block"
style={{
backgroundColor: t.previewBg,
borderRadius: t.isRounded ? '6px' : '0px',
}}
title="Canvas-Hintergrund"
/>
<span
className="w-5 h-5 border border-strong inline-block"
style={{
backgroundColor: t.previewPanel,
borderRadius: t.isRounded ? '6px' : '0px',
}}
title="Panel-Oberfläche"
/>
<span
className="w-5 h-5 border border-strong inline-block"
style={{
backgroundColor: t.previewAccent,
borderRadius: t.isRounded ? '6px' : '0px',
}}
title="Akzentfarbe"
/>
<span
className="w-5 h-5 border border-strong inline-block"
style={{
backgroundColor: t.previewText,
borderRadius: t.isRounded ? '6px' : '0px',
}}
title="Textfarbe"
/>
</div>
<code className="text-[10px] font-mono text-faint">{t.file}</code>
</div>
</button>
{custom && (
<div className="flex items-center justify-end gap-1.5 px-2 py-1.5 border border-t-0 border-border bg-canvas">
{deletingThemeId === custom.id ? (
<>
<span className="flex-1 text-[11px] text-muted">Theme wirklich löschen?</span>
<button onClick={() => handleDeleteTheme(custom)} className="btn-primary h-7 px-2.5 text-[11px]">
Löschen
</button>
<button onClick={() => setDeletingThemeId(null)} className="btn-action h-7 px-2 text-[11px]">
Abbrechen
</button>
</>
) : (
<>
<button onClick={() => handleEditTheme(custom)} className="btn-icon" title="Theme bearbeiten">
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setDeletingThemeId(custom.id)}
className="btn-icon btn-icon-danger"
title="Theme löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
)}
</div>
);
})}
</div>
{/* Custom theme: upload or paste CSS, based on the template */}
<form onSubmit={handleSaveTheme} className="p-4 bg-canvas border border-border space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-semibold text-main">
{themeEditId ? 'Eigenes Theme bearbeiten' : 'Eigenes Theme hinzufügen'}
</p>
<div className="flex flex-wrap gap-1.5">
<button type="button" onClick={handleDownloadTemplate} className="btn-action h-7 px-2 text-[11px] flex items-center gap-1">
<Download className="w-3 h-3" />
<span>Vorlage herunterladen</span>
</button>
<button type="button" onClick={handleCopyTemplate} className="btn-action h-7 px-2 text-[11px] flex items-center gap-1">
<Copy className="w-3 h-3" />
<span>Vorlage kopieren</span>
</button>
<button
type="button"
onClick={() => setThemeCss(themeTemplateCss)}
className="btn-action h-7 px-2 text-[11px]"
>
Vorlage einfügen
</button>
</div>
</div>
<p className="text-muted text-[11px] leading-relaxed">
Ein Theme ist CSS mit 6 Blöcken: <strong>1. Akzentfarbe</strong>, <strong>2. Oberflächen</strong>,{' '}
<strong>3. Textfarben</strong>, <strong>4. Rahmen &amp; Schatten</strong>, <strong>5. Statusfarben</strong> und{' '}
<strong>6. Rundungen</strong>. 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.
</p>
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={themeName}
onChange={(e) => setThemeName(e.target.value)}
placeholder="Name, z. B. Wald"
maxLength={60}
className="flex-1 min-w-0 h-8 px-2.5 input-field"
/>
<button
type="button"
onClick={() => themeFileRef.current?.click()}
className="btn-action h-8 px-3 text-xs flex items-center justify-center gap-1.5"
>
<Upload className="w-3.5 h-3.5" />
<span>CSS-Datei hochladen</span>
</button>
<input
ref={themeFileRef}
type="file"
accept=".css,text/css"
className="hidden"
onChange={(e) => {
handleThemeFile(e.target.files?.[0]);
e.target.value = '';
}}
/>
</div>
<textarea
value={themeCss}
onChange={(e) => setThemeCss(e.target.value)}
placeholder=":root[data-theme=&quot;mein-theme&quot;] { --accent-primary: #15803d; … }"
rows={10}
spellCheck={false}
className="w-full px-2.5 py-2 input-field font-mono text-[11px] leading-relaxed resize-y"
/>
{themeMsg && (
<p className={`text-[11px] ${themeMsg.type === 'ok' ? 'text-status-ok' : 'text-status-err'}`}>
{themeMsg.text}
</p>
)}
<div className="flex justify-end gap-2">
{themeEditId && (
<button type="button" onClick={resetThemeEditor} className="btn-action h-8 px-3 text-xs">
Abbrechen
</button>
)}
<button
type="submit"
disabled={savingTheme || !themeName.trim() || !themeCss.trim()}
className="btn-primary h-8 px-4 text-xs font-semibold disabled:opacity-50"
>
{savingTheme ? 'Speichere…' : 'Speichern & aktivieren'}
</button>
</div>
</form>
</div>
)}
{/* ================================================================= */}
{/* SECTION 3: ALARME (MULTI-CHANNEL: EMAIL, DISCORD, CUSTOM WEBHOOK) */}
{/* ================================================================= */}
{section === 'alarms' && (
<div className="space-y-5">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">
Aufgaben-Alarme & Benachrichtigungsziele
</h2>
<p className="text-muted mt-0.5">
Wähle aus, über welche Kanäle du benachrichtigt werden möchtest, sobald eine
Aufgabe mit Fälligkeitsdatum ansteht. Mehrere Ziele können gleichzeitig aktiv sein.
</p>
</div>
{alarmMsg && (
<div
className={`flex items-center gap-2 ${
alarmMsg.type === 'ok' ? 'alert-ok' : 'alert-err'
}`}
>
{alarmMsg.type === 'ok' ? (
<Check className="w-4 h-4 text-status-ok flex-shrink-0" />
) : (
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
)}
<span>{alarmMsg.text}</span>
</div>
)}
<form onSubmit={handleSaveAlarms} className="space-y-4">
{/* Lead Time Selector */}
<div className="p-3.5 bg-canvas border border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<label className="font-semibold text-main block">
Vorwarnzeit vor Fälligkeit
</label>
<p className="text-[11px] text-muted">
Wann soll MindSift den Alarm vor dem Fälligkeitszeitpunkt auslösen?
</p>
</div>
<select
value={notifyBeforeMinutes}
onChange={(e) => setNotifyBeforeMinutes(Number(e.target.value))}
className="h-9 px-3 input-field min-w-[200px]"
>
<option value={0}>Pünktlich bei Fälligkeit (0 Min.)</option>
<option value={15}>15 Minuten vorher</option>
<option value={30}>30 Minuten vorher</option>
<option value={60}>1 Stunde vorher</option>
<option value={120}>2 Stunden vorher</option>
<option value={1440}>1 Tag vorher (24 Std.)</option>
</select>
</div>
{/* Channel 1: E-Mail */}
<div
className={`p-4 border transition-colors space-y-3 bg-canvas ${
alarmChannels.includes('email') ? 'border-accent' : 'border-border'
}`}
>
<div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2.5 cursor-pointer select-none">
<input
type="checkbox"
checked={alarmChannels.includes('email')}
onChange={() => toggleAlarmChannel('email')}
className="w-4 h-4 accent-blue-600"
/>
<Mail className="w-4 h-4 text-accent" />
<div>
<span className="font-bold text-main block">Ziel: E-Mail</span>
<span className="text-[11px] text-muted">
Sendet eine E-Mail über den konfigurierten SMTP-Server der Instanz.
</span>
</div>
</label>
<button
type="button"
onClick={() => handleTestChannel('email')}
disabled={testingChannel === 'email'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
>
<Send className="w-3 h-3 text-accent" />
<span>{testingChannel === 'email' ? 'Sende…' : 'E-Mail testen'}</span>
</button>
</div>
<div className="pl-6">
<label className="block text-muted mb-1">
Abweichende Empfänger-E-Mail (optional, Standard: {currentUser.email})
</label>
<input
type="email"
value={alarmEmail}
onChange={(e) => setAlarmEmail(e.target.value)}
placeholder={currentUser.email}
className="w-full h-8 px-2.5 input-field"
/>
</div>
</div>
{/* Channel 2: Discord Webhook */}
<div
className={`p-4 border transition-colors space-y-3 bg-canvas ${
alarmChannels.includes('discord') ? 'border-accent' : 'border-border'
}`}
>
<div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2.5 cursor-pointer select-none">
<input
type="checkbox"
checked={alarmChannels.includes('discord')}
onChange={() => toggleAlarmChannel('discord')}
className="w-4 h-4 accent-blue-600"
/>
<MessageSquare className="w-4 h-4 text-accent" />
<div>
<span className="font-bold text-main block">Ziel: Discord Webhook</span>
<span className="text-[11px] text-muted">
Sendet eine formatierte Embed-Benachrichtigung direkt in deinen
Discord-Kanal.
</span>
</div>
</label>
<button
type="button"
onClick={() => handleTestChannel('discord')}
disabled={testingChannel === 'discord'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
>
<Send className="w-3 h-3 text-accent" />
<span>{testingChannel === 'discord' ? 'Sende…' : 'Discord testen'}</span>
</button>
</div>
<div className="pl-6">
<label className="block text-muted mb-1">Discord Webhook URL</label>
<input
type="url"
value={discordWebhookUrl}
onChange={(e) => setDiscordWebhookUrl(e.target.value)}
placeholder="https://discord.com/api/webhooks/123456789/token..."
className="w-full h-8 px-2.5 input-field font-mono"
/>
</div>
</div>
{/* Channel 3: Custom HTTP Webhook */}
<div
className={`p-4 border transition-colors space-y-3 bg-canvas ${
alarmChannels.includes('webhook') ? 'border-accent' : 'border-border'
}`}
>
<div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2.5 cursor-pointer select-none">
<input
type="checkbox"
checked={alarmChannels.includes('webhook')}
onChange={() => toggleAlarmChannel('webhook')}
className="w-4 h-4 accent-blue-600"
/>
<Globe className="w-4 h-4 text-accent" />
<div>
<span className="font-bold text-main block">
Ziel: Eigener Webhook (HTTP POST JSON)
</span>
<span className="text-[11px] text-muted">
Für eigene Empfänger-Skripte, Ntfy, Gotify, Slack, Mattermost oder
Automatisierungen.
</span>
</div>
</label>
<button
type="button"
onClick={() => handleTestChannel('webhook')}
disabled={testingChannel === 'webhook'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
>
<Send className="w-3 h-3 text-accent" />
<span>{testingChannel === 'webhook' ? 'Sende…' : 'Webhook testen'}</span>
</button>
</div>
<div className="pl-6">
<label className="block text-muted mb-1">
Ziel-URL (empfängt JSON-Payload per POST)
</label>
<input
type="url"
value={customWebhookUrl}
onChange={(e) => setCustomWebhookUrl(e.target.value)}
placeholder="https://dein-server.local/webhook/mindsift-alarm"
className="w-full h-8 px-2.5 input-field font-mono"
/>
</div>
</div>
<div className="pt-2 border-t border-border flex justify-end">
<button
type="submit"
disabled={savingAlarms}
className="btn-primary h-9 px-5 text-xs font-semibold"
>
{savingAlarms ? 'Speichere…' : 'Alarm-Einstellungen speichern'}
</button>
</div>
</form>
</div>
)}
{/* ================================================================= */}
{/* SECTION 4: PRIVATE WEBHOOKS & EXPORT */}
{/* ================================================================= */}
{section === 'webhooks' && (
<div className="space-y-5">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">
Private Webhooks (Eingehend) & Daten-Export
</h2>
<p className="text-muted mt-0.5">
Erstelle persönliche API-Schlüssel, um aus externen Skripten, Server-Monitoring
oder Discord-Bots automatisch Aufgaben und Gedanken in dein MindSift einzuspeisen.
</p>
</div>
<ApiKeysPanel />
<div className="pt-4 border-t border-border">
<ServerBackupsCard />
</div>
{/* Full Data Backup */}
<div className="pt-4 border-t border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h3 className="font-bold text-main">Vollständiges Backup (.zip)</h3>
<p className="text-muted text-[11px]">
Alle deine Gedanken, Aufgaben, Notizen und Listen als JSON, gepackt in eine ZIP-Datei. Über
„MindSift-Daten importieren“ lässt es sich vollständig zurückspielen.
</p>
</div>
<button
onClick={handleExportData}
disabled={exportingBackup}
className="btn-action h-11 sm:h-9 px-4 text-xs flex items-center justify-center gap-2 flex-shrink-0 disabled:opacity-50"
>
<Download className="w-3.5 h-3.5 text-accent" />
<span>{exportingBackup ? 'Wird erstellt…' : 'Backup herunterladen'}</span>
</button>
</div>
{/* Obsidian-compatible Markdown vault */}
<div className="pt-4 border-t border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h3 className="font-bold text-main">Markdown-Archiv für Obsidian (.zip)</h3>
<p className="text-muted text-[11px]">
Alle Notizen als <code className="font-mono">.md</code>-Dateien in deiner Ordnerstruktur (mit Titel, Tags und
Datum im Kopf), Aufgaben je Liste unter <code className="font-mono">Aufgaben/</code> im Format des
Obsidian-Tasks-Plugins, Listen unter <code className="font-mono">Listen/</code>. Mit dir geteilte Notizen sind
dabei.
</p>
{vaultError && <p className="text-[11px] text-status-err mt-1">{vaultError}</p>}
</div>
<button
onClick={handleExportVault}
disabled={exportingVault}
className="btn-action h-11 sm:h-9 px-4 text-xs flex items-center justify-center gap-2 flex-shrink-0 disabled:opacity-50"
>
<Download className="w-3.5 h-3.5 text-accent" />
<span>{exportingVault ? 'Wird erstellt…' : 'Markdown-Archiv herunterladen'}</span>
</button>
</div>
{/* Import: two separate paths, never guessed from the file */}
<div className="pt-4 border-t border-border space-y-3">
<div>
<h3 className="font-bold text-main">Daten importieren</h3>
<p className="text-muted text-[11px]">
Ein Import legt alles neu an und ändert nichts Bestehendes. Vor dem Import siehst du, was angelegt wird.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<ImportCard
source="mindsift"
title="MindSift-Daten"
accept=".json,.zip,application/json,application/zip"
description={
<>
Eigenes Backup zurückspielen: JSON-Backup (<code className="font-mono">.json</code>) oder Markdown-Archiv
(<code className="font-mono">.zip</code>). Notizen kommen in ihre Ordner, Aufgabenlisten mit Unteraufgaben,
Fälligkeiten und Prioritäten sowie Listen werden wiederhergestellt.
</>
}
/>
<ImportCard
source="obsidian"
title="Obsidian-Vault"
accept=".zip,application/zip"
description={
<>
Vault als <code className="font-mono">.zip</code>. Alle Notizen landen in einem eigenen Ordner mit dem Namen
des Vaults, Unterordner bleiben erhalten. Checkboxen bleiben Checkboxen in der Notiz, Frontmatter-Tags werden zu
#Hashtags. Bilder und andere Anhänge werden übersprungen.
</>
}
/>
</div>
</div>
</div>
)}
</div>
</div>
{/* Footer: legal notices of this instance and project info */}
<LegalLinks legal={legal} className="pt-4 border-t border-border">
<button type="button" onClick={() => setShowAbout(true)} className="hover:text-main underline-offset-2 hover:underline">
Über MindSift
</button>
</LegalLinks>
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
{dangerAction && (
<FormSheet
title={dangerAction === 'account' ? 'Konto unwiderruflich löschen' : 'Alle eigenen Daten löschen'}
icon={<AlertCircle className="w-4 h-4 text-status-err" />}
onClose={() => setDangerAction(null)}
onSubmit={handleDangerSubmit}
submitLabel={dangerAction === 'account' ? 'Konto löschen' : 'Daten löschen'}
danger
busy={dangerBusy || !dangerPassword}
>
<p className="text-xs text-muted">
{dangerAction === 'account'
? 'Dein Konto und alle deine Daten werden endgültig gelöscht. Das lässt sich nicht rückgängig machen.'
: 'Alle deine Notizen, Ordner, Aufgaben, Gedanken und Listen werden endgültig gelöscht. Lade vorher ein Backup herunter, wenn du sie behalten willst.'}
</p>
<label className="block text-xs text-muted">
Zur Bestätigung dein Passwort
<input
type="password"
autoFocus
autoComplete="current-password"
value={dangerPassword}
onChange={(e) => setDangerPassword(e.target.value)}
className="mt-1 input-field w-full h-11 sm:h-8 px-2.5 text-base sm:text-xs"
/>
</label>
{dangerError && <p className="text-[11px] text-status-err">{dangerError}</p>}
</FormSheet>
)}
</div>
);
};