Files
MindSift/frontend/src/features/settings/AdminSettingsView.tsx
T
sandrodachandClaude Opus 5.5 b5f926bcf7 fix(auth): new passwords need at least 8 characters (FLAW-09)
auth.ValidatePassword checks every way a password is set: setup,
registration, profile, admin create and reset, reset by mail. Same rule
in the browser before sending (lib/password.ts), with our own German
message instead of the browser popup. Existing shorter passwords still
log in. Profile and admin reset no longer trim the password, which made
passwords with surrounding spaces unusable for login.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:20:46 +01:00

1310 lines
56 KiB
TypeScript

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<AdminSettingsViewProps> = ({
currentUser,
onBack,
}) => {
const isServerAdmin = currentUser.role === 'admin';
const [section, setSection] = useState<AdminSection>('users');
// --- Users & Invites State ---
const [users, setUsers] = useState<User[]>([]);
const [invites, setInvites] = useState<UserInvite[]>([]);
const [newUserName, setNewUserName] = useState('');
const [newUserEmail, setNewUserEmail] = useState('');
const [newUserPassword, setNewUserPassword] = useState('');
const [newUserRole, setNewUserRole] = useState<UserRole>('member');
const [newUserOrg, setNewUserOrg] = useState('');
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] = useState<UserRole>('member');
const [inviteOrg, setInviteOrg] = useState('');
// --- Organizations State ---
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [newOrgName, setNewOrgName] = useState('');
const [renamingOrgId, setRenamingOrgId] = useState<string | null>(null);
const [renameOrgVal, setRenameOrgVal] = useState('');
const [deletingOrgId, setDeletingOrgId] = useState<string | null>(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<string | null>(null);
const [resetPwdUserId, setResetPwdUserId] = useState<string | null>(null);
const [resetPwdVal, setResetPwdVal] = useState('');
// --- Server & SMTP Settings State ---
const [settings, setSettings] = useState<SystemSettings>({
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<SystemSettings>) => {
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 (
<div className="w-full space-y-4">
{/* Top Page Header */}
<div className="flex items-center justify-between border-b border-border pb-3">
<div className="flex items-center gap-3">
<button
onClick={onBack}
className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5"
title="Zurück zur Übersicht"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Zurück</span>
</button>
<div>
<h1 className="text-base font-bold text-main tracking-tight flex items-center gap-2">
<Shield className="w-4 h-4 text-accent" />
<span>Server-Administration</span>
</h1>
<p className="text-xs text-muted">
Benutzerverwaltung, Einladungscodes, Isolationsmodus & Mail-Server
</p>
</div>
</div>
<span className="badge-accent">Server-Administrator</span>
</div>
{statusMsg && (
<div
className={`p-3 text-xs flex items-center justify-between gap-2 rounded-card ${
statusMsg.type === 'ok' ? 'alert-ok' : 'alert-err'
}`}
>
<div className="flex items-center gap-2">
{statusMsg.type === 'ok' ? (
<Check className="w-4 h-4 text-accent flex-shrink-0" />
) : (
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
)}
<span>{statusMsg.text}</span>
</div>
<button
onClick={() => setStatusMsg(null)}
className="text-muted hover:text-main text-[11px]"
>
Schließen
</button>
</div>
)}
{/* Main 2-Column Layout */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 items-start">
{/* Left Navigation Sidebar */}
<aside className="panel-surface divide-y divide-border overflow-hidden">
<button
onClick={() => setSection('users')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'users'
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
: 'text-muted hover:text-main hover:bg-canvas/50'
}`}
>
<Users className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Benutzer & Einladungen</p>
<p className="text-[10px] text-faint">Konten, Rollen & Invite-Codes</p>
</div>
</button>
{isServerAdmin && (
<>
<button
onClick={() => setSection('organizations')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'organizations'
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
: 'text-muted hover:text-main hover:bg-canvas/50'
}`}
>
<Building2 className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Organisationen</p>
<p className="text-[10px] text-faint">Wer wen sehen darf</p>
</div>
</button>
<button
onClick={() => setSection('server')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'server'
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
: 'text-muted hover:text-main hover:bg-canvas/50'
}`}
>
<Server className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Server & Isolation</p>
<p className="text-[10px] text-faint">Sichtbarkeit & Registrierung</p>
</div>
</button>
<button
onClick={() => setSection('smtp')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'smtp'
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
: 'text-muted hover:text-main hover:bg-canvas/50'
}`}
>
<Mail className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">E-Mail / SMTP-Server</p>
<p className="text-[10px] text-faint">Mailversand für Alarme</p>
</div>
</button>
</>
)}
</aside>
{/* Right Content Area */}
<div className="md:col-span-3 panel-surface p-5 text-xs">
{/* ================================================================= */}
{/* SECTION 1: BENUTZER, ROLLEN & EINLADUNGEN */}
{/* ================================================================= */}
{section === 'users' && (
<div className="space-y-6">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">Benutzersteuerung, Rollen & Einladungen</h2>
<p className="text-muted mt-0.5">
Lege Benutzerkonten direkt an oder erzeuge Einladungscodes, mit denen sich eingeladene
Personen selbst registrieren können, auch wenn die öffentliche Registrierung gesperrt ist.
</p>
</div>
{/* Direct User Creation Form */}
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
<h3 className="font-bold text-main flex items-center gap-1.5">
<UserPlus className="w-3.5 h-3.5 text-accent" />
<span>Neues Benutzerkonto direkt anlegen</span>
</h3>
<form onSubmit={handleCreateUser} className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3">
<div className="min-w-0">
<label className="block text-muted mb-1">Anzeigename</label>
<input
type="text"
value={newUserName}
onChange={(e) => setNewUserName(e.target.value)}
placeholder="z.B. Anna Beispiel"
required
className="w-full min-w-0 h-8 px-2.5 input-field"
/>
</div>
<div className="min-w-0">
<label className="block text-muted mb-1">E-Mail-Adresse</label>
<input
type="email"
value={newUserEmail}
onChange={(e) => setNewUserEmail(e.target.value)}
placeholder="anna@beispiel.de"
required
className="w-full min-w-0 h-8 px-2.5 input-field"
/>
</div>
<div className="min-w-0">
<label className="block text-muted mb-1">Initial-Passwort</label>
<input
type="password"
value={newUserPassword}
onChange={(e) => setNewUserPassword(e.target.value)}
placeholder="Mind. 8 Zeichen"
required
className="w-full min-w-0 h-8 px-2.5 input-field"
/>
</div>
<div className="min-w-0">
<label className="block text-muted mb-1">Benutzerrolle</label>
<select
value={newUserRole}
onChange={(e) => setNewUserRole(e.target.value as UserRole)}
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
>
<option value="member">Mitglied (Standard)</option>
<option value="admin">Server-Admin (Vollzugriff)</option>
</select>
</div>
{isServerAdmin && (
<div className="min-w-0">
<label className="block text-muted mb-1">Organisation</label>
<select
value={newUserOrg}
onChange={(e) => setNewUserOrg(e.target.value)}
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
>
<option value="">Keine Organisation</option>
{organizations.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
</div>
)}
</div>
<div className="flex justify-end">
<button
type="submit"
className="btn-primary h-8 px-4 text-xs font-semibold flex items-center gap-1.5"
>
<Plus className="w-3.5 h-3.5" />
<span>Benutzer jetzt anlegen</span>
</button>
</div>
</form>
</div>
{/* Invite Code Generator */}
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
<h3 className="font-bold text-main flex items-center gap-1.5">
<KeyRound className="w-3.5 h-3.5 text-accent" />
<span>Einladungscode erzeugen (gültig für 7 Tage)</span>
</h3>
<form onSubmit={handleCreateInvite} className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="min-w-0">
<label className="block text-muted mb-1">
Gebundene E-Mail (optional)
</label>
<input
type="email"
value={inviteEmail}
onChange={(e) => 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"
/>
</div>
<div className="min-w-0">
<label className="block text-muted mb-1">Zugewiesene Server-Rolle</label>
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as UserRole)}
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
>
<option value="member">Mitglied</option>
<option value="admin">Server-Admin</option>
</select>
</div>
{isServerAdmin && (
<div className="min-w-0">
<label className="block text-muted mb-1">Organisation</label>
<select
value={inviteOrg}
onChange={(e) => setInviteOrg(e.target.value)}
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
>
<option value="">Keine Organisation</option>
{organizations.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
</div>
)}
</div>
<div className="flex justify-end">
<button
type="submit"
className="btn-action h-8 px-4 text-xs font-semibold flex items-center gap-1.5 border-accent text-main"
>
<KeyRound className="w-3.5 h-3.5 text-accent" />
<span>Einladungscode generieren</span>
</button>
</div>
</form>
{/* Active Invites List */}
{invites.length > 0 && (
<div className="pt-2 border-t border-border space-y-1.5">
<p className="text-[11px] font-semibold text-muted">
Aktive & bisherige Einladungscodes:
</p>
<div className="divide-y divide-border border border-border bg-panel rounded-card overflow-hidden">
{invites.map((inv) => (
<div
key={inv.id}
className="p-2 flex flex-wrap items-center justify-between gap-2"
>
<div className="flex items-center gap-2.5">
<code className="px-2 py-0.5 bg-canvas border border-border font-mono font-bold text-accent rounded-card">
{inv.code}
</code>
<span className="text-muted">
Rolle: <strong className="text-main">{inv.role}</strong>
{inv.email ? ` · Für: ${inv.email}` : ''}
{inv.organizationName ? ` · ${inv.organizationName}` : ''}
</span>
{inv.usedBy ? (
<span className="badge-neutral text-[10px]">
Eingelöst
</span>
) : (
<span className="badge-ok text-[10px]">
Offen
</span>
)}
</div>
<div className="flex items-center gap-1.5">
{!inv.usedBy && (
<button
onClick={() => handleCopyInviteCode(inv)}
className="btn-action h-7 px-2 text-[11px] flex items-center gap-1"
>
<Copy className="w-3 h-3" />
<span>{copiedInviteId === inv.id ? 'Kopiert!' : 'Code kopieren'}</span>
</button>
)}
<button
onClick={() => handleDeleteInvite(inv.id)}
className="btn-icon btn-icon-danger"
title="Einladung löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Users List */}
<div className="space-y-2">
<h3 className="font-bold text-main">
Registrierte Benutzer ({users.length})
</h3>
<div className="border border-border divide-y divide-border bg-canvas rounded-card overflow-hidden">
{users.map((u) => (
<div key={u.id} className="p-3 space-y-2">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="flex items-center gap-2.5">
<UserAvatar user={u} size="sm" className="border border-border/80" />
<div>
<div className="flex items-center gap-2">
<span className="font-bold text-main">{u.displayName}</span>
{!u.isActive && (
<span className="badge-err text-[10px]">
Gesperrt
</span>
)}
</div>
<p className="text-muted text-[11px]">
{u.email}
{isServerAdmin && settings.sharingMode === 'organizations' && !u.organizationId && (
<span className="text-status-warn"> · ohne Organisation, sieht niemanden</span>
)}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{/* Role selector */}
<select
value={u.role}
disabled={!isServerAdmin && u.role === 'admin'}
onChange={(e) => handleChangeUserRole(u, e.target.value as UserRole)}
className="h-7 px-2 input-field text-[11px] bg-panel"
title="Benutzerrolle ändern"
>
<option value="member">Mitglied</option>
<option value="admin">Server-Admin</option>
</select>
{isServerAdmin && (
<select
value={u.organizationId || ''}
onChange={(e) => handleChangeUserOrganization(u, e.target.value)}
className="h-7 px-2 input-field text-[11px] bg-panel max-w-[11rem]"
title="Organisation"
>
<option value="">Keine Organisation</option>
{organizations.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
)}
{isServerAdmin && (
<>
<button
onClick={() =>
setResetPwdUserId(resetPwdUserId === u.id ? null : u.id)
}
className="btn-action h-7 px-2 text-[11px]"
>
Passwort
</button>
{u.id !== currentUser.id && (
<>
<button
onClick={() => handleToggleUserActive(u)}
className="btn-action h-7 px-2 text-[11px]"
>
{u.isActive ? 'Sperren' : 'Aktivieren'}
</button>
<button
onClick={() => handleDeleteUser(u)}
className="btn-icon btn-icon-danger"
title="Benutzer löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
</>
)}
</div>
</div>
{pendingMove?.user.id === u.id && (
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-border">
<AlertCircle className="w-3.5 h-3.5 text-status-warn shrink-0" />
<span className="flex-1 min-w-0 text-muted">
{u.displayName} verlässt „{orgName(u.organizationId)}“ und verliert dort alle Freigaben,
Gruppen und Aufgaben-Zuweisungen.
</span>
<button
onClick={() => applyUserOrganization(u, pendingMove.orgId)}
className="btn-primary h-7 px-3 text-[11px]"
>
{pendingMove.orgId ? `Nach „${orgName(pendingMove.orgId)}“ verschieben` : 'Entfernen'}
</button>
<button onClick={() => setPendingMove(null)} className="btn-action h-7 px-2 text-[11px]">
Abbrechen
</button>
</div>
)}
{resetPwdUserId === u.id && (
<div className="flex items-center gap-2 pt-2 border-t border-border">
<input
type="password"
value={resetPwdVal}
onChange={(e) => setResetPwdVal(e.target.value)}
placeholder="Neues Passwort (mind. 8 Zeichen)…"
className="flex-1 h-7 px-2 input-field"
/>
<button
onClick={() => handleConfirmResetPassword(u.id)}
className="btn-primary h-7 px-3 text-[11px]"
>
Speichern
</button>
<button
onClick={() => setResetPwdUserId(null)}
className="btn-action h-7 px-2 text-[11px]"
>
Abbrechen
</button>
</div>
)}
</div>
))}
</div>
</div>
</div>
)}
{/* ================================================================= */}
{/* SECTION 2: ORGANISATIONEN (SERVER-ADMIN ONLY) */}
{/* ================================================================= */}
{section === 'organizations' && isServerAdmin && (
<div className="space-y-6">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">Organisationen</h2>
<p className="text-muted mt-0.5">
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.
</p>
{settings.sharingMode !== 'organizations' && (
<p className="mt-2 text-status-warn">
Der aktuelle Sichtbarkeits-Modus ist nicht „Organisationen“; die Zuordnung wirkt sich derzeit nicht aus.
</p>
)}
</div>
<form onSubmit={handleCreateOrganization} className="flex gap-2 max-w-md">
<input
type="text"
value={newOrgName}
onChange={(e) => setNewOrgName(e.target.value)}
placeholder="Name der neuen Organisation"
className="flex-1 min-w-0 h-8 px-2.5 input-field"
/>
<button type="submit" className="btn-primary h-8 px-3 text-xs flex items-center gap-1.5">
<Plus className="w-3.5 h-3.5" />
<span>Anlegen</span>
</button>
</form>
{organizations.length === 0 ? (
<p className="text-muted">Noch keine Organisation angelegt.</p>
) : (
<div className="border border-border divide-y divide-border bg-canvas rounded-card overflow-hidden">
{organizations.map((o) => {
const members = users.filter((u) => u.organizationId === o.id);
return (
<div key={o.id} className="p-3 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
{renamingOrgId === o.id ? (
<div className="flex flex-1 min-w-0 gap-2">
<input
type="text"
value={renameOrgVal}
onChange={(e) => setRenameOrgVal(e.target.value)}
className="flex-1 min-w-0 h-7 px-2 input-field"
autoFocus
/>
<button onClick={() => handleRenameOrganization(o.id)} className="btn-primary h-7 px-3 text-[11px]">
Speichern
</button>
<button onClick={() => setRenamingOrgId(null)} className="btn-action h-7 px-2 text-[11px]">
Abbrechen
</button>
</div>
) : (
<div className="flex items-center gap-2 min-w-0">
<Building2 className="w-4 h-4 text-accent shrink-0" />
<span className="font-bold text-main truncate">{o.name}</span>
<CountBadge count={o.memberCount} showZero />
</div>
)}
{renamingOrgId !== o.id && (
<div className="flex items-center gap-1.5">
<button
onClick={() => {
setRenamingOrgId(o.id);
setRenameOrgVal(o.name);
}}
className="btn-icon"
title="Umbenennen"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setDeletingOrgId(deletingOrgId === o.id ? null : o.id)}
className="btn-icon btn-icon-danger"
title="Organisation löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
{members.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{members.map((m) => (
<span
key={m.id}
className="inline-flex items-center gap-1.5 pl-0.5 pr-2 py-0.5 rounded-pill bg-panel border border-border text-[11px] text-main"
>
<UserAvatar user={m} size="xxs" showTitle={false} />
<span>{m.displayName}</span>
</span>
))}
</div>
)}
{deletingOrgId === o.id && (
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-border">
<AlertCircle className="w-3.5 h-3.5 text-status-warn shrink-0" />
<span className="flex-1 min-w-0 text-muted">
Die {o.memberCount} Mitglieder sind danach ohne Organisation und verlieren ihre
Freigaben, Gruppen und Zuweisungen untereinander.
</span>
<button onClick={() => handleDeleteOrganization(o.id)} className="btn-primary h-7 px-3 text-[11px]">
Löschen
</button>
<button onClick={() => setDeletingOrgId(null)} className="btn-action h-7 px-2 text-[11px]">
Abbrechen
</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
)}
{/* ================================================================= */}
{/* SECTION 3: SERVER & SHARING-ISOLATION (SERVER-ADMIN ONLY) */}
{/* ================================================================= */}
{section === 'server' && isServerAdmin && (
<div className="space-y-6">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">
Server-Grundeinstellungen & Benutzer-Sichtbarkeit
</h2>
<p className="text-muted mt-0.5">
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.
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-muted font-semibold mb-1">
Name der MindSift-Instanz
</label>
<div className="flex gap-2 max-w-md">
<input
type="text"
value={settings.instanceName}
onChange={(e) =>
setSettings((prev) => ({ ...prev, instanceName: e.target.value }))
}
className="flex-1 h-9 px-3 input-field"
/>
<button
type="button"
onClick={() => handleSaveSystemSettings()}
className="btn-primary h-9 px-4 text-xs"
>
Speichern
</button>
</div>
</div>
{/* Sharing Isolation Cards */}
<div className="space-y-2">
<label className="block text-muted font-semibold flex items-center gap-1.5">
<Users className="w-4 h-4 text-accent" />
<span>Globale Sharing- & Sichtbarkeits-Isolation</span>
</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{SHARING_MODES.map((m) => (
<button
key={m.id}
type="button"
onClick={() => handleSelectSharingMode(m.id)}
className={`p-4 text-left border rounded-card transition-colors ${
settings.sharingMode === m.id
? 'bg-canvas border-accent text-main shadow-panel'
: 'bg-canvas/50 border-border text-muted hover:text-main'
}`}
>
<p className="font-bold text-xs text-main">{m.title}</p>
<p className="text-[11px] text-muted mt-1 leading-relaxed">{m.text}</p>
</button>
))}
</div>
{pendingMode && (
<div className="p-3 border border-status-warn/40 bg-status-warn/5 rounded-card flex flex-wrap items-center gap-2">
<AlertCircle className="w-4 h-4 text-status-warn shrink-0" />
<span className="flex-1 min-w-0 text-main">
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.
</span>
<button
onClick={() => {
const mode = pendingMode.mode;
setPendingMode(null);
handleSaveSystemSettings({ sharingMode: mode });
}}
className="btn-primary h-7 px-3 text-[11px]"
>
Trotzdem wechseln
</button>
<button onClick={() => setPendingMode(null)} className="btn-action h-7 px-2 text-[11px]">
Abbrechen
</button>
</div>
)}
</div>
{/* Alarm webhooks into the local network (SSRF protection) */}
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
<div className="pr-4">
<p className="font-bold text-main">Alarm-Webhooks ins lokale Netz erlauben</p>
<p className="text-[11px] text-muted mt-0.5">
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.
</p>
</div>
<ToggleSwitch
checked={Boolean(settings.allowPrivateWebhooks)}
onChange={(allowPrivateWebhooks) => handleSaveSystemSettings({ allowPrivateWebhooks })}
/>
</div>
{/* Public Registration Toggle Switch */}
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
<div className="pr-4">
<p className="font-bold text-main">Öffentliche Selbstregistrierung erlauben</p>
<p className="text-[11px] text-muted mt-0.5">
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.
</p>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<span className={`text-xs font-medium hidden sm:inline ${settings.allowRegistration ? 'text-accent' : 'text-muted'}`}>
{settings.allowRegistration ? 'Aktiviert (Offen)' : 'Gesperrt (Nur Einladung)'}
</span>
<ToggleSwitch
checked={settings.allowRegistration}
onChange={(allowRegistration) => handleSaveSystemSettings({ allowRegistration })}
title={settings.allowRegistration ? 'Selbstregistrierung deaktivieren' : 'Selbstregistrierung aktivieren'}
/>
</div>
</div>
</div>
</div>
)}
{/* ================================================================= */}
{/* SECTION 4: E-MAIL / SMTP-SERVER (SERVER-ADMIN ONLY) */}
{/* ================================================================= */}
{section === 'smtp' && isServerAdmin && (
<div className="space-y-6">
<div className="border-b border-border pb-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div>
<h2 className="text-sm font-bold text-main">
E-Mail / SMTP-Server für Aufgaben-Alarme
</h2>
<p className="text-muted mt-0.5">
Konfiguriere den Postausgangsserver per Docker-Compose Environment-Variablen oder
direkt hier über die Oberfläche (wird persistent in der SQLite-Datenbank gespeichert).
</p>
</div>
{settings.smtpConfiguredViaEnv && (
<span className="badge-ok text-[11px] flex-shrink-0">
Docker Env erkannt
</span>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-muted font-medium mb-1">
SMTP Host (`SMTP_HOST`)
</label>
<input
type="text"
value={settings.smtpHost || ''}
onChange={(e) =>
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"
/>
</div>
<div>
<label className="block text-muted font-medium mb-1">
SMTP Port (`SMTP_PORT`)
</label>
<input
type="text"
inputMode="numeric"
value={settings.smtpPort ?? ''}
onChange={(e) =>
// 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"
/>
</div>
<div>
<label className="block text-muted font-medium mb-1">
SMTP Benutzername (`SMTP_USER`)
</label>
<input
type="text"
value={settings.smtpUser || ''}
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpUser: e.target.value }))
}
placeholder="alerts@deinedomain.de"
className="w-full h-9 px-3 input-field font-mono"
/>
</div>
<div>
<label className="block text-muted font-medium mb-1">
SMTP Passwort (`SMTP_PASS`)
</label>
<input
type="password"
value={settings.smtpPass || ''}
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpPass: e.target.value }))
}
placeholder="••••••••••••"
className="w-full h-9 px-3 input-field font-mono"
/>
</div>
<div>
<label className="block text-muted font-medium mb-1">
Absender-Adresse (`SMTP_FROM`)
</label>
<input
type="text"
value={settings.smtpFrom || ''}
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpFrom: e.target.value }))
}
placeholder="MindSift <alerts@deinedomain.de>"
className="w-full h-9 px-3 input-field"
/>
</div>
<div className="sm:col-span-2 space-y-3 pt-1">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-main font-medium">Verschlüsselung (`SMTP_TLS`)</p>
<p className="text-[11px] text-muted">
Port 465: TLS ab Verbindungsbeginn, sonst STARTTLS. Ist es an, wird ohne Verschlüsselung nicht gesendet.
</p>
</div>
<ToggleSwitch
checked={settings.smtpTls ?? true}
onChange={(smtpTls) => setSettings((prev) => ({ ...prev, smtpTls }))}
/>
</div>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-main font-medium">Zertifikatsprüfung deaktivieren (`SMTP_SKIP_VERIFY`)</p>
<p className="text-[11px] text-muted">
Nur für interne Mailserver mit selbstsigniertem Zertifikat oder abweichendem Namen. Die Verbindung
bleibt verschlüsselt, der Server wird aber nicht mehr geprüft.
</p>
</div>
<ToggleSwitch
checked={settings.smtpSkipVerify ?? false}
disabled={!(settings.smtpTls ?? true)}
onChange={(smtpSkipVerify) => setSettings((prev) => ({ ...prev, smtpSkipVerify }))}
/>
</div>
</div>
</div>
<div className="flex justify-end pt-2 border-t border-border">
<button
type="button"
onClick={() => handleSaveSystemSettings()}
className="btn-primary h-9 px-5 text-xs font-semibold"
>
SMTP-Konfiguration in Datenbank speichern
</button>
</div>
{/* Test SMTP Delivery */}
<div className="p-4 bg-canvas border border-border rounded-card space-y-2">
<h3 className="font-bold text-main">SMTP-Verbindung testen</h3>
<p className="text-[11px] text-muted">
Sendet sofort eine Test-E-Mail mit den oben eingetragenen Daten.
</p>
<div className="flex flex-col sm:flex-row gap-2">
<input
type="email"
value={testSmtpEmail}
onChange={(e) => setTestSmtpEmail(e.target.value)}
placeholder="empfaenger@beispiel.de"
className="flex-1 h-8 px-3 input-field"
/>
<button
type="button"
onClick={handleTestSmtp}
disabled={testingSmtp || !testSmtpEmail.trim()}
className="btn-action h-8 px-4 text-xs flex items-center justify-center gap-1.5 disabled:opacity-40"
>
<Send className="w-3.5 h-3.5 text-accent" />
<span>{testingSmtp ? 'Sende Test-Mail…' : 'Test-E-Mail senden'}</span>
</button>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};