MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 16s
851 lines
35 KiB
TypeScript
851 lines
35 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import {
|
|
User as UserIcon,
|
|
Bell,
|
|
Key,
|
|
Plus,
|
|
Trash2,
|
|
Terminal,
|
|
Download,
|
|
ArrowLeft,
|
|
Check,
|
|
Mail,
|
|
MessageSquare,
|
|
Globe,
|
|
Send,
|
|
AlertCircle,
|
|
Palette,
|
|
} from 'lucide-react';
|
|
import { APIKey, User } from '../../lib/types';
|
|
import { api, triggerHaptic } from '../../lib/api';
|
|
import { AVAILABLE_THEMES, ThemeId } from '../../lib/theme';
|
|
|
|
interface UserSettingsViewProps {
|
|
currentUser: User;
|
|
activeTheme: ThemeId;
|
|
onThemeChange: (themeId: ThemeId) => void;
|
|
onUserUpdated: (u: User) => void;
|
|
onBack: () => void;
|
|
}
|
|
|
|
type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks';
|
|
|
|
export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|
currentUser,
|
|
activeTheme,
|
|
onThemeChange,
|
|
onUserUpdated,
|
|
onBack,
|
|
}) => {
|
|
const [section, setSection] = useState<UserSettingsSection>('profile');
|
|
|
|
// --- Profile State ---
|
|
const [displayName, setDisplayName] = useState(currentUser.displayName || '');
|
|
const [email, setEmail] = useState(currentUser.email || '');
|
|
const [newPassword, setNewPassword] = useState('');
|
|
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
|
const [savingProfile, setSavingProfile] = useState(false);
|
|
|
|
// --- 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 || '');
|
|
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: Profile ---
|
|
const handleSaveProfile = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setProfileMsg(null);
|
|
setSavingProfile(true);
|
|
try {
|
|
const patch: any = {
|
|
displayName: displayName.trim(),
|
|
email: email.trim(),
|
|
};
|
|
if (newPassword.trim()) {
|
|
patch.password = newPassword.trim();
|
|
}
|
|
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);
|
|
}
|
|
};
|
|
|
|
// --- 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);
|
|
};
|
|
|
|
const handleExportData = async () => {
|
|
const data = await api.exportAll();
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
|
type: 'application/json;charset=utf-8',
|
|
});
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const baseUrl = window.location.origin;
|
|
const activeKeyPlaceholder =
|
|
newlyCreatedKey || (apiKeys[0]?.keyPrefix ? apiKeys[0].keyPrefix + '••••••••' : 'DEIN_API_KEY');
|
|
|
|
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'
|
|
: currentUser.role === 'team_admin'
|
|
? 'Team-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-4">
|
|
<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 4 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>
|
|
</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">
|
|
{AVAILABLE_THEMES.map((t) => {
|
|
const isSelected = activeTheme === t.id;
|
|
return (
|
|
<button
|
|
key={t.id}
|
|
type="button"
|
|
onClick={() => onThemeChange(t.id)}
|
|
className={`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>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="p-3.5 bg-canvas border border-border space-y-1.5">
|
|
<p className="font-semibold text-main">Eigenes Theme anpassen oder hinzufügen:</p>
|
|
<p className="text-muted text-[11px] leading-relaxed">
|
|
Alle Theme-Dateien liegen unter{' '}
|
|
<code className="font-mono text-accent">frontend/src/themes/</code> (inklusive der
|
|
Kopiervorlage{' '}
|
|
<code className="font-mono text-accent">theme-template.css</code>). Dort sind alle
|
|
Eigenschaften in 6 Blöcken gruppiert: <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>.
|
|
</p>
|
|
</div>
|
|
</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="flex-1 h-9 px-3 input-field"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="btn-primary h-9 px-4 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 Example */}
|
|
<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-Beispiel: Direkt als Aufgabe einspeisen</span>
|
|
</span>
|
|
<button
|
|
onClick={() => handleCopy(curlTaskExample, 'curl')}
|
|
className="btn-action h-7 px-2.5 text-[11px]"
|
|
>
|
|
{copiedLabel === 'curl' ? 'Kopiert!' : 'Befehl kopieren'}
|
|
</button>
|
|
</div>
|
|
<pre className="p-3 code-block text-[11px] font-mono overflow-x-auto whitespace-pre">
|
|
{curlTaskExample}
|
|
</pre>
|
|
</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ändiger Daten-Export (JSON Backup)</h3>
|
|
<p className="text-muted text-[11px]">
|
|
Lädt alle deine Gedanken, Aufgaben, Markdown-Dokumente und Listen als portable
|
|
JSON-Datei herunter.
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={handleExportData}
|
|
className="btn-action h-9 px-4 text-xs flex items-center gap-2 flex-shrink-0"
|
|
>
|
|
<Download className="w-3.5 h-3.5 text-accent" />
|
|
<span>Backup herunterladen</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|