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>
1310 lines
56 KiB
TypeScript
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>
|
|
);
|
|
};
|