From 7802eb2cf01bf0ca2a8854a04fc2f6d900ab541b Mon Sep 17 00:00:00 2001 From: Sandro Date: Sat, 24 Oct 2026 22:59:48 +0200 Subject: [PATCH] feat(auth,settings,tasks): add first-boot setup, real login/invites, full-page settings & admin views, multi-channel alarms, and fix null slice crashes --- .env.example | 10 +- BRAIN.md | 30 +- cmd/server/main.go | 6 +- docker-compose.yml | 9 +- frontend/src/App.tsx | 396 +++-- .../src/features/auth/SetupAndAuthView.tsx | 527 +++++++ .../features/settings/AdminSettingsView.tsx | 1317 +++++++++++++++++ .../src/features/settings/SettingsModal.tsx | 953 ------------ .../features/settings/UserSettingsView.tsx | 726 +++++++++ frontend/src/features/triage/TasksView.tsx | 35 +- frontend/src/lib/api.ts | 253 +++- frontend/src/lib/types.ts | 34 +- internal/api/server.go | 683 ++++++++- internal/api/server_test.go | 180 ++- internal/config/config.go | 23 +- internal/domain/models.go | 54 +- internal/storage/sqlite.go | 523 +++++-- 17 files changed, 4391 insertions(+), 1368 deletions(-) create mode 100644 frontend/src/features/auth/SetupAndAuthView.tsx create mode 100644 frontend/src/features/settings/AdminSettingsView.tsx delete mode 100644 frontend/src/features/settings/SettingsModal.tsx create mode 100644 frontend/src/features/settings/UserSettingsView.tsx diff --git a/.env.example b/.env.example index 765196e..bf4a4d8 100644 --- a/.env.example +++ b/.env.example @@ -4,5 +4,13 @@ DATA_DIR=/data DATABASE_URL=sqlite:///data/mindsift.db JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars BASE_URL=http://localhost:8080 -ALLOW_REGISTRATION=true +ALLOW_REGISTRATION=false LOG_LEVEL=info + +# Optional SMTP Server Configuration for Email Alarms (can also be set in Admin Settings UI) +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_FROM=noreply@mindsift.local +SMTP_TLS=true diff --git a/BRAIN.md b/BRAIN.md index c232d29..594bba6 100644 --- a/BRAIN.md +++ b/BRAIN.md @@ -22,25 +22,31 @@ - Beliebig viele Aufgabenlisten (z.B. Privat, Arbeit, Homelab) mit eigenem Farb- und Icon-Code. - Teilen von Aufgabenlisten mit Teams/Spaces. - Umschalter zwischen kompakter Listenansicht und 3-Spalten-Kanban-Board (`Zu erledigen`, `In Arbeit`, `Erledigt`) mit 1-Klick-Statuswechsel, Fälligkeitsdaten und Notizfeldern. -- [x] **Benutzer- & Rollensteuerung + Admin-Panel (`SettingsModal.tsx`, `server.go`, `sqlite.go`):** - - Benutzerrollen: `admin` und `member`. - - Admin-Verwaltungsbereich zur Erstellung von Accounts, Rollenwechsel, Deaktivierung und Passwort-Reset. - - Server-weite Sharing-Isolations-Modi: `open` (alle finden sich), `teams_only` (nur innerhalb von Teams/Organisationen sichtbar) und `strict_private` (kein Teilen). - - Organisationen & Teams zur Strukturierung von Benutzergruppen. +- [x] **Ersteinrichtung (First-Boot Setup Wizard), Echter Login & Einladungscodes (`SetupAndAuthView.tsx`, `server.go`, `sqlite.go`):** + - Kein hartcodierter Auto-Seed-Benutzer (`admin@mindsift.local` / `"Sandro"`) und kein automatischer `quickStart`-Login mehr beim Seitenaufruf. + - Beim ersten Start (`0` Benutzer in der Datenbank) erkennt `GET /api/v1/auth/status` automatisch `needsSetup: true` und zeigt den **Ersteinrichtungs-Assistenten** (`POST /api/v1/auth/setup`) zum Anlegen des ersten Server-Admin-Kontos und der Instanz-Grundeinstellungen. + - Öffentliche Selbstregistrierung ist standardmäßig deaktiviert (`allowRegistration: false`). Neue Benutzer können nur über einen **Einladungscode** (`MS-XXXXXXXX`, Tabelle `user_invites`) oder direkt durch einen Admin (`admin` / `team_admin`) angelegt werden. +- [x] **Profile-Dropdown & Vollbild-Einstellungsseiten statt kleinem Modal (`App.tsx`, `UserSettingsView.tsx`, `AdminSettingsView.tsx`):** + - Klick auf den Benutzer oben rechts öffnet ein eckiges, monochromes Dropdown-Menü: + - **Kontoeinstellungen** (`UserSettingsView.tsx` — Vollbild-Ansicht für Profil & Passwort, Multi-Channel-Alarme, Private Webhooks & Daten-Export). + - **Administration** (`AdminSettingsView.tsx` — nur sichtbar für `admin` und `team_admin`; Vollbild-Ansicht für Benutzer, Rollen, Einladungscodes, Organisationen & Teams, Server-Isolation und SMTP-Mailserver). + - **Abmelden** (bereinigt JWT-Token und führt zurück zum Login-Bildschirm). + - 3-stufiges Rollensystem: `admin` (Server-Admin), `team_admin` (Team-Admin mit Berechtigung zum Einladen und Verwalten von Teams) und `member` (Mitglied). + - Layout-Fix im Benutzer-Erstellungsformular (`grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3` mit `min-w-0`), sodass das Rollen-Dropdown niemals über den Container hinausragt. +- [x] **Multi-Channel-Alarme: E-Mail (SMTP), Discord & Eigener Webhook (`UserSettingsView.tsx`, `AdminSettingsView.tsx`, `server.go`):** + - Tab heißt klar **Alarme** mit frei kombinierbaren Zielen (`alarmChannels`: `'email'`, `'discord'`, `'webhook'`). + - SMTP-Konfiguration (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS`) sowohl über `docker-compose.yml` Environment-Variablen als auch direkt in der Admin-Oberfläche (`system_settings` in SQLite) einstellbar, inklusive Live-Testversand. +- [x] **Crash-Fix für „Aufgaben" & „Organisationen & Teams" (`sqlite.go`, `api.ts`, `TasksView.tsx`):** + - Alle Go-Storage-Methoden initialisieren Slices strikt mit `make([]T, 0)` statt `var s []T`, damit `json.Marshal` niemals `null` statt `[]` liefert. + - Zusätzliche `ensureArray()`-Normalisierung in `frontend/src/lib/api.ts` sowie `ViewErrorBoundary` in `App.tsx`. - [x] **Workspace Markdown-Wiki mit Notion-/Word-Toolbar & Disk-Sync (`WorkspaceView.tsx`, `sqlite.go`):** - Word-/Notion-artige Formatierungsleiste oben (Fett, Kursiv, Durchgestrichen, Code, Links, Überschriften H1-H3, Checklisten, Aufzählungen, Callouts, Markdown-Tabellen, Fortschrittsbalken). - Interne Wiki-Links (`[[Dokumenttitel]]`) mit Klick-Navigation und Auto-Erkennung. - Physische Speicherung auf Festplatte unter `/data/workspace//...` als Standard-`.md`-Dateien zur einfachen Daten-Extraktion, synchronisiert mit SQLite. -- [x] **Webhooks, Alarm-Scheduler & Discord-Integration (`SettingsModal.tsx`, `server.go`):** - - "Webhooks & Export" aus dem Header in das Benutzereinstellungen-Modal verlagert. - - Eigene API-Tokens mit curl-Beispielen für Skripte und externe Alarme. - - Hintergrund-Goroutine im Backend prüft fällige Aufgaben und sendet rechtzeitig (z.B. 15, 30 oder 60 Min. vor Fälligkeit) formatierte Discord-Embeds via Webhook. - - Vollständiger JSON-DB-Export auf Knopfdruck. - [x] **Cross-Browser & Performance Fixes (Chrome & Firefox):** - SQLite Connection-Pool Deadlock durch striktes Schließen von `sql.Rows` vor Unterabfragen behoben. - - Stale Token Auto-Resolution via `EnsureDefaultUser()`. - `Cache-Control: no-store` auf allen API-Routen und `index.html`. - - Optimistisches Leeren des Capture-Inputs und 8-Sekunden-Fetch-Timeout. + - Optimistisches Leeren des Capture-Inputs und 12-Sekunden-Fetch-Timeout. --- diff --git a/cmd/server/main.go b/cmd/server/main.go index 201bdf6..da1b50e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -31,10 +31,8 @@ func main() { } defer store.Close() - // Ensure initial default workspace exists on fresh self-hosted boot - if _, err := store.EnsureDefaultUser(); err != nil { - log.Printf("Warning: could not seed default user: %v", err) - } + // Seed SMTP defaults from environment variables into DB if not yet configured in DB + store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS) wsHub := ws.NewHub() diff --git a/docker-compose.yml b/docker-compose.yml index d257c66..d06a86f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,5 +15,12 @@ services: - DATA_DIR=/data - JWT_SECRET=${JWT_SECRET:-change_this_to_a_secure_random_key_min_32_chars} - BASE_URL=${BASE_URL:-http://localhost:8080} - - ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-true} + - ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false} - LOG_LEVEL=${LOG_LEVEL:-info} + # Optional SMTP / Mail Configuration (can also be configured in Admin UI) + - SMTP_HOST=${SMTP_HOST:-} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USER=${SMTP_USER:-} + - SMTP_PASS=${SMTP_PASS:-} + - SMTP_FROM=${SMTP_FROM:-} + - SMTP_TLS=${SMTP_TLS:-true} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b7c874d..f09118e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,23 +1,83 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Layers, CheckSquare, FileText, ShoppingCart, Brain, + Settings, + Shield, + LogOut, + ChevronDown, + RotateCcw, } from 'lucide-react'; -import { DashboardStats, SharedList, Thought, User } from './lib/types'; +import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types'; import { api, clearToken, getToken, triggerHaptic } from './lib/api'; import { InstantCaptureBar } from './features/capture/InstantCaptureBar'; import { TriageDeck } from './features/triage/TriageDeck'; import { TasksView } from './features/triage/TasksView'; import { WorkspaceView } from './features/workspace/WorkspaceView'; import { SharedListsView } from './features/lists/SharedListsView'; -import { SettingsModal } from './features/settings/SettingsModal'; +import { SetupAndAuthView } from './features/auth/SetupAndAuthView'; +import { UserSettingsView } from './features/settings/UserSettingsView'; +import { AdminSettingsView } from './features/settings/AdminSettingsView'; -type ActiveTab = 'triage' | 'tasks' | 'workspace' | 'lists'; +type ActiveTab = + | 'triage' + | 'tasks' + | 'workspace' + | 'lists' + | 'user_settings' + | 'admin_settings'; + +interface ErrorBoundaryState { + hasError: boolean; + errorText: string; +} + +class ViewErrorBoundary extends React.Component< + { children: React.ReactNode; onReset: () => void }, + ErrorBoundaryState +> { + constructor(props: { children: React.ReactNode; onReset: () => void }) { + super(props); + this.state = { hasError: false, errorText: '' }; + } + + static getDerivedStateFromError(err: Error): ErrorBoundaryState { + return { hasError: true, errorText: err?.message || 'Unbekannter Darstellungsfehler' }; + } + + render() { + if (this.state.hasError) { + return ( +
+

