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
File diff suppressed because it is too large.
Load diff
@@ -1,953 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
X,
|
||||
User as UserIcon,
|
||||
Shield,
|
||||
Bell,
|
||||
Key,
|
||||
Users,
|
||||
Settings as SettingsIcon,
|
||||
Plus,
|
||||
Trash2,
|
||||
Check,
|
||||
Copy,
|
||||
RefreshCw,
|
||||
LogOut,
|
||||
Terminal,
|
||||
Download,
|
||||
AlertTriangle,
|
||||
Lock,
|
||||
Share2,
|
||||
} from 'lucide-react';
|
||||
import { APIKey, Organization, SystemSettings, Team, User } from '../../lib/types';
|
||||
import { api, clearToken, getToken, triggerHaptic } from '../../lib/api';
|
||||
|
||||
interface SettingsModalProps {
|
||||
currentUser: User | null;
|
||||
onClose: () => void;
|
||||
onUserUpdated: (u: User) => void;
|
||||
onLoggedOut: () => void;
|
||||
}
|
||||
|
||||
type SettingsTab = 'profile' | 'alarms' | 'webhooks' | 'teams' | 'admin';
|
||||
|
||||
export const SettingsModal: React.FC<SettingsModalProps> = ({
|
||||
currentUser,
|
||||
onClose,
|
||||
onUserUpdated,
|
||||
onLoggedOut,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('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);
|
||||
|
||||
// --- Alarms / Discord State ---
|
||||
const [discordWebhookUrl, setDiscordWebhookUrl] = useState(currentUser?.discordWebhookUrl || '');
|
||||
const [notifyBeforeMinutes, setNotifyBeforeMinutes] = useState(currentUser?.notifyBeforeMinutes || 30);
|
||||
const [alarmMsg, setAlarmMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
const [testingDiscord, setTestingDiscord] = useState(false);
|
||||
|
||||
// --- Webhooks / API Keys State ---
|
||||
const [apiKeys, setApiKeys] = useState<APIKey[]>([]);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
|
||||
const [copiedKey, setCopiedKey] = useState(false);
|
||||
|
||||
// --- Organizations & Teams State ---
|
||||
const [orgs, setOrgs] = useState<Organization[]>([]);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [newOrgName, setNewOrgName] = useState('');
|
||||
const [selectedOrgId, setSelectedOrgId] = useState('');
|
||||
const [newTeamName, setNewTeamName] = useState('');
|
||||
const [newTeamDesc, setNewTeamDesc] = useState('');
|
||||
|
||||
// --- Admin State ---
|
||||
const [adminUsers, setAdminUsers] = useState<User[]>([]);
|
||||
const [systemSettings, setSystemSettings] = useState<SystemSettings>({
|
||||
sharingMode: 'teams_only',
|
||||
allowRegistration: true,
|
||||
instanceName: 'MindSift',
|
||||
});
|
||||
const [newAdminUserEmail, setNewAdminUserEmail] = useState('');
|
||||
const [newAdminUserPassword, setNewAdminUserPassword] = useState('');
|
||||
const [newAdminUserDisplayName, setNewAdminUserDisplayName] = useState('');
|
||||
const [newAdminUserRole, setNewAdminUserRole] = useState<'member' | 'admin'>('member');
|
||||
const [resetPwdUserId, setResetPwdUserId] = useState<string | null>(null);
|
||||
const [resetPwdVal, setResetPwdVal] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadKeys();
|
||||
loadOrgsAndTeams();
|
||||
if (currentUser?.role === 'admin') {
|
||||
loadAdminData();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadKeys = async () => {
|
||||
try {
|
||||
const keys = await api.getAPIKeys();
|
||||
setApiKeys(keys);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const loadOrgsAndTeams = async () => {
|
||||
try {
|
||||
const [o, t] = await Promise.all([api.getOrganizations(), api.getUserTeams()]);
|
||||
setOrgs(o);
|
||||
setTeams(t);
|
||||
if (o.length > 0 && !selectedOrgId) {
|
||||
setSelectedOrgId(o[0].id);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const loadAdminData = async () => {
|
||||
try {
|
||||
const [u, s] = await Promise.all([api.adminGetUsers(), api.adminGetSettings()]);
|
||||
setAdminUsers(u);
|
||||
setSystemSettings(s);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// --- Profile Handlers ---
|
||||
const handleSaveProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setProfileMsg(null);
|
||||
try {
|
||||
const patch: any = { displayName, email };
|
||||
if (newPassword.trim()) {
|
||||
patch.password = newPassword.trim();
|
||||
}
|
||||
const updated = await api.updateSettings(patch);
|
||||
onUserUpdated(updated);
|
||||
setNewPassword('');
|
||||
setProfileMsg({ type: 'ok', text: 'Profil erfolgreich gespeichert.' });
|
||||
} catch (err: any) {
|
||||
setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' });
|
||||
}
|
||||
};
|
||||
|
||||
// --- Alarms Handlers ---
|
||||
const handleSaveAlarms = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setAlarmMsg(null);
|
||||
try {
|
||||
const updated = await api.updateSettings({
|
||||
discordWebhookUrl: discordWebhookUrl.trim(),
|
||||
notifyBeforeMinutes: Number(notifyBeforeMinutes),
|
||||
});
|
||||
onUserUpdated(updated);
|
||||
setAlarmMsg({ type: 'ok', text: 'Discord Alarm-Einstellungen gespeichert.' });
|
||||
} catch (err: any) {
|
||||
setAlarmMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestDiscordWebhook = async () => {
|
||||
if (!discordWebhookUrl.trim()) {
|
||||
setAlarmMsg({ type: 'err', text: 'Bitte zuerst eine Discord Webhook URL eingeben.' });
|
||||
return;
|
||||
}
|
||||
setTestingDiscord(true);
|
||||
setAlarmMsg(null);
|
||||
try {
|
||||
const res = await fetch(discordWebhookUrl.trim(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: '🔔 **MindSift Test-Alarm**: Verbindung zu Discord erfolgreich hergestellt!',
|
||||
embeds: [
|
||||
{
|
||||
title: 'MindSift Aufgaben-Alarm eingerichtet',
|
||||
description: 'Du erhältst hier Benachrichtigungen, wenn Aufgaben fällig werden.',
|
||||
color: 3066993,
|
||||
footer: { text: 'MindSift System Test' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Discord antwortete mit Status ${res.status}`);
|
||||
}
|
||||
setAlarmMsg({ type: 'ok', text: '✅ Test-Nachricht erfolgreich an Discord gesendet!' });
|
||||
} catch (err: any) {
|
||||
setAlarmMsg({ type: 'err', text: `Fehler: ${err.message}` });
|
||||
} finally {
|
||||
setTestingDiscord(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Webhook / Key Handlers ---
|
||||
const handleCreateAPIKey = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
const created = await api.createAPIKey(newKeyName.trim() || 'Home Assistant / Script');
|
||||
if (created.rawKey) {
|
||||
setNewlyCreatedKey(created.rawKey);
|
||||
}
|
||||
setNewKeyName('');
|
||||
loadKeys();
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAPIKey = async (id: string) => {
|
||||
if (!window.confirm('Schlüssel widerrufen?')) return;
|
||||
await api.deleteAPIKey(id);
|
||||
loadKeys();
|
||||
};
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedKey(true);
|
||||
setTimeout(() => setCopiedKey(false), 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);
|
||||
};
|
||||
|
||||
// --- Orgs & Teams Handlers ---
|
||||
const handleCreateOrg = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newOrgName.trim()) return;
|
||||
try {
|
||||
const created = await api.createOrganization(newOrgName.trim());
|
||||
setOrgs((prev) => [...prev, created]);
|
||||
setSelectedOrgId(created.id);
|
||||
setNewOrgName('');
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTeam = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedOrgId || !newTeamName.trim()) return;
|
||||
try {
|
||||
const created = await api.createOrgTeam(selectedOrgId, newTeamName.trim(), newTeamDesc.trim());
|
||||
setTeams((prev) => [...prev, created]);
|
||||
setNewTeamName('');
|
||||
setNewTeamDesc('');
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Admin Handlers ---
|
||||
const handleUpdateSystemSettings = async (patch: Partial<SystemSettings>) => {
|
||||
const next = { ...systemSettings, ...patch };
|
||||
setSystemSettings(next);
|
||||
try {
|
||||
await api.adminUpdateSettings(next);
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdminCreateUser = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const created = await api.adminCreateUser({
|
||||
email: newAdminUserEmail,
|
||||
password: newAdminUserPassword,
|
||||
displayName: newAdminUserDisplayName || newAdminUserEmail.split('@')[0],
|
||||
role: newAdminUserRole,
|
||||
});
|
||||
setAdminUsers((prev) => [...prev, created]);
|
||||
setNewAdminUserEmail('');
|
||||
setNewAdminUserPassword('');
|
||||
setNewAdminUserDisplayName('');
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleUserActive = async (u: User) => {
|
||||
try {
|
||||
const updated = await api.adminUpdateUser(u.id, { isActive: !u.isActive });
|
||||
setAdminUsers((prev) => prev.map((item) => (item.id === u.id ? updated : item)));
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleUserRole = async (u: User) => {
|
||||
const nextRole = u.role === 'admin' ? 'member' : 'admin';
|
||||
try {
|
||||
const updated = await api.adminUpdateUser(u.id, { role: nextRole });
|
||||
setAdminUsers((prev) => prev.map((item) => (item.id === u.id ? updated : item)));
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmResetPassword = async (userId: string) => {
|
||||
if (!resetPwdVal.trim()) return;
|
||||
try {
|
||||
await api.adminResetPassword(userId, resetPwdVal.trim());
|
||||
alert('Passwort erfolgreich zurückgesetzt.');
|
||||
setResetPwdUserId(null);
|
||||
setResetPwdVal('');
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdminDeleteUser = async (userId: string) => {
|
||||
if (!window.confirm('Benutzer unwiderruflich löschen? Alle Daten dieses Nutzers werden gelöscht.')) return;
|
||||
try {
|
||||
await api.adminDeleteUser(userId);
|
||||
setAdminUsers((prev) => prev.filter((u) => u.id !== userId));
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const baseUrl = window.location.origin;
|
||||
const curlExample = `curl -X POST ${baseUrl}/api/v1/inbox \\
|
||||
-H "Authorization: Bearer ${newlyCreatedKey || apiKeys[0]?.keyPrefix || 'DEIN_API_KEY'}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"content": "Server-Backup abgeschlossen #homelab #backup", "priority": "high", "target": "task"}'`;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/75 backdrop-blur-sm flex items-center justify-center p-2 sm:p-4">
|
||||
<div className="w-full max-w-3xl bg-panel border border-border shadow-2xl flex flex-col max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-3 bg-canvas border-b border-border flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<SettingsIcon className="w-4 h-4 text-accent" />
|
||||
<h2 className="text-sm font-bold text-white tracking-tight">
|
||||
Konto-Einstellungen & Administration
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Navigation Tabs */}
|
||||
<div className="flex items-stretch border-b border-border bg-canvas overflow-x-auto no-scrollbar text-xs">
|
||||
<button
|
||||
onClick={() => setActiveTab('profile')}
|
||||
className={`h-9 px-3.5 flex items-center gap-1.5 font-medium border-r border-border transition-colors ${
|
||||
activeTab === 'profile' ? 'bg-panel text-white border-b-2 border-b-accent' : 'text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<UserIcon className="w-3.5 h-3.5" />
|
||||
<span>Profil & Sicherheit</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('alarms')}
|
||||
className={`h-9 px-3.5 flex items-center gap-1.5 font-medium border-r border-border transition-colors ${
|
||||
activeTab === 'alarms' ? 'bg-panel text-white border-b-2 border-b-accent' : 'text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Bell className="w-3.5 h-3.5" />
|
||||
<span>Alarme & Discord</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('webhooks')}
|
||||
className={`h-9 px-3.5 flex items-center gap-1.5 font-medium border-r border-border transition-colors ${
|
||||
activeTab === 'webhooks' ? 'bg-panel text-white border-b-2 border-b-accent' : 'text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Key className="w-3.5 h-3.5" />
|
||||
<span>Webhooks & Export</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('teams')}
|
||||
className={`h-9 px-3.5 flex items-center gap-1.5 font-medium border-r border-border transition-colors ${
|
||||
activeTab === 'teams' ? 'bg-panel text-white border-b-2 border-b-accent' : 'text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
<span>Organisationen & Teams</span>
|
||||
</button>
|
||||
|
||||
{currentUser?.role === 'admin' && (
|
||||
<button
|
||||
onClick={() => setActiveTab('admin')}
|
||||
className={`h-9 px-3.5 flex items-center gap-1.5 font-medium border-r border-border transition-colors ${
|
||||
activeTab === 'admin' ? 'bg-panel text-accent font-semibold border-b-2 border-b-accent' : 'text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Shield className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Administration</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-4 overflow-y-auto flex-1 space-y-4 text-xs">
|
||||
{/* TAB 1: PROFIL & SICHERHEIT */}
|
||||
{activeTab === 'profile' && (
|
||||
<div className="space-y-4 max-w-lg">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white mb-1">Benutzerprofil bearbeiten</h3>
|
||||
<p className="text-zinc-400 text-xs">
|
||||
Passe deinen Anzeigenamen und dein Anmeldekennwort an.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{profileMsg && (
|
||||
<div
|
||||
className={`p-2 border text-xs ${
|
||||
profileMsg.type === 'ok'
|
||||
? 'bg-blue-900/20 border-accent text-blue-200'
|
||||
: 'bg-red-900/20 border-red-500 text-red-200'
|
||||
}`}
|
||||
>
|
||||
{profileMsg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSaveProfile} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1">Anzeigename</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
required
|
||||
className="w-full h-8 px-2.5 input-field text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1">E-Mail-Adresse</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full h-8 px-2.5 input-field text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1">Neues Passwort (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-8 px-2.5 input-field text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLoggedOut}
|
||||
className="text-red-400 hover:underline flex items-center gap-1.5 py-1"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
<span>Abmelden</span>
|
||||
</button>
|
||||
|
||||
<button type="submit" className="btn-primary h-8 px-4 text-xs">
|
||||
Änderungen speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 2: ALARME & DISCORD */}
|
||||
{activeTab === 'alarms' && (
|
||||
<div className="space-y-4 max-w-lg">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white mb-1">Aufgaben-Alarme & Discord Benachrichtigungen</h3>
|
||||
<p className="text-zinc-400 text-xs leading-relaxed">
|
||||
Trage deine Discord Webhook URL ein, damit der MindSift Server dich automatisch erinnert, sobald Aufgaben fällig werden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{alarmMsg && (
|
||||
<div
|
||||
className={`p-2 border text-xs ${
|
||||
alarmMsg.type === 'ok'
|
||||
? 'bg-blue-900/20 border-accent text-blue-200'
|
||||
: 'bg-red-900/20 border-red-500 text-red-200'
|
||||
}`}
|
||||
>
|
||||
{alarmMsg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSaveAlarms} className="space-y-3">
|
||||
<div>
|
||||
<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 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-zinc-400 mb-1">Vorwarnzeit vor Fälligkeit</label>
|
||||
<select
|
||||
value={notifyBeforeMinutes}
|
||||
onChange={(e) => setNotifyBeforeMinutes(Number(e.target.value))}
|
||||
className="w-full h-8 px-2 input-field text-white bg-canvas"
|
||||
>
|
||||
<option value={0}>Direkt bei Fälligkeit</option>
|
||||
<option value={15}>15 Minuten vorher</option>
|
||||
<option value={30}>30 Minuten vorher</option>
|
||||
<option value={60}>1 Stunde vorher</option>
|
||||
<option value={1440}>1 Tag vorher</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestDiscordWebhook}
|
||||
disabled={testingDiscord || !discordWebhookUrl.trim()}
|
||||
className="btn-action h-8 px-3 text-xs disabled:opacity-40"
|
||||
>
|
||||
<span>{testingDiscord ? 'Sende Test…' : 'Webhook testen'}</span>
|
||||
</button>
|
||||
|
||||
<button type="submit" className="btn-primary h-8 px-4 text-xs">
|
||||
Alarm-Einstellungen sichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 3: WEBHOOKS & EXPORT */}
|
||||
{activeTab === 'webhooks' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white mb-1">Private Webhook Keys & Daten-Export</h3>
|
||||
<p className="text-zinc-400 text-xs">
|
||||
Erstelle API-Keys für eigene Bash-Skripte, Home Assistant oder Cronjobs, um Gedanken direkt per Curl zu erfassen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* API Key Creator */}
|
||||
<form onSubmit={handleCreateAPIKey} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="Key Bezeichnung (z.B. Smart Home, Backup Skript)…"
|
||||
className="flex-1 h-8 px-2.5 input-field text-xs text-white"
|
||||
/>
|
||||
<button type="submit" className="btn-primary h-8 px-3 text-xs flex items-center gap-1">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Key erstellen</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{newlyCreatedKey && (
|
||||
<div className="p-2.5 bg-accent/15 border border-accent/40 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] text-accent font-semibold">Neuer Key (jetzt kopieren):</p>
|
||||
<code className="text-xs font-mono text-white break-all">{newlyCreatedKey}</code>
|
||||
</div>
|
||||
<button onClick={() => handleCopy(newlyCreatedKey)} className="btn-primary h-7 px-2.5 text-xs">
|
||||
{copiedKey ? 'Kopiert!' : 'Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keys list */}
|
||||
<div className="border border-border divide-y divide-border bg-canvas">
|
||||
{apiKeys.length === 0 ? (
|
||||
<div className="p-3 text-center text-zinc-500 text-xs">Noch keine API-Schlüssel angelegt.</div>
|
||||
) : (
|
||||
apiKeys.map((k) => (
|
||||
<div key={k.id} className="p-2 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-white">{k.name}</p>
|
||||
<p className="text-[10px] text-zinc-500 font-mono">Präfix: {k.keyPrefix}••••••••</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteAPIKey(k.id)}
|
||||
className="btn-icon btn-icon-danger"
|
||||
title="Key löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Interactive Curl Example */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-semibold text-zinc-300 flex items-center gap-1">
|
||||
<Terminal className="w-3 h-3 text-accent" /> Curl Schnellbefehl für eigene Skripte:
|
||||
</span>
|
||||
<button onClick={() => handleCopy(curlExample)} className="text-zinc-400 hover:text-white text-[10px]">
|
||||
{copiedKey ? 'Kopiert!' : 'Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-2.5 bg-black/60 border border-border text-[11px] font-mono text-zinc-300 overflow-x-auto whitespace-pre">
|
||||
{curlExample}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Data Export Button */}
|
||||
<div className="pt-2 border-t border-border flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-semibold text-white">Vollständiger Daten-Export</h4>
|
||||
<p className="text-zinc-500 text-[11px]">Sichere alle Notizen, Aufgaben, Listen und Metadaten als JSON.</p>
|
||||
</div>
|
||||
<button onClick={handleExportData} className="btn-action h-8 px-3 text-xs flex items-center gap-1.5">
|
||||
<Download className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Backup herunterladen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 4: ORGANISATIONEN & TEAMS */}
|
||||
{activeTab === 'teams' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white mb-1">Organisationen & Teams gründen</h3>
|
||||
<p className="text-zinc-400 text-xs">
|
||||
Erstelle Organisationen und unterteile sie in Teams, um Aufgabenlisten und Workspace-Dokumente gemeinsam zu bearbeiten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Org Creator */}
|
||||
<div className="p-3 bg-canvas border border-border space-y-2">
|
||||
<h4 className="font-semibold text-white">Neue Organisation</h4>
|
||||
<form onSubmit={handleCreateOrg} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newOrgName}
|
||||
onChange={(e) => setNewOrgName(e.target.value)}
|
||||
placeholder="Name der Organisation (z.B. Familie, Startup, Homelab Crew)…"
|
||||
className="flex-1 h-8 px-2.5 input-field text-xs text-white"
|
||||
/>
|
||||
<button type="submit" className="btn-primary h-8 px-3 text-xs">
|
||||
Organisation anlegen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Team Creator inside Org */}
|
||||
{orgs.length > 0 && (
|
||||
<div className="p-3 bg-canvas border border-border space-y-2">
|
||||
<h4 className="font-semibold text-white">Neues Team in Organisation</h4>
|
||||
<form onSubmit={handleCreateTeam} className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={selectedOrgId}
|
||||
onChange={(e) => setSelectedOrgId(e.target.value)}
|
||||
className="h-8 px-2 input-field text-white bg-panel"
|
||||
>
|
||||
{orgs.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={newTeamName}
|
||||
onChange={(e) => setNewTeamName(e.target.value)}
|
||||
placeholder="Team Name (z.B. DevOps, Einkauf)…"
|
||||
required
|
||||
className="flex-1 h-8 px-2.5 input-field text-xs text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTeamDesc}
|
||||
onChange={(e) => setNewTeamDesc(e.target.value)}
|
||||
placeholder="Optionale Teambeschreibung…"
|
||||
className="flex-1 h-8 px-2.5 input-field text-xs text-white"
|
||||
/>
|
||||
<button type="submit" className="btn-primary h-8 px-3 text-xs">
|
||||
Team erstellen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Teams List */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold text-zinc-300">Deine Teams ({teams.length}):</h4>
|
||||
<div className="border border-border divide-y divide-border bg-canvas">
|
||||
{teams.length === 0 ? (
|
||||
<div className="p-3 text-center text-zinc-500">Du bist noch keinem Team beigetreten.</div>
|
||||
) : (
|
||||
teams.map((t) => (
|
||||
<div key={t.id} className="p-2.5 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-bold text-white flex items-center gap-1.5">
|
||||
<Users className="w-3.5 h-3.5 text-accent" />
|
||||
<span>{t.name}</span>
|
||||
</p>
|
||||
{t.description && <p className="text-zinc-400 text-[11px]">{t.description}</p>}
|
||||
</div>
|
||||
<span className="text-[10px] text-zinc-500 font-mono">ID: {t.id}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 5: ADMIN BEREICH */}
|
||||
{activeTab === 'admin' && currentUser?.role === 'admin' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white mb-1">Server-Administration & Benutzersteuerung</h3>
|
||||
<p className="text-zinc-400 text-xs">
|
||||
Steuere die Instanz-Einstellungen, das Sharing-Verhalten und verwalte registrierte Benutzer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Server Sharing Isolation Setting */}
|
||||
<div className="p-3 bg-canvas border border-border space-y-3">
|
||||
<h4 className="font-semibold text-white flex items-center gap-1.5">
|
||||
<Share2 className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Sharing-Isolationsmodus</span>
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUpdateSystemSettings({ sharingMode: 'open' })}
|
||||
className={`p-2.5 text-left border transition-colors ${
|
||||
systemSettings.sharingMode === 'open'
|
||||
? 'bg-panel border-accent text-white'
|
||||
: 'border-border text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs">Offen (Open)</p>
|
||||
<p className="text-[10px] text-zinc-400 mt-0.5">Alle Benutzer können Spaces finden und beitreten.</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUpdateSystemSettings({ sharingMode: 'teams_only' })}
|
||||
className={`p-2.5 text-left border transition-colors ${
|
||||
systemSettings.sharingMode === 'teams_only'
|
||||
? 'bg-panel border-accent text-white'
|
||||
: 'border-border text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs">Teams isoliert</p>
|
||||
<p className="text-[10px] text-zinc-400 mt-0.5">Sharing ausschließlich innerhalb zugewiesener Teams.</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUpdateSystemSettings({ sharingMode: 'strict_private' })}
|
||||
className={`p-2.5 text-left border transition-colors ${
|
||||
systemSettings.sharingMode === 'strict_private'
|
||||
? 'bg-panel border-accent text-white'
|
||||
: 'border-border text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs">Strikt Privat</p>
|
||||
<p className="text-[10px] text-zinc-400 mt-0.5">Vollständige Benutzer-Isolation; kein Teilen möglich.</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-border">
|
||||
<span className="text-zinc-300">Öffentliche Registrierung erlauben:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleUpdateSystemSettings({ allowRegistration: !systemSettings.allowRegistration })
|
||||
}
|
||||
className={`px-3 py-1 text-xs border ${
|
||||
systemSettings.allowRegistration
|
||||
? 'bg-accent/20 border-accent text-accent'
|
||||
: 'bg-zinc-800 border-zinc-700 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{systemSettings.allowRegistration ? 'Aktiviert' : 'Deaktiviert'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Creation by Admin */}
|
||||
<div className="p-3 bg-canvas border border-border space-y-2">
|
||||
<h4 className="font-semibold text-white">Neuen Benutzer anlegen</h4>
|
||||
<form onSubmit={handleAdminCreateUser} className="space-y-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<input
|
||||
type="email"
|
||||
value={newAdminUserEmail}
|
||||
onChange={(e) => setNewAdminUserEmail(e.target.value)}
|
||||
placeholder="E-Mail"
|
||||
required
|
||||
className="h-8 px-2.5 input-field text-white"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newAdminUserPassword}
|
||||
onChange={(e) => setNewAdminUserPassword(e.target.value)}
|
||||
placeholder="Initial-Passwort"
|
||||
required
|
||||
className="h-8 px-2.5 input-field text-white"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newAdminUserDisplayName}
|
||||
onChange={(e) => setNewAdminUserDisplayName(e.target.value)}
|
||||
placeholder="Anzeigename"
|
||||
className="flex-1 h-8 px-2.5 input-field text-white"
|
||||
/>
|
||||
<select
|
||||
value={newAdminUserRole}
|
||||
onChange={(e) => setNewAdminUserRole(e.target.value as any)}
|
||||
className="h-8 px-2 input-field text-white bg-panel"
|
||||
>
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button type="submit" className="btn-primary h-7 px-3 text-xs">
|
||||
Benutzer erstellen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold text-zinc-300">Registrierte Benutzer ({adminUsers.length}):</h4>
|
||||
<div className="border border-border divide-y divide-border bg-canvas overflow-x-auto">
|
||||
{adminUsers.map((u) => (
|
||||
<div key={u.id} className="p-2.5 flex items-center justify-between gap-3 text-xs">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white">{u.displayName}</span>
|
||||
<span
|
||||
className={`px-1.5 py-0.2 text-[10px] uppercase font-bold border ${
|
||||
u.role === 'admin'
|
||||
? 'bg-accent/20 border-accent/40 text-accent'
|
||||
: 'bg-zinc-800 border-zinc-700 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{u.role}
|
||||
</span>
|
||||
{!u.isActive && (
|
||||
<span className="px-1.5 py-0.2 text-[10px] bg-red-900/30 border border-red-500/40 text-red-300">
|
||||
Deaktiviert
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-zinc-500 text-[11px] font-mono">{u.email}</p>
|
||||
|
||||
{/* Reset password inline form */}
|
||||
{resetPwdUserId === u.id && (
|
||||
<div className="mt-2 flex items-center gap-1.5 bg-panel p-1.5 border border-border">
|
||||
<input
|
||||
type="password"
|
||||
value={resetPwdVal}
|
||||
onChange={(e) => setResetPwdVal(e.target.value)}
|
||||
placeholder="Neues Passwort…"
|
||||
className="h-6 px-2 input-field text-xs text-white"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleConfirmResetPassword(u.id)}
|
||||
className="btn-primary h-6 px-2 text-[10px]"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setResetPwdUserId(null)}
|
||||
className="btn-action h-6 px-1.5 text-[10px]"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleToggleUserActive(u)}
|
||||
className="btn-action h-7 px-2 text-[11px]"
|
||||
title="Aktiv / Deaktivieren"
|
||||
>
|
||||
{u.isActive ? 'Deaktivieren' : 'Aktivieren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggleUserRole(u)}
|
||||
className="btn-action h-7 px-2 text-[11px]"
|
||||
title="Rolle wechseln"
|
||||
>
|
||||
{u.role === 'admin' ? 'Zu Member' : 'Zu Admin'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setResetPwdUserId(u.id);
|
||||
setResetPwdVal('');
|
||||
}}
|
||||
className="btn-action h-7 px-2 text-[11px]"
|
||||
title="Passwort zurücksetzen"
|
||||
>
|
||||
<Lock className="w-3 h-3 text-zinc-400" />
|
||||
</button>
|
||||
{u.id !== currentUser.id && (
|
||||
<button
|
||||
onClick={() => handleAdminDeleteUser(u.id)}
|
||||
className="btn-icon btn-icon-danger"
|
||||
title="Benutzer löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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