import React, { useEffect, useState } from 'react'; import { Shield, Users, Server, Mail, Plus, Trash2, KeyRound, Copy, Check, ArrowLeft, Send, AlertCircle, UserPlus, Building2, Pencil, } from 'lucide-react'; import { Organization, SharingMode, SystemSettings, User, UserInvite, UserRole, } from '../../lib/types'; import { api, setCurrentSharingMode, triggerHaptic } from '../../lib/api'; import { passwordProblem } from '../../lib/password'; import { UserAvatar } from '../../components/UserAvatar'; import { ToggleSwitch } from '../../components/ToggleSwitch'; import { CountBadge } from '../../components/CountBadge'; interface AdminSettingsViewProps { currentUser: User; onBack: () => void; } type AdminSection = 'users' | 'organizations' | 'server' | 'smtp'; const SHARING_MODES: { id: SharingMode; title: string; text: string }[] = [ { id: 'private', title: '1. Privat (Keiner sieht andere)', text: 'Jeder arbeitet für sich allein. Keine Benutzersuche, kein Teilen, keine Gruppen.', }, { id: 'organizations', title: '2. Organisationen (Empfohlen)', text: 'Benutzer sehen nur Mitglieder ihrer eigenen Organisation. Wer keiner Organisation angehört, sieht niemanden.', }, { id: 'open', title: '3. Offen (Alle sehen alle)', text: 'Alle Benutzer dieser Instanz können sich gegenseitig finden und miteinander teilen.', }, ]; // Stricter modes come first; switching to a stricter one can remove shares const modeRank = (m: SharingMode) => SHARING_MODES.findIndex((x) => x.id === m); 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 [newUserOrg, setNewUserOrg] = useState(''); const [inviteEmail, setInviteEmail] = useState(''); const [inviteRole, setInviteRole] = useState('member'); const [inviteOrg, setInviteOrg] = useState(''); // --- Organizations State --- const [organizations, setOrganizations] = useState([]); const [newOrgName, setNewOrgName] = useState(''); const [renamingOrgId, setRenamingOrgId] = useState(null); const [renameOrgVal, setRenameOrgVal] = useState(''); const [deletingOrgId, setDeletingOrgId] = useState(null); // Moving someone out of an organization removes their shares there, so it needs a confirmation const [pendingMove, setPendingMove] = useState<{ user: User; orgId: string } | null>(null); const [pendingMode, setPendingMode] = useState<{ mode: SharingMode; affected: number } | null>(null); const [copiedInviteId, setCopiedInviteId] = useState(null); const [resetPwdUserId, setResetPwdUserId] = useState(null); const [resetPwdVal, setResetPwdVal] = useState(''); // --- Server & SMTP Settings State --- const [settings, setSettings] = useState({ sharingMode: 'organizations', allowRegistration: false, instanceName: 'MindSift', smtpHost: '', smtpPort: '587', smtpUser: '', smtpPass: '', smtpFrom: '', smtpTls: true, smtpSkipVerify: false, 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] = await Promise.all([ api.adminGetUsers(), api.adminGetInvites(), ]); setUsers(uList); setInvites(invList); if (isServerAdmin) { const [sys, orgs] = await Promise.all([api.adminGetSettings(), api.adminGetOrganizations()]); setOrganizations(orgs); setSettings({ sharingMode: sys.sharingMode || 'organizations', allowRegistration: Boolean(sys.allowRegistration), instanceName: sys.instanceName || 'MindSift', smtpHost: sys.smtpHost || '', smtpPort: sys.smtpPort ? String(sys.smtpPort) : '587', smtpUser: sys.smtpUser || '', smtpPass: sys.smtpPass || '', smtpFrom: sys.smtpFrom || '', smtpTls: sys.smtpTls ?? true, smtpSkipVerify: Boolean(sys.smtpSkipVerify), allowPrivateWebhooks: Boolean(sys.allowPrivateWebhooks), 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); const problem = passwordProblem(newUserPassword); if (problem) { setStatusMsg({ type: 'err', text: problem }); return; } triggerHaptic(15); try { const created = await api.adminCreateUser({ email: newUserEmail.trim(), password: newUserPassword, displayName: newUserName.trim() || newUserEmail.split('@')[0], role: newUserRole, organizationId: newUserOrg || undefined, }); setUsers((prev) => [...prev, created]); if (newUserOrg) await reloadOrganizations(); 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, organizationId: inviteOrg || undefined, }); 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); const problem = passwordProblem(resetPwdVal); if (problem) { setStatusMsg({ type: 'err', text: problem }); return; } try { // Not trimmed: login compares the password exactly as typed await api.adminResetPassword(userId, resetPwdVal); 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: SERVER & SMTP SETTINGS // ============================================================================ // ============================================================================ // HANDLERS: ORGANIZATIONS // ============================================================================ const reloadOrganizations = async () => { try { setOrganizations(await api.adminGetOrganizations()); } catch { // keep the old list } }; const handleCreateOrganization = async (e: React.FormEvent) => { e.preventDefault(); if (!newOrgName.trim()) return; try { await api.adminCreateOrganization(newOrgName.trim()); setNewOrgName(''); await reloadOrganizations(); } catch (err: any) { setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Anlegen der Organisation' }); } }; const handleRenameOrganization = async (orgId: string) => { try { await api.adminRenameOrganization(orgId, renameOrgVal.trim()); setRenamingOrgId(null); await reloadOrganizations(); } catch (err: any) { setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Umbenennen' }); } }; const handleDeleteOrganization = async (orgId: string) => { try { await api.adminDeleteOrganization(orgId); setDeletingOrgId(null); setUsers((prev) => prev.map((u) => (u.organizationId === orgId ? { ...u, organizationId: '' } : u))); await reloadOrganizations(); } catch (err: any) { setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Löschen' }); } }; const applyUserOrganization = async (u: User, orgId: string) => { setPendingMove(null); try { await api.adminSetUserOrganization(u.id, orgId); setUsers((prev) => prev.map((x) => (x.id === u.id ? { ...x, organizationId: orgId } : x))); await reloadOrganizations(); } catch (err: any) { setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Zuordnen' }); } }; const handleChangeUserOrganization = (u: User, orgId: string) => { if (u.organizationId) setPendingMove({ user: u, orgId }); else applyUserOrganization(u, orgId); }; const orgName = (id?: string) => organizations.find((o) => o.id === id)?.name || ''; // Tightening the mode first asks how much sharing would be lost const handleSelectSharingMode = async (mode: SharingMode) => { if (mode === settings.sharingMode) return; if (modeRank(mode) < modeRank(settings.sharingMode)) { try { const affected = await api.adminSharingImpact(mode); if (affected > 0) { setPendingMode({ mode, affected }); return; } } catch { // fall through and save } } setPendingMode(null); handleSaveSystemSettings({ sharingMode: mode }); }; const handleSaveSystemSettings = async (patch?: Partial) => { setStatusMsg(null); const next = { ...settings, ...(patch || {}) }; setSettings(next); try { const saved = await api.adminUpdateSettings(next); setSettings((prev) => ({ ...prev, ...saved })); setCurrentSharingMode(saved.sharingMode); 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); } }; return (
{/* Top Page Header */}

