MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
1318 lines
57 KiB
TypeScript
1318 lines
57 KiB
TypeScript
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 "Teams isoliert" 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>
|
||
);
|
||
};
|