Files
MindSift/frontend/src/features/settings/AdminSettingsView.tsx
T
2026-10-24 22:59:48 +02:00

1318 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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 [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] = useState<UserRole>('member');
const [inviteTeamId, setInviteTeamId] = useState('');
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
const [resetPwdUserId, setResetPwdUserId] = useState<string | null>(null);
const [resetPwdVal, setResetPwdVal] = useState('');
// --- Organizations & Teams State ---
const [orgs, setOrgs] = useState<Organization[]>([]);
const [allTeams, setAllTeams] = useState<Team[]>([]);
const [newOrgName, setNewOrgName] = useState('');
const [selectedOrgId, setSelectedOrgId] = useState('');
const [newTeamName, setNewTeamName] = useState('');
const [newTeamDesc, setNewTeamDesc] = useState('');
const [memberSelectByTeam, setMemberSelectByTeam] = useState<Record<string, string>>({});
const [memberRoleByTeam, setMemberRoleByTeam] = useState<Record<string, string>>({});
// --- Server & SMTP Settings State ---
const [settings, setSettings] = useState<SystemSettings>({
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<SystemSettings>) => {
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 (
<div className="w-full space-y-4">
{/* Top Page Header */}
<div className="flex items-center justify-between border-b border-border pb-3">
<div className="flex items-center gap-3">
<button
onClick={onBack}
className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5"
title="Zurück zur Übersicht"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Zurück</span>
</button>
<div>
<h1 className="text-base font-bold text-white tracking-tight flex items-center gap-2">
<Shield className="w-4 h-4 text-accent" />
<span>Administration & Team-Steuerung</span>
</h1>
<p className="text-xs text-zinc-400">
Benutzerverwaltung, Einladungscodes, Organisationen, Teams, Isolationsmodus & Mail-Server
</p>
</div>
</div>
<span className="px-2.5 py-1 bg-accent/15 border border-accent text-accent text-xs font-semibold">
{isServerAdmin ? 'Server-Administrator' : 'Team-Administrator'}
</span>
</div>
{statusMsg && (
<div
className={`p-3 border text-xs flex items-center justify-between gap-2 ${
statusMsg.type === 'ok'
? 'bg-blue-950/30 border-accent text-blue-200'
: 'bg-red-950/30 border-red-500 text-red-200'
}`}
>
<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-red-400 flex-shrink-0" />
)}
<span>{statusMsg.text}</span>
</div>
<button
onClick={() => setStatusMsg(null)}
className="text-zinc-400 hover:text-white 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="bg-panel border border-border divide-y divide-border">
<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-white border-l-2 border-l-accent'
: 'text-zinc-400 hover:text-white hover:bg-white/[0.02]'
}`}
>
<Users className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Benutzer & Einladungen</p>
<p className="text-[10px] text-zinc-500">Konten, Rollen & Invite-Codes</p>
</div>
</button>
<button
onClick={() => setSection('orgs')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'orgs'
? 'bg-panel-elevated text-white border-l-2 border-l-accent'
: 'text-zinc-400 hover:text-white hover:bg-white/[0.02]'
}`}
>
<Building2 className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Organisationen & Teams</p>
<p className="text-[10px] text-zinc-500">Teams & Mitglieder zuweisen</p>
</div>
</button>
{isServerAdmin && (
<>
<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-white border-l-2 border-l-accent'
: 'text-zinc-400 hover:text-white hover:bg-white/[0.02]'
}`}
>
<Server className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Server & Isolation</p>
<p className="text-[10px] text-zinc-500">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-white border-l-2 border-l-accent'
: 'text-zinc-400 hover:text-white hover:bg-white/[0.02]'
}`}
>
<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-zinc-500">Mailversand für Alarme</p>
</div>
</button>
</>
)}
</aside>
{/* Right Content Area */}
<div className="md:col-span-3 bg-panel border border-border 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-white">Benutzersteuerung, Rollen & Einladungen</h2>
<p className="text-zinc-400 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 (Fixed Layout: 4 clean columns with min-w-0, no overflow!) */}
<div className="p-4 bg-canvas border border-border space-y-3">
<h3 className="font-bold text-white 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-4 gap-3">
<div className="min-w-0">
<label className="block text-zinc-400 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 text-white"
/>
</div>
<div className="min-w-0">
<label className="block text-zinc-400 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 text-white"
/>
</div>
<div className="min-w-0">
<label className="block text-zinc-400 mb-1">Initial-Passwort</label>
<input
type="password"
value={newUserPassword}
onChange={(e) => 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"
/>
</div>
<div className="min-w-0">
<label className="block text-zinc-400 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 text-white bg-panel"
>
<option value="member">Mitglied (Standard)</option>
<option value="team_admin">Team-Admin (Darf einladen)</option>
{isServerAdmin && <option value="admin">Server-Admin (Vollzugriff)</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 space-y-3">
<h3 className="font-bold text-white 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-zinc-400 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 text-white"
/>
</div>
<div className="min-w-0">
<label className="block text-zinc-400 mb-1">Zugewiesene 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 text-white bg-panel"
>
<option value="member">Mitglied</option>
<option value="team_admin">Team-Admin</option>
{isServerAdmin && <option value="admin">Server-Admin</option>}
</select>
</div>
<div className="min-w-0">
<label className="block text-zinc-400 mb-1">
Automatisch Team zuweisen (optional)
</label>
<select
value={inviteTeamId}
onChange={(e) => setInviteTeamId(e.target.value)}
className="w-full min-w-0 h-8 px-2 input-field text-white bg-panel"
>
<option value="">Kein direktes Team</option>
{flattenedTeams.map((t) => (
<option key={t.id} value={t.id}>
{t.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-white"
>
<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-zinc-400">
Aktive & bisherige Einladungscodes:
</p>
<div className="divide-y divide-border border border-border bg-panel">
{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">
{inv.code}
</code>
<span className="text-zinc-300">
Rolle: <strong>{inv.role}</strong>
{inv.email ? ` · Für: ${inv.email}` : ''}
</span>
{inv.usedBy ? (
<span className="px-1.5 py-0.5 bg-zinc-800 text-zinc-400 text-[10px]">
Eingelöst
</span>
) : (
<span className="px-1.5 py-0.5 bg-emerald-950/60 border border-emerald-500/40 text-emerald-300 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-white">
Registrierte Benutzer ({users.length})
</h3>
<div className="border border-border divide-y divide-border bg-canvas">
{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>
<div className="flex items-center gap-2">
<span className="font-bold text-white">{u.displayName}</span>
{!u.isActive && (
<span className="px-1.5 py-0.5 bg-red-950 text-red-300 border border-red-500/40 text-[10px]">
Gesperrt
</span>
)}
</div>
<p className="text-zinc-400 text-[11px]">{u.email}</p>
</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] text-white bg-panel"
title="Benutzerrolle ändern"
>
<option value="member">Mitglied</option>
<option value="team_admin">Team-Admin</option>
{isServerAdmin && <option value="admin">Server-Admin</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>
{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 eingeben…"
className="flex-1 h-7 px-2 input-field text-white"
/>
<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 & TEAMS */}
{/* ================================================================= */}
{section === 'orgs' && (
<div className="space-y-6">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-white">Organisationen & Teams verwalten</h2>
<p className="text-zinc-400 mt-0.5">
Erstelle Organisationen und unterteile sie in Teams. Weise Benutzern Teams zu, damit
sie im Modus &quot;Teams isoliert&quot; gemeinsam an Listen und Aufgaben arbeiten können.
</p>
</div>
{/* Create Organization & Team */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="p-4 bg-canvas border border-border space-y-3">
<h3 className="font-bold text-white">1. Neue Organisation gründen</h3>
<form onSubmit={handleCreateOrg} className="flex gap-2">
<input
type="text"
value={newOrgName}
onChange={(e) => 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"
/>
<button type="submit" className="btn-primary h-8 px-3 text-xs flex-shrink-0">
Anlegen
</button>
</form>
</div>
<div className="p-4 bg-canvas border border-border space-y-3">
<h3 className="font-bold text-white">2. Neues Team in Organisation anlegen</h3>
{orgs.length === 0 ? (
<p className="text-zinc-500">
Bitte lege zuerst links eine Organisation an.
</p>
) : (
<form onSubmit={handleCreateTeam} className="space-y-2">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<select
value={selectedOrgId}
onChange={(e) => setSelectedOrgId(e.target.value)}
className="w-full min-w-0 h-8 px-2 input-field text-white bg-panel"
>
{orgs.map((o) => (
<option key={o.id} value={o.id}>
Org: {o.name}
</option>
))}
</select>
<input
type="text"
value={newTeamName}
onChange={(e) => 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"
/>
</div>
<div className="flex gap-2">
<input
type="text"
value={newTeamDesc}
onChange={(e) => setNewTeamDesc(e.target.value)}
placeholder="Kurzbeschreibung (optional)…"
className="flex-1 min-w-0 h-8 px-2.5 input-field text-white"
/>
<button type="submit" className="btn-primary h-8 px-3 text-xs flex-shrink-0">
Team erstellen
</button>
</div>
</form>
)}
</div>
</div>
{/* Organizations & Nested Teams Display */}
<div className="space-y-4">
{orgs.length === 0 ? (
<div className="p-6 bg-canvas border border-border text-center text-zinc-500">
Noch keine Organisationen vorhanden.
</div>
) : (
orgs.map((org) => {
const orgTeams = Array.isArray(org.teams) ? org.teams : [];
return (
<div key={org.id} className="bg-canvas border border-border">
<div className="p-3 bg-panel-elevated border-b border-border flex items-center justify-between">
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-accent" />
<span className="font-bold text-sm text-white">{org.name}</span>
<span className="text-[11px] text-zinc-400">
({orgTeams.length} {orgTeams.length === 1 ? 'Team' : 'Teams'})
</span>
</div>
<button
onClick={() => handleDeleteOrg(org)}
className="btn-icon btn-icon-danger"
title="Organisation löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="p-3 space-y-3">
{orgTeams.length === 0 ? (
<p className="text-zinc-500 text-xs">
Noch keine Teams in dieser Organisation.
</p>
) : (
orgTeams.map((team) => {
const members = Array.isArray(team.members) ? team.members : [];
return (
<div
key={team.id}
className="p-3 bg-panel border border-border space-y-3"
>
<div className="flex items-center justify-between border-b border-border pb-2">
<div>
<span className="font-bold text-white flex items-center gap-1.5">
<Users className="w-3.5 h-3.5 text-accent" />
<span>{team.name}</span>
</span>
{team.description && (
<p className="text-[11px] text-zinc-400 mt-0.5">
{team.description}
</p>
)}
</div>
<button
onClick={() => handleDeleteTeam(team)}
className="btn-icon btn-icon-danger"
title="Team löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
{/* Team Members */}
<div className="space-y-1.5">
<p className="text-[11px] font-semibold text-zinc-400">
Mitglieder ({members.length}):
</p>
{members.length === 0 ? (
<p className="text-[11px] text-zinc-500">
Noch keine Mitglieder zugewiesen.
</p>
) : (
<div className="flex flex-wrap gap-1.5">
{members.map((m) => (
<div
key={m.userId}
className="px-2 py-1 bg-canvas border border-border flex items-center gap-2"
>
<span className="text-white font-medium">
{m.displayName}
</span>
<span className="text-[10px] text-zinc-400">
({m.role})
</span>
<button
onClick={() =>
handleRemoveMemberFromTeam(team.id, m.userId)
}
className="text-zinc-500 hover:text-red-400"
title="Aus Team entfernen"
>
×
</button>
</div>
))}
</div>
)}
</div>
{/* Add User to Team */}
<div className="pt-2 border-t border-border flex flex-wrap items-center gap-2">
<select
value={memberSelectByTeam[team.id] || ''}
onChange={(e) =>
setMemberSelectByTeam((prev) => ({
...prev,
[team.id]: e.target.value,
}))
}
className="flex-1 min-w-[160px] h-7 px-2 input-field text-[11px] text-white bg-canvas"
>
<option value="">Benutzer auswählen…</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.displayName} ({u.email})
</option>
))}
</select>
<select
value={memberRoleByTeam[team.id] || 'member'}
onChange={(e) =>
setMemberRoleByTeam((prev) => ({
...prev,
[team.id]: e.target.value,
}))
}
className="h-7 px-2 input-field text-[11px] text-white bg-canvas"
>
<option value="member">Mitglied</option>
<option value="team_admin">Team-Admin</option>
</select>
<button
type="button"
onClick={() => handleAddMemberToTeam(team.id)}
disabled={!memberSelectByTeam[team.id]}
className="btn-action h-7 px-2.5 text-[11px] disabled:opacity-40"
>
+ Zum Team hinzufügen
</button>
</div>
</div>
);
})
)}
</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-white">
Server-Grundeinstellungen & Benutzer-Sichtbarkeit
</h2>
<p className="text-zinc-400 mt-0.5">
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.
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-zinc-300 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 text-white"
/>
<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-zinc-300 font-semibold flex items-center gap-1.5">
<Share2 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">
<button
type="button"
onClick={() => handleSaveSystemSettings({ sharingMode: 'strict_private' })}
className={`p-4 text-left border transition-colors ${
settings.sharingMode === 'strict_private'
? 'bg-canvas border-accent text-white'
: 'bg-canvas/50 border-border text-zinc-400 hover:text-white'
}`}
>
<p className="font-bold text-xs text-white">1. Strikt Privat (Keiner sieht andere)</p>
<p className="text-[11px] text-zinc-400 mt-1 leading-relaxed">
Jeder Benutzer arbeitet komplett für sich allein. Keine geteilten Spaces und keine
Benutzersuche möglich.
</p>
</button>
<button
type="button"
onClick={() => handleSaveSystemSettings({ sharingMode: 'teams_only' })}
className={`p-4 text-left border transition-colors ${
settings.sharingMode === 'teams_only'
? 'bg-canvas border-accent text-white'
: 'bg-canvas/50 border-border text-zinc-400 hover:text-white'
}`}
>
<p className="font-bold text-xs text-white">2. Teams isoliert (Empfohlen)</p>
<p className="text-[11px] text-zinc-400 mt-1 leading-relaxed">
Benutzer sehen ausschließlich Personen, mit denen sie mindestens ein gemeinsames Team
teilen.
</p>
</button>
<button
type="button"
onClick={() => handleSaveSystemSettings({ sharingMode: 'open' })}
className={`p-4 text-left border transition-colors ${
settings.sharingMode === 'open'
? 'bg-canvas border-accent text-white'
: 'bg-canvas/50 border-border text-zinc-400 hover:text-white'
}`}
>
<p className="font-bold text-xs text-white">3. Offen (Alle sehen alle)</p>
<p className="text-[11px] text-zinc-400 mt-1 leading-relaxed">
Alle registrierten Benutzer dieser Instanz können sich gegenseitig sehen und Spaces
miteinander teilen.
</p>
</button>
</div>
</div>
{/* Public Registration Toggle */}
<div className="p-4 bg-canvas border border-border flex items-center justify-between gap-4">
<div>
<p className="font-bold text-white">Öffentliche Selbstregistrierung erlauben</p>
<p className="text-[11px] text-zinc-400 mt-0.5">
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.
</p>
</div>
<button
type="button"
onClick={() =>
handleSaveSystemSettings({ allowRegistration: !settings.allowRegistration })
}
className={`h-8 px-4 text-xs font-semibold border flex-shrink-0 ${
settings.allowRegistration
? 'bg-accent/20 border-accent text-accent'
: 'bg-panel border-border text-zinc-400'
}`}
>
{settings.allowRegistration ? 'Erlaubt (Offen)' : 'Gesperrt (Nur mit Einladung)'}
</button>
</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-white">
E-Mail / SMTP-Server für Aufgaben-Alarme
</h2>
<p className="text-zinc-400 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="px-2.5 py-1 bg-emerald-950/50 border border-emerald-500/40 text-emerald-300 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-zinc-300 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.gmail.com oder mail.deinedomain.de"
className="w-full h-9 px-3 input-field text-white font-mono"
/>
</div>
<div>
<label className="block text-zinc-300 font-medium mb-1">
SMTP Port (`SMTP_PORT`)
</label>
<input
type="number"
value={settings.smtpPort || 587}
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpPort: Number(e.target.value) }))
}
placeholder="587"
className="w-full h-9 px-3 input-field text-white font-mono"
/>
</div>
<div>
<label className="block text-zinc-300 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 text-white font-mono"
/>
</div>
<div>
<label className="block text-zinc-300 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 text-white font-mono"
/>
</div>
<div>
<label className="block text-zinc-300 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 text-white"
/>
</div>
<div className="flex items-center pt-5">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={settings.smtpTls ?? true}
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpTls: e.target.checked }))
}
className="w-4 h-4 accent-blue-600"
/>
<span className="text-white font-medium">
STARTTLS / TLS-Verschlüsselung verwenden (`SMTP_TLS=true`)
</span>
</label>
</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 space-y-2">
<h3 className="font-bold text-white">SMTP-Verbindung testen</h3>
<p className="text-[11px] text-zinc-400">
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 text-white"
/>
<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>
);
};