Server-Administration

Benutzerverwaltung, Einladungscodes, Isolationsmodus & Mail-Server

Server-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 */}

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" />
setNewUserEmail(e.target.value)} placeholder="anna@beispiel.de" required className="w-full min-w-0 h-8 px-2.5 input-field" />
setNewUserPassword(e.target.value)} placeholder="Mind. 8 Zeichen" required className="w-full min-w-0 h-8 px-2.5 input-field" />
{isServerAdmin && (
)}
{/* 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" />
{isServerAdmin && (
)}
{/* Active Invites List */} {invites.length > 0 && (

Aktive & bisherige Einladungscodes:

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

Registrierte Benutzer ({users.length})

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

{u.email} {isServerAdmin && settings.sharingMode === 'organizations' && !u.organizationId && ( · ohne Organisation, sieht niemanden )}

{/* Role selector */} {isServerAdmin && ( )} {isServerAdmin && ( <> {u.id !== currentUser.id && ( <> )} )}
{pendingMove?.user.id === u.id && (
{u.displayName} verlässt „{orgName(u.organizationId)}“ und verliert dort alle Freigaben, Gruppen und Aufgaben-Zuweisungen.
)} {resetPwdUserId === u.id && (
setResetPwdVal(e.target.value)} placeholder="Neues Passwort (mind. 8 Zeichen)…" className="flex-1 h-7 px-2 input-field" />
)}
))}
)} {/* ================================================================= */} {/* SECTION 2: ORGANISATIONEN (SERVER-ADMIN ONLY) */} {/* ================================================================= */} {section === 'organizations' && isServerAdmin && (

Organisationen

Jeder Benutzer gehört höchstens einer Organisation an. Im Modus „Organisationen“ sehen sich nur Mitglieder derselben Organisation und können nur untereinander teilen und Gruppen bilden. Die Zuordnung änderst du in der Benutzerliste.

{settings.sharingMode !== 'organizations' && (

Der aktuelle Sichtbarkeits-Modus ist nicht „Organisationen“; die Zuordnung wirkt sich derzeit nicht aus.

)}
setNewOrgName(e.target.value)} placeholder="Name der neuen Organisation" className="flex-1 min-w-0 h-8 px-2.5 input-field" />
{organizations.length === 0 ? (

Noch keine Organisation angelegt.

) : (
{organizations.map((o) => { const members = users.filter((u) => u.organizationId === o.id); return (
{renamingOrgId === o.id ? (
setRenameOrgVal(e.target.value)} className="flex-1 min-w-0 h-7 px-2 input-field" autoFocus />
) : (
{o.name}
)} {renamingOrgId !== o.id && (
)}
{members.length > 0 && (
{members.map((m) => ( {m.displayName} ))}
)} {deletingOrgId === o.id && (
Die {o.memberCount} Mitglieder sind danach ohne Organisation und verlieren ihre Freigaben, Gruppen und Zuweisungen untereinander.
)}
); })}
)}
)} {/* ================================================================= */} {/* SECTION 3: SERVER & SHARING-ISOLATION (SERVER-ADMIN ONLY) */} {/* ================================================================= */} {section === 'server' && isServerAdmin && (

Server-Grundeinstellungen & Benutzer-Sichtbarkeit

Als Server-Admin bestimmst du zentral, ob Nutzer komplett isoliert arbeiten, sich nur innerhalb ihrer Organisation 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" />
{/* Sharing Isolation Cards */}
{SHARING_MODES.map((m) => ( ))}
{pendingMode && (
Beim Wechsel zu „{SHARING_MODES.find((m) => m.id === pendingMode.mode)?.title.replace(/^\d\.\s*/, '')}“ fallen {pendingMode.affected} Freigaben und Gruppen-Mitgliedschaften weg, die danach nicht mehr erlaubt sind.
)}
{/* Alarm webhooks into the local network (SSRF protection) */}

Alarm-Webhooks ins lokale Netz erlauben

Standard: Discord- und Webhook-Alarme gehen nur an öffentliche https-Adressen. Eingeschaltet sind auch Ziele im Heimnetz erlaubt (z. B. Home Assistant unter 192.168.x.x oder über Tailscale), dort auch per http. Der Server selbst (localhost) und Cloud-Metadaten bleiben immer gesperrt.

handleSaveSystemSettings({ allowPrivateWebhooks })} />
{/* Public Registration Toggle Switch */}

Öffentliche Selbstregistrierung erlauben

Wenn deaktiviert, können sich fremde Personen auf dem Login-Bildschirm nicht selbst registrieren. Neue Konten erfordern dann zwingend einen Einladungscode oder müssen von einem Admin angelegt werden.

handleSaveSystemSettings({ allowRegistration })} title={settings.allowRegistration ? 'Selbstregistrierung deaktivieren' : 'Selbstregistrierung aktivieren'} />
)} {/* ================================================================= */} {/* 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.example.com oder mail.deinedomain.de" className="w-full h-9 px-3 input-field font-mono" />
// Digits only; an empty field stays empty while typing (saved as 587) setSettings((prev) => ({ ...prev, smtpPort: e.target.value.replace(/\D/g, '').slice(0, 5) })) } placeholder="587" className="w-full h-9 px-3 input-field font-mono" />
setSettings((prev) => ({ ...prev, smtpUser: e.target.value })) } placeholder="alerts@deinedomain.de" className="w-full h-9 px-3 input-field font-mono" />
setSettings((prev) => ({ ...prev, smtpPass: e.target.value })) } placeholder="••••••••••••" className="w-full h-9 px-3 input-field font-mono" />
setSettings((prev) => ({ ...prev, smtpFrom: e.target.value })) } placeholder="MindSift " className="w-full h-9 px-3 input-field" />

Verschlüsselung (`SMTP_TLS`)

Port 465: TLS ab Verbindungsbeginn, sonst STARTTLS. Ist es an, wird ohne Verschlüsselung nicht gesendet.

setSettings((prev) => ({ ...prev, smtpTls }))} />

Zertifikatsprüfung deaktivieren (`SMTP_SKIP_VERIFY`)

Nur für interne Mailserver mit selbstsigniertem Zertifikat oder abweichendem Namen. Die Verbindung bleibt verschlüsselt, der Server wird aber nicht mehr geprüft.

setSettings((prev) => ({ ...prev, smtpSkipVerify }))} />
{/* 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" />
)}
); };