Ansicht konnte nicht geladen werden

+

{this.state.errorText}

+ +
+ ); + } + return this.props.children; + } +} export const App: React.FC = () => { + const [bootLoading, setBootLoading] = useState(true); + const [authStatus, setAuthStatus] = useState({ + needsSetup: false, + allowRegistration: false, + instanceName: 'MindSift', + }); const [user, setUser] = useState(null); const [stats, setStats] = useState({ inboxCount: 0, @@ -34,7 +94,9 @@ export const App: React.FC = () => { const [captureFocusCount, setCaptureFocusCount] = useState(0); const [refreshRevision, setRefreshRevision] = useState(0); - const [showProfileModal, setShowProfileModal] = useState(false); + // Profile Dropdown State + const [showProfileMenu, setShowProfileMenu] = useState(false); + const dropdownRef = useRef(null); const refreshAll = async () => { try { @@ -45,49 +107,85 @@ export const App: React.FC = () => { ]); setUser(meRes.user); setStats(meRes.stats); - setDeck(deckRes); - setLists(listsRes); + setDeck(Array.isArray(deckRes) ? deckRes : []); + setLists(Array.isArray(listsRes) ? listsRes : []); } catch { - // Fallback to quickStart session - try { - const qs = await api.quickStart(); - setUser(qs.user); - const [st, dk, ls] = await Promise.all([ - api.getStats(), - api.getTriageDeck(), - api.getAllLists(), - ]); - setStats(st); - setDeck(dk); - setLists(ls); - } catch { - // Offline or waiting for server + // If token expired or invalid, api.ts dispatches mindsift:unauthorized + } + }; + + const checkBootAndAuth = async () => { + setBootLoading(true); + try { + const st = await api.getAuthStatus(); + setAuthStatus(st); + + if (st.needsSetup) { + clearToken(); + setUser(null); + return; } + + if (getToken()) { + await api.flushOfflineQueue(); + await refreshAll(); + } else { + setUser(null); + } + } catch { + // Server temporarily unreachable + } finally { + setBootLoading(false); } }; useEffect(() => { - const init = async () => { - if (!getToken()) { - try { - await api.quickStart(); - } catch { - // ignore - } + checkBootAndAuth(); + + const onUnauthorized = async () => { + setUser(null); + setShowProfileMenu(false); + try { + const st = await api.getAuthStatus(); + setAuthStatus(st); + } catch { + // ignore } - await api.flushOfflineQueue(); - await refreshAll(); }; - init(); const onOnline = async () => { - const synced = await api.flushOfflineQueue(); - if (synced > 0) refreshAll(); + if (getToken()) { + const synced = await api.flushOfflineQueue(); + if (synced > 0) refreshAll(); + } }; + + window.addEventListener('mindsift:unauthorized', onUnauthorized); window.addEventListener('online', onOnline); - return () => window.removeEventListener('online', onOnline); + return () => { + window.removeEventListener('mindsift:unauthorized', onUnauthorized); + window.removeEventListener('online', onOnline); + }; }, []); + // Close profile dropdown on outside click + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setShowProfileMenu(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleAuthenticated = async (loggedInUser: User) => { + setUser(loggedInUser); + setAuthStatus((prev) => ({ ...prev, needsSetup: false })); + setActiveTab('triage'); + await refreshAll(); + }; + const handleCaptured = ( thought: Thought, target: 'inbox' | 'task' | 'list_item' | 'document' = 'inbox' @@ -110,7 +208,6 @@ export const App: React.FC = () => { const handleTriggerAddButton = () => { triggerHaptic(20); - // Scroll smoothly to top and focus capture bar window.scrollTo({ top: 0, behavior: 'smooth' }); setCaptureFocusCount((c) => c + 1); }; @@ -118,11 +215,43 @@ export const App: React.FC = () => { const handleLogout = async () => { triggerHaptic(20); clearToken(); - await api.quickStart(); - await refreshAll(); - setShowProfileModal(false); + setUser(null); + setShowProfileMenu(false); + setActiveTab('triage'); + try { + const st = await api.getAuthStatus(); + setAuthStatus(st); + } catch { + // ignore + } }; + if (bootLoading) { + return ( +
+ Verbinde mit MindSift… +
+ ); + } + + // Show Setup Wizard or Login Screen if needsSetup or not logged in + if (authStatus.needsSetup || !user) { + return ( + + ); + } + + const isStaff = user.role === 'admin' || user.role === 'team_admin'; + const roleLabel = + user.role === 'admin' + ? 'Server-Admin' + : user.role === 'team_admin' + ? 'Team-Admin' + : 'Mitglied'; + return (
{/* Top Header Bar */} @@ -141,7 +270,7 @@ export const App: React.FC = () => {
- MindSift + {authStatus.instanceName || 'MindSift'} Der Kopf frei in drei Sekunden @@ -158,21 +287,83 @@ export const App: React.FC = () => { />
- {/* User Profile Pill */} -
+ {/* User Profile Pill & Dropdown Menu */} +
+ + {showProfileMenu && ( +
+ {/* User Info Header */} +
+

{user.displayName}

+

{user.email}

+ + {roleLabel} + +
+ + {/* Menu Items */} +
+ + + {isStaff && ( + + )} +
+ + {/* Logout */} +
+ +
+
+ )}
@@ -189,7 +380,13 @@ export const App: React.FC = () => { Triage {stats.inboxCount > 0 && ( - + {stats.inboxCount} )} @@ -205,7 +402,13 @@ export const App: React.FC = () => { Aufgaben {stats.tasksOpenCount > 0 && ( - + {stats.tasksOpenCount} )} @@ -220,7 +423,13 @@ export const App: React.FC = () => { > Workspace - + {stats.docsCount} @@ -235,58 +444,63 @@ export const App: React.FC = () => { Geteilte Listen - {/* Main Container */}
- {/* Tab View Content */} -
- {activeTab === 'triage' && ( - - )} + setActiveTab('triage')}> +
+ {activeTab === 'triage' && ( + + )} - {activeTab === 'tasks' && ( - - )} + {activeTab === 'tasks' && ( + + )} - {activeTab === 'workspace' && ( - - )} + {activeTab === 'workspace' && ( + + )} - {activeTab === 'lists' && ( - - )} -
+ {activeTab === 'lists' && ( + + )} + + {activeTab === 'user_settings' && ( + { + setUser(u); + refreshAll(); + }} + onBack={() => setActiveTab('triage')} + /> + )} + + {activeTab === 'admin_settings' && isStaff && ( + setActiveTab('triage')} + /> + )} +
+
- - {/* Profile, Settings, Discord Alarms, Webhooks, Teams & Admin Modal */} - {showProfileModal && ( - setShowProfileModal(false)} - onUserUpdated={(u) => { - setUser(u); - refreshAll(); - }} - onLoggedOut={handleLogout} - /> - )} ); }; diff --git a/frontend/src/features/auth/SetupAndAuthView.tsx b/frontend/src/features/auth/SetupAndAuthView.tsx new file mode 100644 index 0000000..1392c7c --- /dev/null +++ b/frontend/src/features/auth/SetupAndAuthView.tsx @@ -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 = ({ + 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(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 ( +
+
+ {/* Top Brand Bar */} +
+
+
+ +
+
+

+ MindSift Ersteinrichtung +

+

+ Initiales Administrator-Konto & Server-Konfiguration +

+
+
+ + First Boot + +
+ +
+

+ Willkommen auf deiner neuen MindSift-Instanz. Es existiert noch kein Benutzerkonto in + der Datenbank. Lege jetzt das erste Server-Admin-Konto an und bestimme + die Sicherheitseinstellungen. +

+ + {error && ( +
+ + {error} +
+ )} + + {/* Section 1: Admin Account */} +
+

+ + 1. Server-Administrator anlegen +

+ +
+
+ + setSetupName(e.target.value)} + placeholder="z.B. Admin / Dein Name" + required + autoFocus + className="w-full h-8 px-2.5 input-field text-white" + /> +
+
+ + setSetupEmail(e.target.value)} + placeholder="admin@deinedomain.de" + required + className="w-full h-8 px-2.5 input-field text-white" + /> +
+
+ +
+ + setSetupPassword(e.target.value)} + placeholder="Mindestens 4 Zeichen" + minLength={4} + required + className="w-full h-8 px-2.5 input-field text-white" + /> +
+
+ + {/* Section 2: Server & Isolation */} +
+

+ + 2. Instanz & Berechtigungen +

+ +
+ + setInstanceName(e.target.value)} + placeholder="MindSift" + className="w-full h-8 px-2.5 input-field text-white" + /> +
+ +
+ +
+ + + + + +
+
+ +
+ + + +
+
+ +
+ +
+
+
+
+ ); + } + + // ============================================================================ + // STANDARD LOGIN / INVITE CODE / REGISTRATION VIEW + // ============================================================================ + return ( +
+
+ {/* Top Header */} +
+
+
+ +
+
+

