import React, { useEffect, useState } from 'react'; import { Shield, Users, Server, Mail, Plus, Trash2, KeyRound, Copy, Check, ArrowLeft, Send, AlertCircle, UserPlus, Building2, Pencil, Link2, Clock, X, } from 'lucide-react'; import { Organization, RegistrationMode, 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 { BackupAdminCard } from './BackupAdminCard'; import { VoiceStatusCard } from './VoiceStatusCard'; 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(''); // A link for several people (1 = classic single-use code) const [inviteMaxUses, setInviteMaxUses] = useState(1); // "Per E-Mail einladen": which invite the address field is open for const [mailInviteId, setMailInviteId] = useState(null); const [mailInviteTo, setMailInviteTo] = useState(''); const [mailInviteBusy, setMailInviteBusy] = useState(false); // --- 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: '', smtpFromName: '', 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), registrationMode: sys.registrationMode || (sys.allowRegistration ? 'open' : 'closed'), registrationDomains: sys.registrationDomains || '', registrationConfiguredViaEnv: Boolean(sys.registrationConfiguredViaEnv), registrationVerifyEmail: Boolean(sys.registrationVerifyEmail), registrationVerifyConfiguredViaEnv: Boolean(sys.registrationVerifyConfiguredViaEnv), emailLinksAvailable: Boolean(sys.emailLinksAvailable), instanceName: sys.instanceName || 'MindSift', smtpHost: sys.smtpHost || '', smtpPort: sys.smtpPort ? String(sys.smtpPort) : '587', smtpUser: sys.smtpUser || '', smtpPass: sys.smtpPass || '', smtpFrom: sys.smtpFrom || '', smtpFromName: sys.smtpFromName || '', smtpTls: sys.smtpTls ?? true, smtpSkipVerify: Boolean(sys.smtpSkipVerify), allowPrivateWebhooks: Boolean(sys.allowPrivateWebhooks), smtpConfiguredViaEnv: Boolean(sys.smtpConfiguredViaEnv), backupEnabled: Boolean(sys.backupEnabled), backupTime: sys.backupTime || '03:00', backupRetentionDays: sys.backupRetentionDays || 3, retentionAllowNever: sys.retentionAllowNever ?? true, retentionNeverViaEnv: Boolean(sys.retentionNeverViaEnv), }); } } catch (err: any) { setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Laden der Administrationsdaten' }); } }; // Read-only in the public demo (FLAW-33) const [demoMode, setDemoMode] = useState(false); useEffect(() => { loadAll(); api .getAuthStatus() .then((st) => setDemoMode(Boolean(st.demoMode))) .catch(() => {}); }, []); // ============================================================================ // 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: inviteMaxUses > 1 ? '' : inviteEmail.trim(), role: inviteRole, organizationId: inviteOrg || undefined, maxUses: inviteMaxUses, }); setInvites((prev) => [inv, ...prev]); setInviteEmail(''); setStatusMsg({ type: 'ok', text: inv.maxUses > 1 ? `Einladungslink für ${inv.maxUses} Personen erstellt (${inv.code}).` : `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 }); } }; // Copies the code or the link that opens the sign-up form with the code filled in const inviteLink = (inv: UserInvite) => `${window.location.origin}/?invite=${encodeURIComponent(inv.code)}`; const [copiedWhat, setCopiedWhat] = useState<'code' | 'link'>('code'); const handleCopyInvite = (inv: UserInvite, what: 'code' | 'link') => { navigator.clipboard.writeText(what === 'link' ? inviteLink(inv) : inv.code); setCopiedWhat(what); setCopiedInviteId(inv.id); setTimeout(() => setCopiedInviteId(null), 2000); }; const handleSendInvite = async (inv: UserInvite) => { if (!mailInviteTo.trim()) return; setMailInviteBusy(true); setStatusMsg(null); try { const updated = await api.adminSendInvite(inv.id, mailInviteTo.trim()); setInvites((prev) => prev.map((i) => (i.id === inv.id ? { ...i, ...updated } : i))); setStatusMsg({ type: 'ok', text: `Einladung an ${mailInviteTo.trim()} verschickt.` }); setMailInviteId(null); setMailInviteTo(''); } catch (err: any) { setStatusMsg({ type: 'err', text: err.message || 'Einladung konnte nicht verschickt werden' }); } finally { setMailInviteBusy(false); } }; // Registrations waiting for approval (registration mode "Mit Freigabe") const handleApproveUser = async (u: User) => { setStatusMsg(null); try { const updated = await api.adminApproveUser(u.id); setUsers((prev) => prev.map((item) => (item.id === u.id ? { ...updated, pendingApproval: false } : item))); setStatusMsg({ type: 'ok', text: `${u.displayName} ist freigeschaltet.` }); } catch (err: any) { setStatusMsg({ type: 'err', text: err.message }); } }; 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( u.pendingApproval ? `Registrierung von "${u.displayName}" (${u.email}) ablehnen? Das Konto wird gelöscht.` : `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 */}
{demoMode && (
In der Demo deaktiviert: Server-Einstellungen, E-Mail und Konten lassen sich hier nur ansehen.
)} {/* ================================================================= */} {/* 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 */}

Einladung erzeugen (gültig für 7 Tage)

{/* E-mail gets the room, "how often" only needs a few digits */}
1 ? '' : inviteEmail} onChange={(e) => setInviteEmail(e.target.value)} disabled={inviteMaxUses > 1} placeholder={inviteMaxUses > 1 ? 'Nur bei einmaliger Einladung' : 'Leer lassen für beliebige E-Mail'} className="w-full min-w-0 h-8 px-2.5 input-field disabled:opacity-60" />
setInviteMaxUses(Math.max(1, Math.min(1000, parseInt(e.target.value, 10) || 1)))} title="1 = ein Code für eine Person; mehr = ein Link für eine ganze Gruppe" 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) => { const maxUses = inv.maxUses || 1; const used = inv.useCount ?? (inv.usedBy ? 1 : 0); const open = used < maxUses && new Date(inv.expiresAt).getTime() > Date.now(); const copied = copiedInviteId === inv.id; return (
{inv.code} Rolle: {inv.role} {inv.email ? ` · Für: ${inv.email}` : ''} {inv.organizationName ? ` · ${inv.organizationName}` : ''} {maxUses > 1 ? ` · ${used} von ${maxUses} verwendet` : ''} {open ? ( Offen ) : ( {used >= maxUses ? 'Eingelöst' : 'Abgelaufen'} )}
{open && ( <> )}
{mailInviteId === inv.id && (
{ e.preventDefault(); void handleSendInvite(inv); }} className="flex flex-wrap items-center gap-2" > setMailInviteTo(e.target.value)} readOnly={Boolean(inv.email)} placeholder="name@beispiel.de" required autoFocus className="flex-1 min-w-[12rem] h-8 px-2.5 input-field" /> {maxUses === 1 && !inv.email && ( Die Einladung gilt danach nur noch für diese Adresse. )}
)}
); })}
)}
{/* Registrations waiting for approval */} {users.some((u) => u.pendingApproval) && (

Wartet auf Freigabe ({users.filter((u) => u.pendingApproval).length})

{users .filter((u) => u.pendingApproval) .map((u) => (

{u.displayName} {u.emailUnverified && E-Mail unbestätigt}

{u.email} · registriert am {new Date(u.createdAt).toLocaleDateString('de-DE')}

))}
)} {/* Users List */}

