feat(auth,settings,tasks): add first-boot setup, real login/invites, full-page settings & admin views, multi-channel alarms, and fix null slice crashes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
This commit is contained in:
1 parent
50ac0c8f64
commit
7802eb2cf0
17 files changed
+4391
-1368
No files matched your search
@@ -0,0 +1,726 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
User as UserIcon,
|
||||
Bell,
|
||||
Key,
|
||||
Plus,
|
||||
Trash2,
|
||||
Terminal,
|
||||
Download,
|
||||
ArrowLeft,
|
||||
Check,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Globe,
|
||||
Send,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { APIKey, User } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
|
||||
interface UserSettingsViewProps {
|
||||
currentUser: User;
|
||||
onUserUpdated: (u: User) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
type UserSettingsSection = 'profile' | 'alarms' | 'webhooks';
|
||||
|
||||
export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
currentUser,
|
||||
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 + '••••••••' || '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-white tracking-tight">Kontoeinstellungen</h1>
|
||||
<p className="text-xs text-zinc-400">
|
||||
Persönliches Profil, Aufgaben-Alarme, Private Webhooks & Daten-Backup
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="px-2.5 py-1 bg-panel border border-border text-xs text-zinc-300 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="bg-panel border border-border divide-y divide-border">
|
||||
<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-white border-l-2 border-l-accent'
|
||||
: 'text-zinc-400 hover:text-white hover:bg-white/[0.02]'
|
||||
}`}
|
||||
>
|
||||
<UserIcon className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">Profil & Sicherheit</p>
|
||||
<p className="text-[10px] text-zinc-500">Name, E-Mail & Passwort</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-white border-l-2 border-l-accent'
|
||||
: 'text-zinc-400 hover:text-white hover:bg-white/[0.02]'
|
||||
}`}
|
||||
>
|
||||
<Bell className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">Alarme</p>
|
||||
<p className="text-[10px] text-zinc-500">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-white border-l-2 border-l-accent'
|
||||
: 'text-zinc-400 hover:text-white hover:bg-white/[0.02]'
|
||||
}`}
|
||||
>
|
||||
<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-zinc-500">API-Keys, Curl & JSON-Backup</p>
|
||||
</div>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
{/* Right Content Area */}
|
||||
<div className="md:col-span-3 bg-panel border border-border 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-white">Profil & Anmeldedaten</h2>
|
||||
<p className="text-zinc-400 mt-0.5">
|
||||
Verwalte deinen Anzeigenamen, deine E-Mail-Adresse und dein persönliches Passwort.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{profileMsg && (
|
||||
<div
|
||||
className={`p-3 border flex items-center gap-2 ${
|
||||
profileMsg.type === 'ok'
|
||||
? 'bg-blue-950/30 border-accent text-blue-200'
|
||||
: 'bg-red-950/30 border-red-500 text-red-200'
|
||||
}`}
|
||||
>
|
||||
{profileMsg.type === 'ok' ? (
|
||||
<Check className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4 text-red-400 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-zinc-300 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 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-zinc-300 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 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-zinc-300 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 text-white"
|
||||
/>
|
||||
</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: 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-white">Aufgaben-Alarme & Benachrichtigungsziele</h2>
|
||||
<p className="text-zinc-400 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={`p-3 border flex items-center gap-2 ${
|
||||
alarmMsg.type === 'ok'
|
||||
? 'bg-blue-950/30 border-accent text-blue-200'
|
||||
: 'bg-red-950/30 border-red-500 text-red-200'
|
||||
}`}
|
||||
>
|
||||
{alarmMsg.type === 'ok' ? (
|
||||
<Check className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4 text-red-400 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-white block">
|
||||
Vorwarnzeit vor Fälligkeit
|
||||
</label>
|
||||
<p className="text-[11px] text-zinc-400">
|
||||
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 text-white bg-panel 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 ${
|
||||
alarmChannels.includes('email')
|
||||
? 'bg-canvas border-accent'
|
||||
: 'bg-canvas/60 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-white block">Ziel: E-Mail</span>
|
||||
<span className="text-[11px] text-zinc-400">
|
||||
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-zinc-400 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 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel 2: Discord Webhook */}
|
||||
<div
|
||||
className={`p-4 border transition-colors space-y-3 ${
|
||||
alarmChannels.includes('discord')
|
||||
? 'bg-canvas border-accent'
|
||||
: 'bg-canvas/60 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-white block">Ziel: Discord Webhook</span>
|
||||
<span className="text-[11px] text-zinc-400">
|
||||
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-zinc-400 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 text-white font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel 3: Custom HTTP Webhook */}
|
||||
<div
|
||||
className={`p-4 border transition-colors space-y-3 ${
|
||||
alarmChannels.includes('webhook')
|
||||
? 'bg-canvas border-accent'
|
||||
: 'bg-canvas/60 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-white block">
|
||||
Ziel: Eigener Webhook (HTTP POST JSON)
|
||||
</span>
|
||||
<span className="text-[11px] text-zinc-400">
|
||||
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-zinc-400 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 text-white 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 3: 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-white">Private Webhooks (Eingehend) & Daten-Export</h2>
|
||||
<p className="text-zinc-400 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 text-white"
|
||||
/>
|
||||
<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/15 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-white 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-zinc-500">
|
||||
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-white">{k.name}</p>
|
||||
<p className="text-[11px] text-zinc-500 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-zinc-300 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 bg-canvas border border-border text-[11px] font-mono text-zinc-300 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-white">Vollständiger Daten-Export (JSON Backup)</h3>
|
||||
<p className="text-zinc-400 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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user