+ {authStatus.instanceName || 'MindSift'} +

+

+ Der Kopf frei in drei Sekunden +

+
+
+ +
+ + {/* Navigation Tabs */} +
+ + + + + {authStatus.allowRegistration && ( + + )} +
+ + {/* Form Body */} +
+ {error && ( +
+ + {error} +
+ )} + + {authTab === 'login' && ( +
+
+ + setEmail(e.target.value)} + placeholder="name@beispiel.de" + required + autoFocus + className="w-full h-8 px-2.5 input-field text-white" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + required + className="w-full h-8 px-2.5 input-field text-white" + /> +
+ +
+ +
+ + {!authStatus.allowRegistration && ( +

+ Öffentliche Registrierung ist deaktiviert. Zugang nur mit Konto oder{' '} + + . +

+ )} +
+ )} + + {(authTab === 'invite' || authTab === 'register') && ( +
+ {authTab === 'invite' && ( +
+ + 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" + /> +
+ )} + +
+ + setDisplayName(e.target.value)} + placeholder="Dein Name" + required + className="w-full h-8 px-2.5 input-field text-white" + /> +
+ +
+ + setEmail(e.target.value)} + placeholder="name@beispiel.de" + required + className="w-full h-8 px-2.5 input-field text-white" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Mindestens 4 Zeichen" + minLength={4} + required + className="w-full h-8 px-2.5 input-field text-white" + /> +
+ +
+ +
+
+ )} +
+
+
+ ); +}; diff --git a/frontend/src/features/settings/AdminSettingsView.tsx b/frontend/src/features/settings/AdminSettingsView.tsx new file mode 100644 index 0000000..0ee01c9 --- /dev/null +++ b/frontend/src/features/settings/AdminSettingsView.tsx @@ -0,0 +1,1317 @@ +import React, { useEffect, useState } from 'react'; +import { + Shield, + Users, + Server, + Mail, + Plus, + Trash2, + KeyRound, + Copy, + Check, + ArrowLeft, + Share2, + Send, + AlertCircle, + Building2, + UserPlus, +} from 'lucide-react'; +import { + Organization, + SystemSettings, + Team, + User, + UserInvite, + UserRole, +} from '../../lib/types'; +import { api, triggerHaptic } from '../../lib/api'; + +interface AdminSettingsViewProps { + currentUser: User; + onBack: () => void; +} + +type AdminSection = 'users' | 'orgs' | 'server' | 'smtp'; + +export const AdminSettingsView: React.FC = ({ + currentUser, + onBack, +}) => { + const isServerAdmin = currentUser.role === 'admin'; + const [section, setSection] = useState('users'); + + // --- Users & Invites State --- + const [users, setUsers] = useState([]); + const [invites, setInvites] = useState([]); + const [newUserName, setNewUserName] = useState(''); + const [newUserEmail, setNewUserEmail] = useState(''); + const [newUserPassword, setNewUserPassword] = useState(''); + const [newUserRole, setNewUserRole] = useState('member'); + + const [inviteEmail, setInviteEmail] = useState(''); + const [inviteRole, setInviteRole] = useState('member'); + const [inviteTeamId, setInviteTeamId] = useState(''); + const [copiedInviteId, setCopiedInviteId] = useState(null); + + const [resetPwdUserId, setResetPwdUserId] = useState(null); + const [resetPwdVal, setResetPwdVal] = useState(''); + + // --- Organizations & Teams State --- + const [orgs, setOrgs] = useState([]); + const [allTeams, setAllTeams] = useState([]); + const [newOrgName, setNewOrgName] = useState(''); + const [selectedOrgId, setSelectedOrgId] = useState(''); + const [newTeamName, setNewTeamName] = useState(''); + const [newTeamDesc, setNewTeamDesc] = useState(''); + const [memberSelectByTeam, setMemberSelectByTeam] = useState>({}); + const [memberRoleByTeam, setMemberRoleByTeam] = useState>({}); + + // --- Server & SMTP Settings State --- + const [settings, setSettings] = useState({ + sharingMode: 'teams_only', + allowRegistration: false, + instanceName: 'MindSift', + smtpHost: '', + smtpPort: 587, + smtpUser: '', + smtpPass: '', + smtpFrom: '', + smtpTls: true, + smtpConfiguredViaEnv: false, + }); + const [testSmtpEmail, setTestSmtpEmail] = useState(currentUser.email || ''); + const [testingSmtp, setTestingSmtp] = useState(false); + + const [statusMsg, setStatusMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); + + const loadAll = async () => { + try { + const [uList, invList, orgList, teamList] = await Promise.all([ + api.adminGetUsers(), + api.adminGetInvites(), + api.getOrganizations(), + api.getUserTeams(), + ]); + setUsers(uList); + setInvites(invList); + setOrgs(orgList); + setAllTeams(teamList); + if (orgList.length > 0 && !selectedOrgId) { + setSelectedOrgId(orgList[0].id); + } + + if (isServerAdmin) { + const sys = await api.adminGetSettings(); + setSettings({ + sharingMode: sys.sharingMode || 'teams_only', + allowRegistration: Boolean(sys.allowRegistration), + instanceName: sys.instanceName || 'MindSift', + smtpHost: sys.smtpHost || '', + smtpPort: sys.smtpPort || 587, + smtpUser: sys.smtpUser || '', + smtpPass: sys.smtpPass || '', + smtpFrom: sys.smtpFrom || '', + smtpTls: sys.smtpTls ?? true, + smtpConfiguredViaEnv: Boolean(sys.smtpConfiguredViaEnv), + }); + } + } catch (err: any) { + setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Laden der Administrationsdaten' }); + } + }; + + useEffect(() => { + loadAll(); + }, []); + + // ============================================================================ + // HANDLERS: USERS & INVITES + // ============================================================================ + const handleCreateUser = async (e: React.FormEvent) => { + e.preventDefault(); + setStatusMsg(null); + triggerHaptic(15); + try { + const created = await api.adminCreateUser({ + email: newUserEmail.trim(), + password: newUserPassword, + displayName: newUserName.trim() || newUserEmail.split('@')[0], + role: newUserRole, + }); + setUsers((prev) => [...prev, created]); + setNewUserName(''); + setNewUserEmail(''); + setNewUserPassword(''); + setNewUserRole('member'); + setStatusMsg({ type: 'ok', text: `Benutzer "${created.displayName}" wurde angelegt.` }); + } catch (err: any) { + setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Anlegen des Benutzers' }); + } + }; + + const handleCreateInvite = async (e: React.FormEvent) => { + e.preventDefault(); + setStatusMsg(null); + triggerHaptic(15); + try { + const inv = await api.adminCreateInvite({ + email: inviteEmail.trim(), + role: inviteRole, + teamId: inviteTeamId, + }); + setInvites((prev) => [inv, ...prev]); + setInviteEmail(''); + setStatusMsg({ + type: 'ok', + text: `Einladungscode ${inv.code} erfolgreich erstellt.`, + }); + } catch (err: any) { + setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Erstellen der Einladung' }); + } + }; + + const handleDeleteInvite = async (id: string) => { + try { + await api.adminDeleteInvite(id); + setInvites((prev) => prev.filter((i) => i.id !== id)); + } catch (err: any) { + setStatusMsg({ type: 'err', text: err.message }); + } + }; + + const handleCopyInviteCode = (inv: UserInvite) => { + navigator.clipboard.writeText(inv.code); + setCopiedInviteId(inv.id); + setTimeout(() => setCopiedInviteId(null), 2000); + }; + + const handleChangeUserRole = async (u: User, role: UserRole) => { + setStatusMsg(null); + try { + const updated = await api.adminUpdateUser(u.id, { role }); + setUsers((prev) => prev.map((item) => (item.id === u.id ? updated : item))); + setStatusMsg({ + type: 'ok', + text: `Rolle für ${updated.displayName} auf "${role}" aktualisiert.`, + }); + } catch (err: any) { + setStatusMsg({ type: 'err', text: err.message }); + } + }; + + const handleToggleUserActive = async (u: User) => { + setStatusMsg(null); + try { + const updated = await api.adminUpdateUser(u.id, { isActive: !u.isActive }); + setUsers((prev) => prev.map((item) => (item.id === u.id ? updated : item))); + } catch (err: any) { + setStatusMsg({ type: 'err', text: err.message }); + } + }; + + const handleConfirmResetPassword = async (userId: string) => { + if (!resetPwdVal.trim()) return; + setStatusMsg(null); + try { + await api.adminResetPassword(userId, resetPwdVal.trim()); + setResetPwdUserId(null); + setResetPwdVal(''); + setStatusMsg({ type: 'ok', text: 'Passwort erfolgreich zurückgesetzt.' }); + } catch (err: any) { + setStatusMsg({ type: 'err', text: err.message }); + } + }; + + const handleDeleteUser = async (u: User) => { + if ( + !window.confirm( + `Benutzer "${u.displayName}" (${u.email}) wirklich unwiderruflich löschen?` + ) + ) + return; + setStatusMsg(null); + try { + await api.adminDeleteUser(u.id); + setUsers((prev) => prev.filter((item) => item.id !== u.id)); + setStatusMsg({ type: 'ok', text: 'Benutzer gelöscht.' }); + } catch (err: any) { + setStatusMsg({ type: 'err', text: err.message }); + } + }; + + // ============================================================================ + // HANDLERS: ORGANIZATIONS & TEAMS + // ============================================================================ + const handleCreateOrg = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newOrgName.trim()) return; + setStatusMsg(null); + try { + const created = await api.createOrganization(newOrgName.trim()); + setNewOrgName(''); + setSelectedOrgId(created.id); + await loadAll(); + setStatusMsg({ type: 'ok', text: `Organisation "${created.name}" angelegt.` }); + } catch (err: any) { + setStatusMsg({ type: 'err', text: err.message }); + } + }; + + const handleDeleteOrg = async (org: Organization) => { + if (!window.confirm(`Organisation "${org.name}" inklusive aller zugehörigen Teams löschen?`)) + return; + setStatusMsg(null); + try { + await api.deleteOrganization(org.id); + await loadAll(); + setStatusMsg({ type: 'ok', text: `Organisation "${org.name}" gelöscht.` }); + } catch (err: any) { + setStatusMsg({ type: 'err', text: err.message }); + } + }; + + const handleCreateTeam = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedOrgId || !newTeamName.trim()) return; + setStatusMsg(null); + try { + const created = await api.createOrgTeam( + selectedOrgId, + newTeamName.trim(), + newTeamDesc.trim() + ); + setNewTeamName(''); + setNewTeamDesc(''); + await loadAll(); + setStatusMsg({ type: 'ok', text: `Team "${created.name}" wurde erstellt.` }); + } catch (err: any) { + setStatusMsg({ type: 'err', text: err.message }); + } + }; + + const handleDeleteTeam = async (team: Team) => { + if (!window.confirm(`Team "${team.name}" wirklich löschen?`)) return; + setStatusMsg(null); + try { + await api.deleteTeam(team.id); + await loadAll(); + setStatusMsg({ type: 'ok', text: `Team "${team.name}" gelöscht.` }); + } catch (err: any) { + setStatusMsg({ type: 'err', text: err.message }); + } + }; + + const handleAddMemberToTeam = async (teamId: string) => { + const selectedUserId = memberSelectByTeam[teamId]; + if (!selectedUserId) return; + const role = memberRoleByTeam[teamId] || 'member'; + setStatusMsg(null); + try { + await api.addTeamMember(teamId, { userId: selectedUserId, role }); + await loadAll(); + setStatusMsg({ type: 'ok', text: 'Teammitglied hinzugefügt.' }); + } catch (err: any) { + setStatusMsg({ type: 'err', text: err.message }); + } + }; + + const handleRemoveMemberFromTeam = async (teamId: string, userId: string) => { + setStatusMsg(null); + try { + await api.removeTeamMember(teamId, userId); + await loadAll(); + } catch (err: any) { + setStatusMsg({ type: 'err', text: err.message }); + } + }; + + // ============================================================================ + // HANDLERS: SERVER & SMTP SETTINGS + // ============================================================================ + const handleSaveSystemSettings = async (patch?: Partial) => { + setStatusMsg(null); + const next = { ...settings, ...(patch || {}) }; + setSettings(next); + try { + const saved = await api.adminUpdateSettings(next); + setSettings((prev) => ({ ...prev, ...saved })); + setStatusMsg({ type: 'ok', text: 'Server-Einstellungen erfolgreich gespeichert.' }); + } catch (err: any) { + setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' }); + } + }; + + const handleTestSmtp = async () => { + if (!testSmtpEmail.trim()) return; + setTestingSmtp(true); + setStatusMsg(null); + try { + await api.adminUpdateSettings(settings); + await api.adminTestSMTP(testSmtpEmail.trim()); + setStatusMsg({ + type: 'ok', + text: `Test-E-Mail erfolgreich an ${testSmtpEmail.trim()} gesendet!`, + }); + } catch (err: any) { + setStatusMsg({ + type: 'err', + text: `SMTP-Test fehlgeschlagen: ${err.message}`, + }); + } finally { + setTestingSmtp(false); + } + }; + + // Collect all teams across orgs for invite dropdown + const flattenedTeams: Team[] = + orgs.flatMap((o) => o.teams || []).length > 0 + ? orgs.flatMap((o) => o.teams || []) + : allTeams; + + return ( +
+ {/* Top Page Header */} +
+
+ +
+

+ + Administration & Team-Steuerung +

+

+ Benutzerverwaltung, Einladungscodes, Organisationen, Teams, Isolationsmodus & Mail-Server +

+
+
+ + + {isServerAdmin ? 'Server-Administrator' : 'Team-Administrator'} + +
+ + {statusMsg && ( +
+
+ {statusMsg.type === 'ok' ? ( + + ) : ( + + )} + {statusMsg.text} +
+ +
+ )} + + {/* Main 2-Column Layout */} +
+ {/* Left Navigation Sidebar */} + + + {/* Right Content Area */} +
+ {/* ================================================================= */} + {/* SECTION 1: BENUTZER, ROLLEN & EINLADUNGEN */} + {/* ================================================================= */} + {section === 'users' && ( +
+
+

Benutzersteuerung, Rollen & Einladungen

+

+ Lege Benutzerkonten direkt an oder erzeuge Einladungscodes, mit denen sich eingeladene + Personen selbst registrieren können, auch wenn die öffentliche Registrierung gesperrt ist. +

+
+ + {/* Direct User Creation Form (Fixed Layout: 4 clean columns with min-w-0, no overflow!) */} +
+

+ + Neues Benutzerkonto direkt anlegen +

+ +
+
+
+ + setNewUserName(e.target.value)} + placeholder="z.B. Anna Beispiel" + required + className="w-full min-w-0 h-8 px-2.5 input-field text-white" + /> +
+ +
+ + setNewUserEmail(e.target.value)} + placeholder="anna@beispiel.de" + required + className="w-full min-w-0 h-8 px-2.5 input-field text-white" + /> +
+ +
+ + setNewUserPassword(e.target.value)} + placeholder="Mind. 4 Zeichen" + minLength={4} + required + className="w-full min-w-0 h-8 px-2.5 input-field text-white" + /> +
+ +
+ + +
+
+ +
+ +
+
+
+ + {/* Invite Code Generator */} +
+

