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

This commit is contained in:
sandrodach committed 2026-10-24 22:59:48 +02:00
1 parent 50ac0c8f64
commit 7802eb2cf0
17 files changed
+4391 -1368

No files matched your search

@@ -0,0 +1,527 @@
import React, { useState } from 'react';
import {
Brain,
Shield,
Lock,
KeyRound,
UserPlus,
LogIn,
Server,
Share2,
Check,
AlertCircle,
} from 'lucide-react';
import { AuthStatus, User } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
interface SetupAndAuthViewProps {
authStatus: AuthStatus;
onAuthenticated: (user: User) => void;
}
export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
authStatus,
onAuthenticated,
}) => {
// --- Setup Wizard State ---
const [setupName, setSetupName] = useState('');
const [setupEmail, setSetupEmail] = useState('');
const [setupPassword, setSetupPassword] = useState('');
const [instanceName, setInstanceName] = useState(authStatus.instanceName || 'MindSift');
const [sharingMode, setSharingMode] = useState<'open' | 'teams_only' | 'strict_private'>('teams_only');
const [allowRegistration, setAllowRegistration] = useState(false);
const [createSampleData, setCreateSampleData] = useState(false);
// --- Login / Invite / Register State ---
const [authTab, setAuthTab] = useState<'login' | 'invite' | 'register'>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [inviteCode, setInviteCode] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCompleteSetup = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSubmitting(true);
triggerHaptic(15);
try {
const res = await api.completeSetup({
email: setupEmail.trim(),
password: setupPassword,
displayName: setupName.trim(),
instanceName: instanceName.trim() || 'MindSift',
sharingMode,
allowRegistration,
createSampleData,
});
onAuthenticated(res.user);
} catch (err: any) {
setError(err.message || 'Fehler bei der Ersteinrichtung');
} finally {
setSubmitting(false);
}
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSubmitting(true);
triggerHaptic(15);
try {
const res = await api.login(email.trim(), password);
onAuthenticated(res.user);
} catch (err: any) {
setError(err.message || 'Ungültige Anmeldedaten');
} finally {
setSubmitting(false);
}
};
const handleRegisterOrInvite = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSubmitting(true);
triggerHaptic(15);
try {
const code = authTab === 'invite' ? inviteCode.trim() : '';
const res = await api.register(email.trim(), password, displayName.trim(), code);
onAuthenticated(res.user);
} catch (err: any) {
setError(err.message || 'Registrierung fehlgeschlagen');
} finally {
setSubmitting(false);
}
};
// ============================================================================
// FIRST-BOOT SERVER SETUP WIZARD
// ============================================================================
if (authStatus.needsSetup) {
return (
<div className="min-h-screen bg-canvas text-white flex flex-col items-center justify-center p-4 font-sans">
<div className="w-full max-w-xl bg-panel border border-border shadow-2xl">
{/* Top Brand Bar */}
<div className="p-4 bg-canvas border-b border-border flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-accent text-white flex items-center justify-center">
<Brain className="w-4 h-4" />
</div>
<div>
<h1 className="text-base font-bold text-white tracking-tight leading-none">
MindSift Ersteinrichtung
</h1>
<p className="text-[11px] text-zinc-400 mt-0.5">
Initiales Administrator-Konto & Server-Konfiguration
</p>
</div>
</div>
<span className="px-2 py-0.5 bg-accent/15 border border-accent text-accent text-[10px] font-semibold uppercase tracking-wider">
First Boot
</span>
</div>
<form onSubmit={handleCompleteSetup} className="p-5 space-y-5 text-xs">
<p className="text-zinc-300 leading-relaxed">
Willkommen auf deiner neuen MindSift-Instanz. Es existiert noch kein Benutzerkonto in
der Datenbank. Lege jetzt das erste <strong>Server-Admin-Konto</strong> an und bestimme
die Sicherheitseinstellungen.
</p>
{error && (
<div className="p-3 bg-red-950/40 border border-red-500 text-red-200 flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0" />
<span>{error}</span>
</div>
)}
{/* Section 1: Admin Account */}
<div className="p-3.5 bg-canvas border border-border space-y-3">
<h2 className="text-xs font-bold text-white uppercase tracking-wider flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-accent" />
<span>1. Server-Administrator anlegen</span>
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-zinc-400 mb-1">Dein Anzeigename *</label>
<input
type="text"
value={setupName}
onChange={(e) => setSetupName(e.target.value)}
placeholder="z.B. Admin / Dein Name"
required
autoFocus
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 (Login) *</label>
<input
type="email"
value={setupEmail}
onChange={(e) => setSetupEmail(e.target.value)}
placeholder="admin@deinedomain.de"
required
className="w-full h-8 px-2.5 input-field text-white"
/>
</div>
</div>
<div>
<label className="block text-zinc-400 mb-1">Administrator-Passwort *</label>
<input
type="password"
value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)}
placeholder="Mindestens 4 Zeichen"
minLength={4}
required
className="w-full h-8 px-2.5 input-field text-white"
/>
</div>
</div>
{/* Section 2: Server & Isolation */}
<div className="p-3.5 bg-canvas border border-border space-y-3">
<h2 className="text-xs font-bold text-white uppercase tracking-wider flex items-center gap-1.5">
<Server className="w-3.5 h-3.5 text-accent" />
<span>2. Instanz & Berechtigungen</span>
</h2>
<div>
<label className="block text-zinc-400 mb-1">Name der Instanz</label>
<input
type="text"
value={instanceName}
onChange={(e) => setInstanceName(e.target.value)}
placeholder="MindSift"
className="w-full h-8 px-2.5 input-field text-white"
/>
</div>
<div>
<label className="block text-zinc-400 mb-1.5 flex items-center gap-1">
<Share2 className="w-3 h-3 text-accent" />
<span>Sichtbarkeit & Sharing-Isolation zwischen Benutzern</span>
</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<button
type="button"
onClick={() => setSharingMode('strict_private')}
className={`p-2.5 text-left border transition-colors ${
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">
Keiner sieht andere Nutzer. Komplett isoliert.
</p>
</button>
<button
type="button"
onClick={() => setSharingMode('teams_only')}
className={`p-2.5 text-left border transition-colors ${
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">
Nutzer sehen nur Mitglieder ihrer eigenen Teams.
</p>
</button>
<button
type="button"
onClick={() => setSharingMode('open')}
className={`p-2.5 text-left border transition-colors ${
sharingMode === 'open'
? 'bg-panel border-accent text-white'
: 'border-border text-zinc-400 hover:text-white'
}`}
>
<p className="font-bold text-xs">Alle sehen alle</p>
<p className="text-[10px] text-zinc-400 mt-0.5">
Offene Zusammenarbeit auf dem gesamten Server.
</p>
</button>
</div>
</div>
<div className="pt-2 border-t border-border space-y-2">
<label className="flex items-center justify-between cursor-pointer select-none">
<div>
<span className="font-medium text-white block">
Öffentliche Selbstregistrierung erlauben
</span>
<span className="text-[11px] text-zinc-400">
Wenn deaktiviert, können neue Nutzer nur per Einladungscode oder durch einen Admin angelegt werden.
</span>
</div>
<input
type="checkbox"
checked={allowRegistration}
onChange={(e) => setAllowRegistration(e.target.checked)}
className="w-4 h-4 accent-blue-600"
/>
</label>
<label className="flex items-center justify-between cursor-pointer select-none pt-1">
<div>
<span className="font-medium text-white block">
Start-Beispieldaten anlegen
</span>
<span className="text-[11px] text-zinc-400">
Erstellt wenige Beispiel-Gedanken und ein Einführungsdokument zum Testen.
</span>
</div>
<input
type="checkbox"
checked={createSampleData}
onChange={(e) => setCreateSampleData(e.target.checked)}
className="w-4 h-4 accent-blue-600"
/>
</label>
</div>
</div>
<div className="flex items-center justify-end pt-1">
<button
type="submit"
disabled={submitting}
className="btn-primary h-9 px-5 text-xs font-semibold flex items-center gap-2 disabled:opacity-50"
>
<Check className="w-4 h-4" />
<span>{submitting ? 'Richte Server ein…' : 'Ersteinrichtung abschließen & Starten'}</span>
</button>
</div>
</form>
</div>
</div>
);
}
// ============================================================================
// STANDARD LOGIN / INVITE CODE / REGISTRATION VIEW
// ============================================================================
return (
<div className="min-h-screen bg-canvas text-white flex flex-col items-center justify-center p-4 font-sans">
<div className="w-full max-w-md bg-panel border border-border shadow-2xl">
{/* Top Header */}
<div className="p-4 bg-canvas border-b border-border flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 bg-accent text-white flex items-center justify-center">
<Brain className="w-4 h-4" />
</div>
<div>
<h1 className="text-sm font-bold text-white tracking-tight leading-none">
{authStatus.instanceName || 'MindSift'}
</h1>
<p className="text-[11px] text-zinc-400 mt-0.5">
Der Kopf frei in drei Sekunden
</p>
</div>
</div>
<Lock className="w-4 h-4 text-zinc-500" />
</div>
{/* Navigation Tabs */}
<div className="flex border-b border-border bg-canvas text-xs">
<button
type="button"
onClick={() => {
setAuthTab('login');
setError(null);
}}
className={`flex-1 h-9 px-3 font-medium flex items-center justify-center gap-1.5 border-r border-border transition-colors ${
authTab === 'login'
? 'bg-panel text-white border-b-2 border-b-accent'
: 'text-zinc-400 hover:text-white'
}`}
>
<LogIn className="w-3.5 h-3.5" />
<span>Anmelden</span>
</button>
<button
type="button"
onClick={() => {
setAuthTab('invite');
setError(null);
}}
className={`flex-1 h-9 px-3 font-medium flex items-center justify-center gap-1.5 border-r border-border transition-colors ${
authTab === 'invite'
? 'bg-panel text-white border-b-2 border-b-accent'
: 'text-zinc-400 hover:text-white'
}`}
>
<KeyRound className="w-3.5 h-3.5" />
<span>Einladungscode</span>
</button>
{authStatus.allowRegistration && (
<button
type="button"
onClick={() => {
setAuthTab('register');
setError(null);
}}
className={`flex-1 h-9 px-3 font-medium flex items-center justify-center gap-1.5 transition-colors ${
authTab === 'register'
? 'bg-panel text-white border-b-2 border-b-accent'
: 'text-zinc-400 hover:text-white'
}`}
>
<UserPlus className="w-3.5 h-3.5" />
<span>Registrieren</span>
</button>
)}
</div>
{/* Form Body */}
<div className="p-5 text-xs">
{error && (
<div className="mb-4 p-2.5 bg-red-950/40 border border-red-500 text-red-200 flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0" />
<span>{error}</span>
</div>
)}
{authTab === 'login' && (
<form onSubmit={handleLogin} className="space-y-3.5">
<div>
<label className="block text-zinc-400 mb-1">E-Mail-Adresse</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@beispiel.de"
required
autoFocus
className="w-full h-8 px-2.5 input-field text-white"
/>
</div>
<div>
<label className="block text-zinc-400 mb-1">Passwort</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
className="w-full h-8 px-2.5 input-field text-white"
/>
</div>
<div className="pt-1">
<button
type="submit"
disabled={submitting}
className="w-full btn-primary h-9 text-xs font-semibold flex items-center justify-center gap-2 disabled:opacity-50"
>
<LogIn className="w-3.5 h-3.5" />
<span>{submitting ? 'Melde an…' : 'Anmelden'}</span>
</button>
</div>
{!authStatus.allowRegistration && (
<p className="text-[11px] text-zinc-500 text-center pt-2 border-t border-border">
Öffentliche Registrierung ist deaktiviert. Zugang nur mit Konto oder{' '}
<button
type="button"
onClick={() => setAuthTab('invite')}
className="text-accent hover:underline"
>
Einladungscode
</button>
.
</p>
)}
</form>
)}
{(authTab === 'invite' || authTab === 'register') && (
<form onSubmit={handleRegisterOrInvite} className="space-y-3.5">
{authTab === 'invite' && (
<div>
<label className="block text-zinc-400 mb-1">Einladungscode *</label>
<input
type="text"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value.toUpperCase())}
placeholder="MS-XXXXXXXX"
required
autoFocus
className="w-full h-8 px-2.5 input-field text-white font-mono uppercase"
/>
</div>
)}
<div>
<label className="block text-zinc-400 mb-1">Anzeigename *</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Dein Name"
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)}
placeholder="name@beispiel.de"
required
className="w-full h-8 px-2.5 input-field text-white"
/>
</div>
<div>
<label className="block text-zinc-400 mb-1">Passwort *</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Mindestens 4 Zeichen"
minLength={4}
required
className="w-full h-8 px-2.5 input-field text-white"
/>
</div>
<div className="pt-1">
<button
type="submit"
disabled={submitting}
className="w-full btn-primary h-9 text-xs font-semibold flex items-center justify-center gap-2 disabled:opacity-50"
>
<UserPlus className="w-3.5 h-3.5" />
<span>
{submitting
? 'Konto wird erstellt…'
: authTab === 'invite'
? 'Einladung einlösen & Konto erstellen'
: 'Konto registrieren'}
</span>
</button>
</div>
</form>
)}
</div>
</div>
</div>
);
};
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>
);
};
+20 -15
View File
@@ -60,15 +60,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
setLoading(true);
try {
const [allTasks, allLists, allSpaces, allDiscarded] = await Promise.all([
api.getTasks(selectedListId === 'all' ? undefined : selectedListId),
api.getTaskLists(),
api.getSpaces(),
api.getThoughts('discarded'),
api.getTasks(selectedListId === 'all' ? undefined : selectedListId).catch(() => []),
api.getTaskLists().catch(() => []),
api.getSpaces().catch(() => []),
api.getThoughts('discarded').catch(() => []),
]);
setTasks(allTasks);
setTaskLists(allLists);
setSpaces(allSpaces);
setDiscarded(allDiscarded);
setTasks(Array.isArray(allTasks) ? allTasks : []);
setTaskLists(Array.isArray(allLists) ? allLists : []);
setSpaces(Array.isArray(allSpaces) ? allSpaces : []);
setDiscarded(Array.isArray(allDiscarded) ? allDiscarded : []);
} catch {
// Prevent unhandled promise rejection from crashing view
} finally {
setLoading(false);
}
@@ -190,14 +192,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
}
};
const openTasks = tasks.filter((t) => !t.isCompleted);
const doneTasks = tasks.filter((t) => t.isCompleted);
const safeTasks = Array.isArray(tasks) ? tasks : [];
const safeTaskLists = Array.isArray(taskLists) ? taskLists : [];
const todoKanban = tasks.filter((t) => (!t.kanbanStatus || t.kanbanStatus === 'todo') && !t.isCompleted);
const inProgressKanban = tasks.filter((t) => t.kanbanStatus === 'in_progress' && !t.isCompleted);
const doneKanban = tasks.filter((t) => t.kanbanStatus === 'done' || t.isCompleted);
const openTasks = safeTasks.filter((t) => !t.isCompleted);
const doneTasks = safeTasks.filter((t) => t.isCompleted);
const currentSelectedList = taskLists.find((l) => l.id === selectedListId);
const todoKanban = safeTasks.filter((t) => (!t.kanbanStatus || t.kanbanStatus === 'todo') && !t.isCompleted);
const inProgressKanban = safeTasks.filter((t) => t.kanbanStatus === 'in_progress' && !t.isCompleted);
const doneKanban = safeTasks.filter((t) => t.kanbanStatus === 'done' || t.isCompleted);
const currentSelectedList = safeTaskLists.find((l) => l.id === selectedListId);
return (
<div className="w-full space-y-3">
@@ -216,7 +221,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
Alle Aufgaben
</button>
{taskLists.map((tl) => (
{safeTaskLists.map((tl) => (
<div key={tl.id} className="relative group flex items-center">
<button
onClick={() => setSelectedListId(tl.id)}