auth.ValidatePassword checks every way a password is set: setup, registration, profile, admin create and reset, reset by mail. Same rule in the browser before sending (lib/password.ts), with our own German message instead of the browser popup. Existing shorter passwords still log in. Profile and admin reset no longer trim the password, which made passwords with surrounding spaces unusable for login. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1452 lines
63 KiB
TypeScript
1452 lines
63 KiB
TypeScript
import React, { useEffect, useState, useRef } from 'react';
|
||
import {
|
||
User as UserIcon,
|
||
Bell,
|
||
Key,
|
||
Plus,
|
||
Trash2,
|
||
Terminal,
|
||
Download,
|
||
ArrowLeft,
|
||
Check,
|
||
Mail,
|
||
MessageSquare,
|
||
Globe,
|
||
Send,
|
||
AlertCircle,
|
||
Palette,
|
||
Upload,
|
||
Camera,
|
||
Pencil,
|
||
Copy,
|
||
} from 'lucide-react';
|
||
import { APIKey, AuthStatus, User, UserTheme } from '../../lib/types';
|
||
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 { 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 [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);
|
||
|
||
// --- Webhooks & API Keys State ---
|
||
const [apiKeys, setApiKeys] = useState<APIKey[]>([]);
|
||
const [newKeyName, setNewKeyName] = useState('');
|
||
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
|
||
const [copiedLabel, setCopiedLabel] = 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]);
|
||
|
||
useEffect(() => {
|
||
loadKeys();
|
||
}, []);
|
||
|
||
const loadKeys = async () => {
|
||
try {
|
||
const keys = await api.getAPIKeys();
|
||
setApiKeys(keys);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
};
|
||
|
||
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);
|
||
};
|
||
|
||
// --- 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;
|
||
}
|
||
const updated = await api.updateSettings(patch);
|
||
onUserUpdated(updated);
|
||
setNewPassword('');
|
||
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: Webhooks & Export ---
|
||
const handleCreateAPIKey = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
triggerHaptic(15);
|
||
try {
|
||
const created = await api.createAPIKey(newKeyName.trim() || 'Eigenes Skript / Webhook');
|
||
if (created.rawKey) {
|
||
setNewlyCreatedKey(created.rawKey);
|
||
}
|
||
setNewKeyName('');
|
||
loadKeys();
|
||
} catch (err: any) {
|
||
alert(err.message);
|
||
}
|
||
};
|
||
|
||
const handleDeleteAPIKey = async (id: string) => {
|
||
if (!window.confirm('Diesen API-Schlüssel wirklich widerrufen?')) return;
|
||
await api.deleteAPIKey(id);
|
||
loadKeys();
|
||
};
|
||
|
||
const handleCopy = (text: string, label: string) => {
|
||
navigator.clipboard.writeText(text);
|
||
setCopiedLabel(label);
|
||
setTimeout(() => setCopiedLabel(null), 2000);
|
||
};
|
||
|
||
// Works on mobile browsers too: the link must be in the document and the URL alive until the click ran
|
||
const saveBlob = (blob: Blob, filename: string) => {
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
window.setTimeout(() => URL.revokeObjectURL(url), 10000);
|
||
};
|
||
|
||
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 baseUrl = window.location.origin;
|
||
const activeKeyPlaceholder =
|
||
newlyCreatedKey || (apiKeys[0]?.keyPrefix ? apiKeys[0].keyPrefix + '••••••••' : 'DEIN_API_KEY');
|
||
|
||
const curlTriageExample = `curl -X POST ${baseUrl}/api/v1/inbox \\
|
||
-H "X-API-Key: ${activeKeyPlaceholder}" \\
|
||
-H "Content-Type: application/json" \\
|
||
-d '{"content": "Kellerfeuchtigkeit über 68% gestiegen – Entfeuchter prüfen #smarthome #alarm", "priority": "urgent"}'`;
|
||
|
||
const curlTaskExample = `curl -X POST ${baseUrl}/api/v1/inbox \\
|
||
-H "X-API-Key: ${activeKeyPlaceholder}" \\
|
||
-H "Content-Type: application/json" \\
|
||
-d '{"content": "Server-Alarm prüfen #monitoring", "priority": "urgent", "target": "task"}'`;
|
||
|
||
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. 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"
|
||
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">
|
||
Sitzungen werden durch kryptografische JWTs geschützt. Bei einer Passwortänderung werden alle alten Sitzungen automatisch ungültig.
|
||
</p>
|
||
</div>
|
||
<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 & 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="mein-theme"] { --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>
|
||
|
||
{/* Create API Key */}
|
||
<form onSubmit={handleCreateAPIKey} className="flex flex-col sm:flex-row gap-2">
|
||
<input
|
||
type="text"
|
||
value={newKeyName}
|
||
onChange={(e) => setNewKeyName(e.target.value)}
|
||
placeholder="Bezeichnung des Schlüssels (z.B. Proxmox Monitoring, Discord-Forwarder)…"
|
||
className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-xs"
|
||
/>
|
||
<button
|
||
type="submit"
|
||
className="btn-primary h-11 sm:h-9 px-4 text-sm sm:text-xs font-semibold flex items-center justify-center gap-1.5"
|
||
>
|
||
<Plus className="w-3.5 h-3.5" />
|
||
<span>Neuen API-Key erzeugen</span>
|
||
</button>
|
||
</form>
|
||
|
||
{newlyCreatedKey && (
|
||
<div className="p-3 bg-accent-dim border border-accent flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||
<div className="min-w-0">
|
||
<p className="text-[11px] text-accent font-bold">
|
||
Neuer API-Schlüssel erstellt (bitte jetzt kopieren, wird nur einmal im
|
||
Klartext angezeigt):
|
||
</p>
|
||
<code className="text-xs font-mono text-main break-all">{newlyCreatedKey}</code>
|
||
</div>
|
||
<button
|
||
onClick={() => handleCopy(newlyCreatedKey, 'key')}
|
||
className="btn-primary h-8 px-3 text-xs flex-shrink-0"
|
||
>
|
||
{copiedLabel === 'key' ? 'Kopiert!' : 'Key kopieren'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Existing Keys List */}
|
||
<div className="border border-border divide-y divide-border bg-canvas">
|
||
{apiKeys.length === 0 ? (
|
||
<div className="p-4 text-center text-faint">
|
||
Noch keine persönlichen API-Schlüssel vorhanden.
|
||
</div>
|
||
) : (
|
||
apiKeys.map((k) => (
|
||
<div key={k.id} className="p-3 flex items-center justify-between gap-3">
|
||
<div>
|
||
<p className="font-semibold text-main">{k.name}</p>
|
||
<p className="text-[11px] text-faint font-mono">
|
||
Präfix: {k.keyPrefix}•••••••• · Erstellt:{' '}
|
||
{new Date(k.createdAt).toLocaleDateString()}
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => handleDeleteAPIKey(k.id)}
|
||
className="btn-icon btn-icon-danger"
|
||
title="Schlüssel widerrufen"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
{/* Curl Examples */}
|
||
<div className="space-y-4">
|
||
<div className="space-y-1.5">
|
||
<div className="flex items-center justify-between">
|
||
<span className="font-semibold text-muted flex items-center gap-1.5">
|
||
<Terminal className="w-3.5 h-3.5 text-accent" />
|
||
<span>Curl: In Triage-Deck einspeisen (erhält Webhook-Badge)</span>
|
||
</span>
|
||
<button
|
||
onClick={() => handleCopy(curlTriageExample, 'curl-triage')}
|
||
className="btn-action h-7 px-2.5 text-[11px]"
|
||
>
|
||
{copiedLabel === 'curl-triage' ? 'Kopiert!' : 'Befehl kopieren'}
|
||
</button>
|
||
</div>
|
||
<pre className="p-3 code-block text-[11px] font-mono overflow-x-auto whitespace-pre">
|
||
{curlTriageExample}
|
||
</pre>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<div className="flex items-center justify-between">
|
||
<span className="font-semibold text-muted flex items-center gap-1.5">
|
||
<Terminal className="w-3.5 h-3.5 text-accent" />
|
||
<span>Curl: Direkt als Aufgabe anlegen</span>
|
||
</span>
|
||
<button
|
||
onClick={() => handleCopy(curlTaskExample, 'curl-task')}
|
||
className="btn-action h-7 px-2.5 text-[11px]"
|
||
>
|
||
{copiedLabel === 'curl-task' ? 'Kopiert!' : 'Befehl kopieren'}
|
||
</button>
|
||
</div>
|
||
<pre className="p-3 code-block text-[11px] font-mono overflow-x-auto whitespace-pre">
|
||
{curlTaskExample}
|
||
</pre>
|
||
</div>
|
||
</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>
|
||
);
|
||
};
|