+ + Einladungscode erzeugen (gültig für 7 Tage) +

+ +
+
+
+ + setInviteEmail(e.target.value)} + placeholder="Leer lassen für beliebige E-Mail" + className="w-full min-w-0 h-8 px-2.5 input-field text-white" + /> +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+ + {/* Active Invites List */} + {invites.length > 0 && ( +
+

+ Aktive & bisherige Einladungscodes: +

+
+ {invites.map((inv) => ( +
+
+ + {inv.code} + + + Rolle: {inv.role} + {inv.email ? ` · Für: ${inv.email}` : ''} + + {inv.usedBy ? ( + + Eingelöst + + ) : ( + + Offen + + )} +
+ +
+ {!inv.usedBy && ( + + )} + +
+
+ ))} +
+
+ )} +
+ + {/* Users List */} +
+

+ Registrierte Benutzer ({users.length}) +

+
+ {users.map((u) => ( +
+
+
+
+ {u.displayName} + {!u.isActive && ( + + Gesperrt + + )} +
+

{u.email}

+
+ +
+ {/* Role selector */} + + + {isServerAdmin && ( + <> + + + {u.id !== currentUser.id && ( + <> + + + + + )} + + )} +
+
+ + {resetPwdUserId === u.id && ( +
+ setResetPwdVal(e.target.value)} + placeholder="Neues Passwort eingeben…" + className="flex-1 h-7 px-2 input-field text-white" + /> + + +
+ )} +
+ ))} +
+
+
+ )} + + {/* ================================================================= */} + {/* SECTION 2: ORGANISATIONEN & TEAMS */} + {/* ================================================================= */} + {section === 'orgs' && ( +
+
+

Organisationen & Teams verwalten

+

+ Erstelle Organisationen und unterteile sie in Teams. Weise Benutzern Teams zu, damit + sie im Modus "Teams isoliert" gemeinsam an Listen und Aufgaben arbeiten können. +

+
+ + {/* Create Organization & Team */} +
+
+

1. Neue Organisation gründen

+
+ setNewOrgName(e.target.value)} + placeholder="z.B. Homelab, Familie, Firma…" + required + className="flex-1 min-w-0 h-8 px-2.5 input-field text-white" + /> + +
+
+ +
+

2. Neues Team in Organisation anlegen

+ {orgs.length === 0 ? ( +

+ Bitte lege zuerst links eine Organisation an. +

+ ) : ( +
+
+ + setNewTeamName(e.target.value)} + placeholder="Team-Name (z.B. Infrastruktur)" + required + className="w-full min-w-0 h-8 px-2.5 input-field text-white" + /> +
+
+ setNewTeamDesc(e.target.value)} + placeholder="Kurzbeschreibung (optional)…" + className="flex-1 min-w-0 h-8 px-2.5 input-field text-white" + /> + +
+
+ )} +
+
+ + {/* Organizations & Nested Teams Display */} +
+ {orgs.length === 0 ? ( +
+ Noch keine Organisationen vorhanden. +
+ ) : ( + orgs.map((org) => { + const orgTeams = Array.isArray(org.teams) ? org.teams : []; + return ( +
+
+
+ + {org.name} + + ({orgTeams.length} {orgTeams.length === 1 ? 'Team' : 'Teams'}) + +
+ +
+ +
+ {orgTeams.length === 0 ? ( +

+ Noch keine Teams in dieser Organisation. +

+ ) : ( + orgTeams.map((team) => { + const members = Array.isArray(team.members) ? team.members : []; + return ( +
+
+
+ + + {team.name} + + {team.description && ( +

+ {team.description} +

+ )} +
+ +
+ + {/* Team Members */} +
+

+ Mitglieder ({members.length}): +

+ {members.length === 0 ? ( +

+ Noch keine Mitglieder zugewiesen. +

+ ) : ( +
+ {members.map((m) => ( +
+ + {m.displayName} + + + ({m.role}) + + +
+ ))} +
+ )} +
+ + {/* Add User to Team */} +
+ + + + + +
+
+ ); + }) + )} +
+
+ ); + }) + )} +
+
+ )} + + {/* ================================================================= */} + {/* SECTION 3: SERVER & SHARING-ISOLATION (SERVER-ADMIN ONLY) */} + {/* ================================================================= */} + {section === 'server' && isServerAdmin && ( +
+
+

+ Server-Grundeinstellungen & Benutzer-Sichtbarkeit +

+

+ Als Server-Admin bestimmst du zentral für alle Nutzer, ob sie komplett isoliert + arbeiten, sich nur innerhalb ihrer Teams sehen oder ob alle Nutzer für alle sichtbar sind. +

+
+ +
+
+ +
+ + setSettings((prev) => ({ ...prev, instanceName: e.target.value })) + } + className="flex-1 h-9 px-3 input-field text-white" + /> + +
+
+ + {/* Sharing Isolation Cards */} +
+ + +
+ + + + + +
+
+ + {/* Public Registration Toggle */} +
+
+

Öffentliche Selbstregistrierung erlauben

+

+ Wenn deaktiviert, können sich Fremde auf dem Login-Bildschirm nicht registrieren. + Neue Benutzer benötigen zwingend einen Einladungscode oder müssen von einem Admin + angelegt werden. +

+
+ + +
+
+
+ )} + + {/* ================================================================= */} + {/* SECTION 4: E-MAIL / SMTP-SERVER (SERVER-ADMIN ONLY) */} + {/* ================================================================= */} + {section === 'smtp' && isServerAdmin && ( +
+
+
+

+ E-Mail / SMTP-Server für Aufgaben-Alarme +

+

+ Konfiguriere den Postausgangsserver per Docker-Compose Environment-Variablen oder + direkt hier über die Oberfläche (wird persistent in der SQLite-Datenbank gespeichert). +

+
+ + {settings.smtpConfiguredViaEnv && ( + + Docker Env erkannt + + )} +
+ +
+
+ + + setSettings((prev) => ({ ...prev, smtpHost: e.target.value })) + } + placeholder="z.B. smtp.gmail.com oder mail.deinedomain.de" + className="w-full h-9 px-3 input-field text-white font-mono" + /> +
+ +
+ + + setSettings((prev) => ({ ...prev, smtpPort: Number(e.target.value) })) + } + placeholder="587" + className="w-full h-9 px-3 input-field text-white font-mono" + /> +
+ +
+ + + setSettings((prev) => ({ ...prev, smtpUser: e.target.value })) + } + placeholder="alerts@deinedomain.de" + className="w-full h-9 px-3 input-field text-white font-mono" + /> +
+ +
+ + + setSettings((prev) => ({ ...prev, smtpPass: e.target.value })) + } + placeholder="••••••••••••" + className="w-full h-9 px-3 input-field text-white font-mono" + /> +
+ +
+ + + setSettings((prev) => ({ ...prev, smtpFrom: e.target.value })) + } + placeholder="MindSift " + className="w-full h-9 px-3 input-field text-white" + /> +
+ +
+ +
+
+ +
+ +
+ + {/* Test SMTP Delivery */} +
+