Registrierte Benutzer ({users.filter((u) => !u.pendingApproval).length})

{users.filter((u) => !u.pendingApproval).map((u) => (
{u.displayName} {!u.isActive && ( {u.emailUnverified ? 'E-Mail unbestätigt' : '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.
)}
handleSaveSystemSettings(patch)} /> {/* 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 })} />
{/* Retention: may people keep finished and deleted things for good? */}

Dauerhaftes Aufbewahren erlauben

Eingeschaltet kann jede Person unter Kontoeinstellungen → Aufbewahrung „nie“ wählen, also Erledigtes und Gelöschtes behalten. Ausgeschaltet gilt dort für „nie“ die Voreinstellung des Bereichs (z. B. 30 Tage). {settings.retentionNeverViaEnv && ' Festgelegt per RETENTION_ALLOW_NEVER.'}

handleSaveSystemSettings({ retentionAllowNever })} />
{/* Who may register without an invite */} {(() => { const mode = settings.registrationMode || 'closed'; const locked = Boolean(settings.registrationConfiguredViaEnv); const needsDomains = mode === 'domain' || mode === 'domain+approval'; return (

Registrierung ohne Einladung

Wer sich auf dem Login-Bildschirm selbst ein Konto anlegen darf. Einladungslinks und -codes funktionieren immer. Wartende Konten gibst du unter „Benutzer & Einladungen“ frei.

{needsDomains && ( )} {locked && (

Über die Umgebung festgelegt (REGISTRATION_MODE /{' '} REGISTRATION_DOMAINS) und hier nicht änderbar.

)} {mode !== 'closed' && (

E-Mail-Adresse bestätigen lassen

Neue Konten ohne Einladung bekommen einen Link per E-Mail und sind erst danach aktiv (bei Freigabe landen sie danach bei dir). Unbestätigte Konten werden nach 48 Stunden gelöscht.

{!settings.emailLinksAvailable && (

Wirkt erst mit eingerichtetem Mail-Server und gesetzter BASE_URL.

)} {settings.registrationVerifyConfiguredViaEnv && (

Über REGISTRATION_VERIFY_EMAIL festgelegt.

)}
handleSaveSystemSettings({ registrationVerifyEmail })} title="E-Mail-Adresse bei der Registrierung bestätigen lassen" />
)}
); })()}
)} {/* ================================================================= */} {/* 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={settings.smtpPassSet ? 'Gespeichert – leer lassen für keine Änderung' : '••••••••••••'} autoComplete="new-password" className="w-full h-9 px-3 input-field font-mono" />
setSettings((prev) => ({ ...prev, smtpFrom: e.target.value })) } placeholder="alerts@deinedomain.de" className="w-full h-9 px-3 input-field" />
setSettings((prev) => ({ ...prev, smtpFromName: e.target.value })) } placeholder="MindSift" className="w-full h-9 px-3 input-field" />

Erscheint beim Empfänger als Absender. Leer = „MindSift“; ein Name direkt in der Adresse („Name <adresse>“) hat Vorrang.

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" />
)}
); };