MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m20s
- Invites: link /?invite=CODE, copy link, send by email, max uses with counter - Registration modes closed | approval | domain | domain+approval | open, waiting list with approve/reject - REGISTRATION_MODE / REGISTRATION_DOMAINS lock the admin setting; ALLOW_REGISTRATION=true is honoured Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1556 lines
71 KiB
TypeScript
1556 lines
71 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
||
import {
|
||
Shield,
|
||
Users,
|
||
Server,
|
||
Mail,
|
||
Plus,
|
||
Trash2,
|
||
KeyRound,
|
||
Copy,
|
||
Check,
|
||
ArrowLeft,
|
||
Send,
|
||
AlertCircle,
|
||
UserPlus,
|
||
Building2,
|
||
Pencil,
|
||
Link2,
|
||
Clock,
|
||
X,
|
||
} from 'lucide-react';
|
||
import {
|
||
Organization,
|
||
RegistrationMode,
|
||
SharingMode,
|
||
SystemSettings,
|
||
User,
|
||
UserInvite,
|
||
UserRole,
|
||
} from '../../lib/types';
|
||
import { api, setCurrentSharingMode, triggerHaptic } from '../../lib/api';
|
||
import { passwordProblem } from '../../lib/password';
|
||
import { UserAvatar } from '../../components/UserAvatar';
|
||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||
import { BackupAdminCard } from './BackupAdminCard';
|
||
import { VoiceStatusCard } from './VoiceStatusCard';
|
||
import { CountBadge } from '../../components/CountBadge';
|
||
|
||
interface AdminSettingsViewProps {
|
||
currentUser: User;
|
||
onBack: () => void;
|
||
}
|
||
|
||
type AdminSection = 'users' | 'organizations' | 'server' | 'smtp';
|
||
|
||
const SHARING_MODES: { id: SharingMode; title: string; text: string }[] = [
|
||
{
|
||
id: 'private',
|
||
title: '1. Privat (Keiner sieht andere)',
|
||
text: 'Jeder arbeitet für sich allein. Keine Benutzersuche, kein Teilen, keine Gruppen.',
|
||
},
|
||
{
|
||
id: 'organizations',
|
||
title: '2. Organisationen (Empfohlen)',
|
||
text: 'Benutzer sehen nur Mitglieder ihrer eigenen Organisation. Wer keiner Organisation angehört, sieht niemanden.',
|
||
},
|
||
{
|
||
id: 'open',
|
||
title: '3. Offen (Alle sehen alle)',
|
||
text: 'Alle Benutzer dieser Instanz können sich gegenseitig finden und miteinander teilen.',
|
||
},
|
||
];
|
||
// Stricter modes come first; switching to a stricter one can remove shares
|
||
const modeRank = (m: SharingMode) => SHARING_MODES.findIndex((x) => x.id === m);
|
||
|
||
export const AdminSettingsView: React.FC<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('');
|
||
// A link for several people (1 = classic single-use code)
|
||
const [inviteMaxUses, setInviteMaxUses] = useState(1);
|
||
// "Per E-Mail einladen": which invite the address field is open for
|
||
const [mailInviteId, setMailInviteId] = useState<string | null>(null);
|
||
const [mailInviteTo, setMailInviteTo] = useState('');
|
||
const [mailInviteBusy, setMailInviteBusy] = useState(false);
|
||
|
||
// --- 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: '',
|
||
smtpFromName: '',
|
||
smtpTls: true,
|
||
smtpSkipVerify: false,
|
||
smtpConfiguredViaEnv: false,
|
||
});
|
||
const [testSmtpEmail, setTestSmtpEmail] = useState(currentUser.email || '');
|
||
const [testingSmtp, setTestingSmtp] = useState(false);
|
||
|
||
const [statusMsg, setStatusMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||
|
||
const loadAll = async () => {
|
||
try {
|
||
const [uList, invList] = await Promise.all([
|
||
api.adminGetUsers(),
|
||
api.adminGetInvites(),
|
||
]);
|
||
setUsers(uList);
|
||
setInvites(invList);
|
||
|
||
if (isServerAdmin) {
|
||
const [sys, orgs] = await Promise.all([api.adminGetSettings(), api.adminGetOrganizations()]);
|
||
setOrganizations(orgs);
|
||
setSettings({
|
||
sharingMode: sys.sharingMode || 'organizations',
|
||
allowRegistration: Boolean(sys.allowRegistration),
|
||
registrationMode: sys.registrationMode || (sys.allowRegistration ? 'open' : 'closed'),
|
||
registrationDomains: sys.registrationDomains || '',
|
||
registrationConfiguredViaEnv: Boolean(sys.registrationConfiguredViaEnv),
|
||
instanceName: sys.instanceName || 'MindSift',
|
||
smtpHost: sys.smtpHost || '',
|
||
smtpPort: sys.smtpPort ? String(sys.smtpPort) : '587',
|
||
smtpUser: sys.smtpUser || '',
|
||
smtpPass: sys.smtpPass || '',
|
||
smtpFrom: sys.smtpFrom || '',
|
||
smtpFromName: sys.smtpFromName || '',
|
||
smtpTls: sys.smtpTls ?? true,
|
||
smtpSkipVerify: Boolean(sys.smtpSkipVerify),
|
||
allowPrivateWebhooks: Boolean(sys.allowPrivateWebhooks),
|
||
smtpConfiguredViaEnv: Boolean(sys.smtpConfiguredViaEnv),
|
||
backupEnabled: Boolean(sys.backupEnabled),
|
||
backupTime: sys.backupTime || '03:00',
|
||
backupRetentionDays: sys.backupRetentionDays || 3,
|
||
});
|
||
}
|
||
} catch (err: any) {
|
||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Laden der Administrationsdaten' });
|
||
}
|
||
};
|
||
|
||
// Read-only in the public demo (FLAW-33)
|
||
const [demoMode, setDemoMode] = useState(false);
|
||
useEffect(() => {
|
||
loadAll();
|
||
api
|
||
.getAuthStatus()
|
||
.then((st) => setDemoMode(Boolean(st.demoMode)))
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
// ============================================================================
|
||
// HANDLERS: USERS & INVITES
|
||
// ============================================================================
|
||
const handleCreateUser = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setStatusMsg(null);
|
||
const problem = passwordProblem(newUserPassword);
|
||
if (problem) {
|
||
setStatusMsg({ type: 'err', text: problem });
|
||
return;
|
||
}
|
||
triggerHaptic(15);
|
||
try {
|
||
const created = await api.adminCreateUser({
|
||
email: newUserEmail.trim(),
|
||
password: newUserPassword,
|
||
displayName: newUserName.trim() || newUserEmail.split('@')[0],
|
||
role: newUserRole,
|
||
organizationId: newUserOrg || undefined,
|
||
});
|
||
setUsers((prev) => [...prev, created]);
|
||
if (newUserOrg) await reloadOrganizations();
|
||
setNewUserName('');
|
||
setNewUserEmail('');
|
||
setNewUserPassword('');
|
||
setNewUserRole('member');
|
||
setStatusMsg({ type: 'ok', text: `Benutzer "${created.displayName}" wurde angelegt.` });
|
||
} catch (err: any) {
|
||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Anlegen des Benutzers' });
|
||
}
|
||
};
|
||
|
||
const handleCreateInvite = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setStatusMsg(null);
|
||
triggerHaptic(15);
|
||
try {
|
||
const inv = await api.adminCreateInvite({
|
||
email: inviteMaxUses > 1 ? '' : inviteEmail.trim(),
|
||
role: inviteRole,
|
||
organizationId: inviteOrg || undefined,
|
||
maxUses: inviteMaxUses,
|
||
});
|
||
setInvites((prev) => [inv, ...prev]);
|
||
setInviteEmail('');
|
||
setStatusMsg({
|
||
type: 'ok',
|
||
text:
|
||
inv.maxUses > 1
|
||
? `Einladungslink für ${inv.maxUses} Personen erstellt (${inv.code}).`
|
||
: `Einladungscode ${inv.code} erfolgreich erstellt.`,
|
||
});
|
||
} catch (err: any) {
|
||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Erstellen der Einladung' });
|
||
}
|
||
};
|
||
|
||
const handleDeleteInvite = async (id: string) => {
|
||
try {
|
||
await api.adminDeleteInvite(id);
|
||
setInvites((prev) => prev.filter((i) => i.id !== id));
|
||
} catch (err: any) {
|
||
setStatusMsg({ type: 'err', text: err.message });
|
||
}
|
||
};
|
||
|
||
// Copies the code or the link that opens the sign-up form with the code filled in
|
||
const inviteLink = (inv: UserInvite) => `${window.location.origin}/?invite=${encodeURIComponent(inv.code)}`;
|
||
const [copiedWhat, setCopiedWhat] = useState<'code' | 'link'>('code');
|
||
const handleCopyInvite = (inv: UserInvite, what: 'code' | 'link') => {
|
||
navigator.clipboard.writeText(what === 'link' ? inviteLink(inv) : inv.code);
|
||
setCopiedWhat(what);
|
||
setCopiedInviteId(inv.id);
|
||
setTimeout(() => setCopiedInviteId(null), 2000);
|
||
};
|
||
|
||
const handleSendInvite = async (inv: UserInvite) => {
|
||
if (!mailInviteTo.trim()) return;
|
||
setMailInviteBusy(true);
|
||
setStatusMsg(null);
|
||
try {
|
||
const updated = await api.adminSendInvite(inv.id, mailInviteTo.trim());
|
||
setInvites((prev) => prev.map((i) => (i.id === inv.id ? { ...i, ...updated } : i)));
|
||
setStatusMsg({ type: 'ok', text: `Einladung an ${mailInviteTo.trim()} verschickt.` });
|
||
setMailInviteId(null);
|
||
setMailInviteTo('');
|
||
} catch (err: any) {
|
||
setStatusMsg({ type: 'err', text: err.message || 'Einladung konnte nicht verschickt werden' });
|
||
} finally {
|
||
setMailInviteBusy(false);
|
||
}
|
||
};
|
||
|
||
// Registrations waiting for approval (registration mode "Mit Freigabe")
|
||
const handleApproveUser = async (u: User) => {
|
||
setStatusMsg(null);
|
||
try {
|
||
const updated = await api.adminApproveUser(u.id);
|
||
setUsers((prev) => prev.map((item) => (item.id === u.id ? { ...updated, pendingApproval: false } : item)));
|
||
setStatusMsg({ type: 'ok', text: `${u.displayName} ist freigeschaltet.` });
|
||
} catch (err: any) {
|
||
setStatusMsg({ type: 'err', text: err.message });
|
||
}
|
||
};
|
||
|
||
const handleChangeUserRole = async (u: User, role: UserRole) => {
|
||
setStatusMsg(null);
|
||
try {
|
||
const updated = await api.adminUpdateUser(u.id, { role });
|
||
setUsers((prev) => prev.map((item) => (item.id === u.id ? updated : item)));
|
||
setStatusMsg({
|
||
type: 'ok',
|
||
text: `Rolle für ${updated.displayName} auf "${role}" aktualisiert.`,
|
||
});
|
||
} catch (err: any) {
|
||
setStatusMsg({ type: 'err', text: err.message });
|
||
}
|
||
};
|
||
|
||
const handleToggleUserActive = async (u: User) => {
|
||
setStatusMsg(null);
|
||
try {
|
||
const updated = await api.adminUpdateUser(u.id, { isActive: !u.isActive });
|
||
setUsers((prev) => prev.map((item) => (item.id === u.id ? updated : item)));
|
||
} catch (err: any) {
|
||
setStatusMsg({ type: 'err', text: err.message });
|
||
}
|
||
};
|
||
|
||
const handleConfirmResetPassword = async (userId: string) => {
|
||
if (!resetPwdVal.trim()) return;
|
||
setStatusMsg(null);
|
||
const problem = passwordProblem(resetPwdVal);
|
||
if (problem) {
|
||
setStatusMsg({ type: 'err', text: problem });
|
||
return;
|
||
}
|
||
try {
|
||
// Not trimmed: login compares the password exactly as typed
|
||
await api.adminResetPassword(userId, resetPwdVal);
|
||
setResetPwdUserId(null);
|
||
setResetPwdVal('');
|
||
setStatusMsg({ type: 'ok', text: 'Passwort erfolgreich zurückgesetzt.' });
|
||
} catch (err: any) {
|
||
setStatusMsg({ type: 'err', text: err.message });
|
||
}
|
||
};
|
||
|
||
const handleDeleteUser = async (u: User) => {
|
||
if (
|
||
!window.confirm(
|
||
u.pendingApproval
|
||
? `Registrierung von "${u.displayName}" (${u.email}) ablehnen? Das Konto wird gelöscht.`
|
||
: `Benutzer "${u.displayName}" (${u.email}) wirklich unwiderruflich löschen?`
|
||
)
|
||
)
|
||
return;
|
||
setStatusMsg(null);
|
||
try {
|
||
await api.adminDeleteUser(u.id);
|
||
setUsers((prev) => prev.filter((item) => item.id !== u.id));
|
||
setStatusMsg({ type: 'ok', text: 'Benutzer gelöscht.' });
|
||
} catch (err: any) {
|
||
setStatusMsg({ type: 'err', text: err.message });
|
||
}
|
||
};
|
||
|
||
|
||
|
||
// ============================================================================
|
||
// HANDLERS: SERVER & SMTP SETTINGS
|
||
// ============================================================================
|
||
// ============================================================================
|
||
// HANDLERS: ORGANIZATIONS
|
||
// ============================================================================
|
||
const reloadOrganizations = async () => {
|
||
try {
|
||
setOrganizations(await api.adminGetOrganizations());
|
||
} catch {
|
||
// keep the old list
|
||
}
|
||
};
|
||
|
||
const handleCreateOrganization = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!newOrgName.trim()) return;
|
||
try {
|
||
await api.adminCreateOrganization(newOrgName.trim());
|
||
setNewOrgName('');
|
||
await reloadOrganizations();
|
||
} catch (err: any) {
|
||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Anlegen der Organisation' });
|
||
}
|
||
};
|
||
|
||
const handleRenameOrganization = async (orgId: string) => {
|
||
try {
|
||
await api.adminRenameOrganization(orgId, renameOrgVal.trim());
|
||
setRenamingOrgId(null);
|
||
await reloadOrganizations();
|
||
} catch (err: any) {
|
||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Umbenennen' });
|
||
}
|
||
};
|
||
|
||
const handleDeleteOrganization = async (orgId: string) => {
|
||
try {
|
||
await api.adminDeleteOrganization(orgId);
|
||
setDeletingOrgId(null);
|
||
setUsers((prev) => prev.map((u) => (u.organizationId === orgId ? { ...u, organizationId: '' } : u)));
|
||
await reloadOrganizations();
|
||
} catch (err: any) {
|
||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Löschen' });
|
||
}
|
||
};
|
||
|
||
const applyUserOrganization = async (u: User, orgId: string) => {
|
||
setPendingMove(null);
|
||
try {
|
||
await api.adminSetUserOrganization(u.id, orgId);
|
||
setUsers((prev) => prev.map((x) => (x.id === u.id ? { ...x, organizationId: orgId } : x)));
|
||
await reloadOrganizations();
|
||
} catch (err: any) {
|
||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Zuordnen' });
|
||
}
|
||
};
|
||
|
||
const handleChangeUserOrganization = (u: User, orgId: string) => {
|
||
if (u.organizationId) setPendingMove({ user: u, orgId });
|
||
else applyUserOrganization(u, orgId);
|
||
};
|
||
|
||
const orgName = (id?: string) => organizations.find((o) => o.id === id)?.name || '';
|
||
|
||
// Tightening the mode first asks how much sharing would be lost
|
||
const handleSelectSharingMode = async (mode: SharingMode) => {
|
||
if (mode === settings.sharingMode) return;
|
||
if (modeRank(mode) < modeRank(settings.sharingMode)) {
|
||
try {
|
||
const affected = await api.adminSharingImpact(mode);
|
||
if (affected > 0) {
|
||
setPendingMode({ mode, affected });
|
||
return;
|
||
}
|
||
} catch {
|
||
// fall through and save
|
||
}
|
||
}
|
||
setPendingMode(null);
|
||
handleSaveSystemSettings({ sharingMode: mode });
|
||
};
|
||
|
||
const handleSaveSystemSettings = async (patch?: Partial<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">
|
||
{demoMode && (
|
||
<div className="flex items-center gap-2 alert-err mb-4">
|
||
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
|
||
<span>
|
||
In der Demo deaktiviert: Server-Einstellungen, E-Mail und Konten lassen sich hier nur
|
||
ansehen.
|
||
</span>
|
||
</div>
|
||
)}
|
||
{/* ================================================================= */}
|
||
{/* 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>Einladung 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-4 gap-3">
|
||
<div className="min-w-0">
|
||
<label className="block text-muted mb-1">Wie oft verwendbar</label>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={1000}
|
||
value={inviteMaxUses}
|
||
onChange={(e) => setInviteMaxUses(Math.max(1, Math.min(1000, parseInt(e.target.value, 10) || 1)))}
|
||
title="1 = ein Code für eine Person; mehr = ein Link für eine ganze Gruppe"
|
||
className="w-full min-w-0 h-8 px-2.5 input-field"
|
||
/>
|
||
</div>
|
||
|
||
<div className="min-w-0">
|
||
<label className="block text-muted mb-1">
|
||
Gebundene E-Mail (optional)
|
||
</label>
|
||
<input
|
||
type="email"
|
||
value={inviteMaxUses > 1 ? '' : inviteEmail}
|
||
onChange={(e) => setInviteEmail(e.target.value)}
|
||
disabled={inviteMaxUses > 1}
|
||
placeholder={inviteMaxUses > 1 ? 'Nur bei einmaliger Einladung' : 'Leer lassen für beliebige E-Mail'}
|
||
className="w-full min-w-0 h-8 px-2.5 input-field disabled:opacity-60"
|
||
/>
|
||
</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>{inviteMaxUses > 1 ? `Einladungslink für ${inviteMaxUses} Personen erzeugen` : '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) => {
|
||
const maxUses = inv.maxUses || 1;
|
||
const used = inv.useCount ?? (inv.usedBy ? 1 : 0);
|
||
const open = used < maxUses && new Date(inv.expiresAt).getTime() > Date.now();
|
||
const copied = copiedInviteId === inv.id;
|
||
return (
|
||
<div key={inv.id} className="p-2 space-y-2">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<div className="flex flex-wrap items-center gap-2.5 min-w-0">
|
||
<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}` : ''}
|
||
{maxUses > 1 ? ` · ${used} von ${maxUses} verwendet` : ''}
|
||
</span>
|
||
{open ? (
|
||
<span className="badge-ok text-[10px]">Offen</span>
|
||
) : (
|
||
<span className="badge-neutral text-[10px]">{used >= maxUses ? 'Eingelöst' : 'Abgelaufen'}</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-1.5">
|
||
{open && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleCopyInvite(inv, 'link')}
|
||
className="btn-action h-7 px-2 text-[11px] flex items-center gap-1"
|
||
title={inviteLink(inv)}
|
||
>
|
||
<Link2 className="w-3 h-3" />
|
||
<span>{copied && copiedWhat === 'link' ? 'Kopiert!' : 'Link kopieren'}</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleCopyInvite(inv, 'code')}
|
||
className="btn-action h-7 px-2 text-[11px] flex items-center gap-1"
|
||
>
|
||
<Copy className="w-3 h-3" />
|
||
<span>{copied && copiedWhat === 'code' ? 'Kopiert!' : 'Code'}</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setMailInviteId(mailInviteId === inv.id ? null : inv.id);
|
||
setMailInviteTo(inv.email || '');
|
||
}}
|
||
className="btn-action h-7 px-2 text-[11px] flex items-center gap-1"
|
||
title="Einladung per E-Mail verschicken"
|
||
>
|
||
<Mail className="w-3 h-3" />
|
||
<span>Per E-Mail</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>
|
||
{mailInviteId === inv.id && (
|
||
<form
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
void handleSendInvite(inv);
|
||
}}
|
||
className="flex flex-wrap items-center gap-2"
|
||
>
|
||
<input
|
||
type="email"
|
||
value={mailInviteTo}
|
||
onChange={(e) => setMailInviteTo(e.target.value)}
|
||
readOnly={Boolean(inv.email)}
|
||
placeholder="name@beispiel.de"
|
||
required
|
||
autoFocus
|
||
className="flex-1 min-w-[12rem] h-8 px-2.5 input-field"
|
||
/>
|
||
<button
|
||
type="submit"
|
||
disabled={mailInviteBusy}
|
||
className="btn-primary h-8 px-3 text-xs font-semibold flex items-center gap-1.5 disabled:opacity-50"
|
||
>
|
||
<Send className="w-3.5 h-3.5" />
|
||
<span>{mailInviteBusy ? 'Sende…' : 'Einladung senden'}</span>
|
||
</button>
|
||
{maxUses === 1 && !inv.email && (
|
||
<span className="w-full text-[11px] text-faint">Die Einladung gilt danach nur noch für diese Adresse.</span>
|
||
)}
|
||
</form>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Registrations waiting for approval */}
|
||
{users.some((u) => u.pendingApproval) && (
|
||
<div className="space-y-2">
|
||
<h3 className="font-bold text-main flex items-center gap-1.5">
|
||
<Clock className="w-3.5 h-3.5 text-status-warn" />
|
||
Wartet auf Freigabe ({users.filter((u) => u.pendingApproval).length})
|
||
</h3>
|
||
<div className="border border-status-warn/40 divide-y divide-border bg-canvas rounded-card overflow-hidden">
|
||
{users
|
||
.filter((u) => u.pendingApproval)
|
||
.map((u) => (
|
||
<div key={u.id} className="p-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||
<div className="flex items-center gap-2.5 min-w-0">
|
||
<UserAvatar user={u} size="sm" className="border border-border/80" />
|
||
<div className="min-w-0">
|
||
<p className="font-bold text-main truncate">{u.displayName}</p>
|
||
<p className="text-muted text-[11px] truncate">
|
||
{u.email} · registriert am {new Date(u.createdAt).toLocaleDateString('de-DE')}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleApproveUser(u)}
|
||
className="btn-primary h-11 sm:h-8 px-3 text-xs font-semibold flex items-center gap-1.5"
|
||
>
|
||
<Check className="w-3.5 h-3.5" />
|
||
Freigeben
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleDeleteUser(u)}
|
||
className="btn-action h-11 sm:h-8 px-3 text-xs flex items-center gap-1.5 hover:text-status-err"
|
||
>
|
||
<X className="w-3.5 h-3.5" />
|
||
Ablehnen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Users List */}
|
||
<div className="space-y-2">
|
||
<h3 className="font-bold text-main">
|
||
Registrierte Benutzer ({users.filter((u) => !u.pendingApproval).length})
|
||
</h3>
|
||
<div className="border border-border divide-y divide-border bg-canvas rounded-card overflow-hidden">
|
||
{users.filter((u) => !u.pendingApproval).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 && (
|
||
<fieldset disabled={demoMode} className="min-w-0 border-0 p-0 m-0">
|
||
<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>
|
||
|
||
<BackupAdminCard settings={settings} onSave={(patch) => handleSaveSystemSettings(patch)} />
|
||
|
||
<VoiceStatusCard />
|
||
|
||
{/* 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>
|
||
|
||
{/* Who may register without an invite */}
|
||
{(() => {
|
||
const mode = settings.registrationMode || 'closed';
|
||
const locked = Boolean(settings.registrationConfiguredViaEnv);
|
||
const needsDomains = mode === 'domain' || mode === 'domain+approval';
|
||
return (
|
||
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
|
||
<div>
|
||
<p className="font-bold text-main">Registrierung ohne Einladung</p>
|
||
<p className="text-[11px] text-muted mt-0.5">
|
||
Wer sich auf dem Login-Bildschirm selbst ein Konto anlegen darf. Einladungslinks und -codes funktionieren
|
||
immer. Wartende Konten gibst du unter „Benutzer & Einladungen“ frei.
|
||
</p>
|
||
</div>
|
||
<select
|
||
value={mode}
|
||
disabled={locked}
|
||
onChange={(e) => handleSaveSystemSettings({ registrationMode: e.target.value as RegistrationMode })}
|
||
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs disabled:opacity-60"
|
||
>
|
||
<option value="closed">Geschlossen – nur mit Einladung</option>
|
||
<option value="approval">Mit Freigabe – jeder darf sich registrieren, ein Admin schaltet frei</option>
|
||
<option value="domain">Nur bestimmte E-Mail-Endungen – sofort aktiv</option>
|
||
<option value="domain+approval">Endungen sofort, alle anderen nach Freigabe</option>
|
||
<option value="open">Offen – jeder, sofort aktiv</option>
|
||
</select>
|
||
{needsDomains && (
|
||
<label className="block text-[11px] text-muted">
|
||
Erlaubte E-Mail-Endungen (durch Komma getrennt, <code className="font-mono">*.firma.de</code> auch für Subdomains)
|
||
<input
|
||
type="text"
|
||
disabled={locked}
|
||
value={settings.registrationDomains || ''}
|
||
onChange={(e) => setSettings((prev) => ({ ...prev, registrationDomains: e.target.value }))}
|
||
onBlur={() => handleSaveSystemSettings()}
|
||
placeholder="firma.de, familie-dach.de"
|
||
className="block w-full mt-1 h-11 sm:h-8 px-2.5 input-field font-mono text-base sm:text-xs disabled:opacity-60"
|
||
/>
|
||
</label>
|
||
)}
|
||
{locked && (
|
||
<p className="text-[11px] text-status-warn">
|
||
Über die Umgebung festgelegt (<code className="font-mono">REGISTRATION_MODE</code> /{' '}
|
||
<code className="font-mono">REGISTRATION_DOMAINS</code>) und hier nicht änderbar.
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</fieldset>
|
||
)}
|
||
|
||
{/* ================================================================= */}
|
||
{/* SECTION 4: E-MAIL / SMTP-SERVER (SERVER-ADMIN ONLY) */}
|
||
{/* ================================================================= */}
|
||
{section === 'smtp' && isServerAdmin && (
|
||
<fieldset disabled={demoMode} className="min-w-0 border-0 p-0 m-0">
|
||
<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={settings.smtpPassSet ? 'Gespeichert – leer lassen für keine Änderung' : '••••••••••••'}
|
||
autoComplete="new-password"
|
||
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="alerts@deinedomain.de"
|
||
className="w-full h-9 px-3 input-field"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-muted font-medium mb-1">
|
||
Absender-Name (`SMTP_FROM_NAME`)
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={settings.smtpFromName || ''}
|
||
onChange={(e) =>
|
||
setSettings((prev) => ({ ...prev, smtpFromName: e.target.value }))
|
||
}
|
||
placeholder="MindSift"
|
||
className="w-full h-9 px-3 input-field"
|
||
/>
|
||
<p className="text-[11px] text-faint mt-1">
|
||
Erscheint beim Empfänger als Absender. Leer = „MindSift“; ein Name direkt in der Adresse
|
||
(„Name <adresse>“) hat Vorrang.
|
||
</p>
|
||
</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>
|
||
</fieldset>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|