SMTP-Verbindung testen

+

+ Sendet sofort eine Test-E-Mail mit den oben eingetragenen Daten. +

+
+ setTestSmtpEmail(e.target.value)} + placeholder="empfaenger@beispiel.de" + className="flex-1 h-8 px-3 input-field text-white" + /> + +
+
+
+ )} +
+
+
+ ); +}; diff --git a/frontend/src/features/settings/SettingsModal.tsx b/frontend/src/features/settings/SettingsModal.tsx deleted file mode 100644 index db0da22..0000000 --- a/frontend/src/features/settings/SettingsModal.tsx +++ /dev/null @@ -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 = ({ - currentUser, - onClose, - onUserUpdated, - onLoggedOut, -}) => { - const [activeTab, setActiveTab] = useState('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([]); - const [newKeyName, setNewKeyName] = useState(''); - const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); - const [copiedKey, setCopiedKey] = useState(false); - - // --- Organizations & Teams State --- - const [orgs, setOrgs] = useState([]); - const [teams, setTeams] = useState([]); - const [newOrgName, setNewOrgName] = useState(''); - const [selectedOrgId, setSelectedOrgId] = useState(''); - const [newTeamName, setNewTeamName] = useState(''); - const [newTeamDesc, setNewTeamDesc] = useState(''); - - // --- Admin State --- - const [adminUsers, setAdminUsers] = useState([]); - const [systemSettings, setSystemSettings] = useState({ - 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(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) => { - 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 ( -
-
- {/* Header */} -
-
- -

- Konto-Einstellungen & Administration -

-
- -
- - {/* Modal Navigation Tabs */} -
- - - - - - - - - {currentUser?.role === 'admin' && ( - - )} -
- - {/* Modal Body */} -
- {/* TAB 1: PROFIL & SICHERHEIT */} - {activeTab === 'profile' && ( -
-
-

Benutzerprofil bearbeiten

-

- Passe deinen Anzeigenamen und dein Anmeldekennwort an. -

-
- - {profileMsg && ( -
- {profileMsg.text} -
- )} - -
-
- - setDisplayName(e.target.value)} - required - className="w-full h-8 px-2.5 input-field text-white" - /> -
- -
- - setEmail(e.target.value)} - required - className="w-full h-8 px-2.5 input-field text-white" - /> -
- -
- - setNewPassword(e.target.value)} - placeholder="Mindestens 4 Zeichen" - className="w-full h-8 px-2.5 input-field text-white" - /> -
- -
- - - -
-
-
- )} - - {/* TAB 2: ALARME & DISCORD */} - {activeTab === 'alarms' && ( -
-
-

Aufgaben-Alarme & Discord Benachrichtigungen

-

- Trage deine Discord Webhook URL ein, damit der MindSift Server dich automatisch erinnert, sobald Aufgaben fällig werden. -

-
- - {alarmMsg && ( -
- {alarmMsg.text} -
- )} - -
-
- - 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" - /> -
- -
- - -
- -
- - - -
-
-
- )} - - {/* TAB 3: WEBHOOKS & EXPORT */} - {activeTab === 'webhooks' && ( -
-
-

Private Webhook Keys & Daten-Export

-

- Erstelle API-Keys für eigene Bash-Skripte, Home Assistant oder Cronjobs, um Gedanken direkt per Curl zu erfassen. -

-
- - {/* API Key Creator */} -
- 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" - /> - -
- - {newlyCreatedKey && ( -
-
-

Neuer Key (jetzt kopieren):

- {newlyCreatedKey} -
- -
- )} - - {/* Keys list */} -
- {apiKeys.length === 0 ? ( -
Noch keine API-Schlüssel angelegt.
- ) : ( - apiKeys.map((k) => ( -
-
-

{k.name}

-

Präfix: {k.keyPrefix}••••••••

-
- -
- )) - )} -
- - {/* Interactive Curl Example */} -
-
- - Curl Schnellbefehl für eigene Skripte: - - -
-
-                  {curlExample}
-                
-
- - {/* Data Export Button */} -
-
-

Vollständiger Daten-Export

-

Sichere alle Notizen, Aufgaben, Listen und Metadaten als JSON.

-
- -
-
- )} - - {/* TAB 4: ORGANISATIONEN & TEAMS */} - {activeTab === 'teams' && ( -
-
-

Organisationen & Teams gründen

-

- Erstelle Organisationen und unterteile sie in Teams, um Aufgabenlisten und Workspace-Dokumente gemeinsam zu bearbeiten. -

-
- - {/* Org Creator */} -
-

Neue Organisation

-
- 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" - /> - -
-
- - {/* Team Creator inside Org */} - {orgs.length > 0 && ( -
-

Neues Team in Organisation

-
-
- - 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" - /> -
-
- setNewTeamDesc(e.target.value)} - placeholder="Optionale Teambeschreibung…" - className="flex-1 h-8 px-2.5 input-field text-xs text-white" - /> - -
-
-
- )} - - {/* Active Teams List */} -
-

Deine Teams ({teams.length}):

-
- {teams.length === 0 ? ( -
Du bist noch keinem Team beigetreten.
- ) : ( - teams.map((t) => ( -
-
-

- - {t.name} -

- {t.description &&

{t.description}

} -
- ID: {t.id} -
- )) - )} -
-
-
- )} - - {/* TAB 5: ADMIN BEREICH */} - {activeTab === 'admin' && currentUser?.role === 'admin' && ( -
-
-

Server-Administration & Benutzersteuerung

-

- Steuere die Instanz-Einstellungen, das Sharing-Verhalten und verwalte registrierte Benutzer. -

-
- - {/* Server Sharing Isolation Setting */} -
-

- - Sharing-Isolationsmodus -

-
- - - - - -
- -
- Öffentliche Registrierung erlauben: - -
-
- - {/* User Creation by Admin */} -
-

Neuen Benutzer anlegen

-
-
- setNewAdminUserEmail(e.target.value)} - placeholder="E-Mail" - required - className="h-8 px-2.5 input-field text-white" - /> - setNewAdminUserPassword(e.target.value)} - placeholder="Initial-Passwort" - required - className="h-8 px-2.5 input-field text-white" - /> -
- setNewAdminUserDisplayName(e.target.value)} - placeholder="Anzeigename" - className="flex-1 h-8 px-2.5 input-field text-white" - /> - -
-
-
- -
-
-
- - {/* Users Table */} -
-

Registrierte Benutzer ({adminUsers.length}):

-
- {adminUsers.map((u) => ( -
-
-
- {u.displayName} - - {u.role} - - {!u.isActive && ( - - Deaktiviert - - )} -
-

{u.email}

- - {/* Reset password inline form */} - {resetPwdUserId === u.id && ( -
- setResetPwdVal(e.target.value)} - placeholder="Neues Passwort…" - className="h-6 px-2 input-field text-xs text-white" - /> - - -
- )} -
- -
- - - - {u.id !== currentUser.id && ( - - )} -
-
- ))} -
-
-
- )} -
-
-
- ); -}; diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx new file mode 100644 index 0000000..8e22b4a --- /dev/null +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -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 = ({ + currentUser, + onUserUpdated, + onBack, +}) => { + const [section, setSection] = useState('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( + 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( + currentUser.notifyBeforeMinutes ?? 30 + ); + const [alarmMsg, setAlarmMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); + const [savingAlarms, setSavingAlarms] = useState(false); + const [testingChannel, setTestingChannel] = useState(null); + + // --- Webhooks & API Keys State --- + const [apiKeys, setApiKeys] = useState([]); + const [newKeyName, setNewKeyName] = useState(''); + const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); + const [copiedLabel, setCopiedLabel] = useState(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 ( +
+ {/* Top Page Header */} +
+
+ +
+

Kontoeinstellungen

+

+ Persönliches Profil, Aufgaben-Alarme, Private Webhooks & Daten-Backup +

+
+
+ + + Rolle: {roleBadgeLabel} + +
+ + {/* Main 2-Column Layout */} +
+ {/* Left Navigation Sidebar */} + + + {/* Right Content Area */} +
+ {/* ================================================================= */} + {/* SECTION 1: PROFIL & SICHERHEIT */} + {/* ================================================================= */} + {section === 'profile' && ( +
+
+

Profil & Anmeldedaten

+

+ Verwalte deinen Anzeigenamen, deine E-Mail-Adresse und dein persönliches Passwort. +

+
+ + {profileMsg && ( +
+ {profileMsg.type === 'ok' ? ( + + ) : ( + + )} + {profileMsg.text} +
+ )} + +
+
+
+ + setDisplayName(e.target.value)} + required + className="w-full h-9 px-3 input-field text-white" + /> +
+ +
+ + setEmail(e.target.value)} + required + className="w-full h-9 px-3 input-field text-white" + /> +
+
+ +
+ + setNewPassword(e.target.value)} + placeholder="Mindestens 4 Zeichen" + className="w-full h-9 px-3 input-field text-white" + /> +
+ +
+ +
+
+
+ )} + + {/* ================================================================= */} + {/* SECTION 2: ALARME (MULTI-CHANNEL: EMAIL, DISCORD, CUSTOM WEBHOOK) */} + {/* ================================================================= */} + {section === 'alarms' && ( +
+
+

Aufgaben-Alarme & Benachrichtigungsziele

+

+ 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. +

+
+ + {alarmMsg && ( +
+ {alarmMsg.type === 'ok' ? ( + + ) : ( + + )} + {alarmMsg.text} +
+ )} + +
+ {/* Lead Time Selector */} +
+
+ +

+ Wann soll MindSift den Alarm vor dem Fälligkeitszeitpunkt auslösen? +

+
+ +
+ + {/* Channel 1: E-Mail */} +
+
+ + + +
+ +
+ + setAlarmEmail(e.target.value)} + placeholder={currentUser.email} + className="w-full h-8 px-2.5 input-field text-white" + /> +
+
+ + {/* Channel 2: Discord Webhook */} +
+
+ + + +
+ +
+ + 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" + /> +
+
+ + {/* Channel 3: Custom HTTP Webhook */} +
+
+ + + +
+ +
+ + 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" + /> +
+
+ +
+ +
+
+
+ )} + + {/* ================================================================= */} + {/* SECTION 3: PRIVATE WEBHOOKS & EXPORT */} + {/* ================================================================= */} + {section === 'webhooks' && ( +
+
+

Private Webhooks (Eingehend) & Daten-Export

+

+ Erstelle persönliche API-Schlüssel, um aus externen Skripten, Server-Monitoring oder + Discord-Bots automatisch Aufgaben und Gedanken in dein MindSift einzuspeisen. +

+
+ + {/* Create API Key */} +
+ 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" + /> + +
+ + {newlyCreatedKey && ( +
+
+

+ Neuer API-Schlüssel erstellt (bitte jetzt kopieren, wird nur einmal im Klartext angezeigt): +

+ {newlyCreatedKey} +
+ +
+ )} + + {/* Existing Keys List */} +
+ {apiKeys.length === 0 ? ( +
+ Noch keine persönlichen API-Schlüssel vorhanden. +
+ ) : ( + apiKeys.map((k) => ( +
+
+

{k.name}

+

+ Präfix: {k.keyPrefix}•••••••• · Erstellt: {new Date(k.createdAt).toLocaleDateString()} +

+
+ +
+ )) + )} +
+ + {/* Curl Example */} +
+
+ + + Curl-Beispiel: Direkt als Aufgabe einspeisen + + +
+
+                  {curlTaskExample}
+                
+
+ + {/* Full Data Backup */} +
+
+

Vollständiger Daten-Export (JSON Backup)

+

+ Lädt alle deine Gedanken, Aufgaben, Markdown-Dokumente und Listen als portable JSON-Datei herunter. +

+
+ +
+
+ )} +
+
+
+ ); +}; diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index c159677..c52ceab 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -60,15 +60,17 @@ export const TasksView: React.FC = ({ 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 = ({ } }; - 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 (
@@ -216,7 +221,7 @@ export const TasksView: React.FC = ({ Alle Aufgaben - {taskLists.map((tl) => ( + {safeTaskLists.map((tl) => (