Files
perinco-CRM/src/components/settings/UserManagement.tsx
T
Sascha WolfandClaude Opus 5 8b7ce448a6 fix(ui): echte Umlaute in allen sichtbaren Texten
Menüs, Dialoge, Schaltflächen, Platzhalter, Hinweise und die
Fehlermeldungen der API standen in Ersatzschreibweise (oe/ae/ue). Sie
erscheinen als Toast oder Beschriftung direkt in der Oberfläche und
gehören damit richtig geschrieben; sechs sichtbare Texte mit ss statt ß
sind gleich mit erledigt ("Schließen", "Straße", "zu groß").

Unangetastet bleibt bewusst:

- Kommentare im Quelltext - die sind durchgängig ASCII, und der Diff wäre
  dreimal so groß ohne Nutzen für den Benutzer
- Protokollzeilen (console.*) - die landen im journal, nicht in der App
- Datenbankwerte und interne Kennungen: 'uebernommen', 'empfaenger',
  'vorschlaege', 'laeuft-bereits' stecken in Enums, SQL und
  Zustandsvergleichen. Ein Umlaut darin hätte still falsche Ergebnisse
  geliefert
- server/src/mail/extract.js - die Muster decken absichtlich beide
  Schreibweisen ab: /gr(ü|ue)(ß|ss)e/
- scanner/utils/crypto.js - der Ersatzschlüssel darf sich nicht ändern,
  sonst sind gespeicherte Portal-Passwörter nicht mehr lesbar

Geprüft mit einem Rundgang durch die Ansichten und Dialoge: sichtbarer
Text plus title/aria-label/placeholder enthalten keine
Ersatzschreibweisen mehr.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 09:37:24 +02:00

364 lines
13 KiB
TypeScript

import React, { useCallback, useEffect, useState } from 'react';
import { Modal, ConfirmDialog } from '../ui/Modal';
import { AvatarPicker } from '../ui/AvatarPicker';
import { UserAvatar } from '../ui/UserAvatar';
import { ColorPicker } from './ColorPicker';
import { inputClass, labelClass } from '../leads/leadUi';
import { useToast } from '../Toast';
import { useAuth } from '../../lib/auth';
import { usersApi, type UserInput } from '../../lib/api';
import { userColor, type AppUser, type Role } from '../../types';
interface UserFormState {
username: string;
name: string;
email: string;
role: Role;
is_active: boolean;
password: string;
/** null = Farbe automatisch aus der Palette */
color: string | null;
/** '' = kein Foto, dann wird die Initiale angezeigt */
avatar: string;
}
const emptyForm: UserFormState = {
username: '',
name: '',
email: '',
role: 'user',
is_active: true,
password: '',
color: null,
avatar: '',
};
const RoleBadge: React.FC<{ role: Role }> = ({ role }) =>
role === 'admin' ? (
<span className="rounded-md border border-brand-200 bg-brand-50 px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-brand-700">
Admin
</span>
) : (
<span className="rounded-md border border-ink-200 bg-ink-100 px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-ink-500">
User
</span>
);
export const UserManagement: React.FC = () => {
const { addToast } = useToast();
const { user: currentUser, updateUser } = useAuth();
const [users, setUsers] = useState<AppUser[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [editingUser, setEditingUser] = useState<AppUser | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [form, setForm] = useState<UserFormState>(emptyForm);
const [isSaving, setIsSaving] = useState(false);
const [userToDelete, setUserToDelete] = useState<AppUser | null>(null);
const load = useCallback(async () => {
setIsLoading(true);
try {
setUsers(await usersApi.list());
} catch (err) {
addToast(err instanceof Error ? err.message : 'Benutzer konnten nicht geladen werden.', 'error');
} finally {
setIsLoading(false);
}
}, [addToast]);
useEffect(() => {
load();
}, [load]);
const openCreate = () => {
setEditingUser(null);
setForm(emptyForm);
setIsModalOpen(true);
};
const openEdit = (user: AppUser) => {
setEditingUser(user);
setForm({
username: user.username,
name: user.name,
email: user.email ?? '',
role: user.role,
is_active: user.is_active,
password: '',
color: user.color ?? null,
avatar: user.avatar ?? '',
});
setIsModalOpen(true);
};
const set = <K extends keyof UserFormState>(key: K, value: UserFormState[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!form.username.trim() || !form.name.trim()) {
addToast('Benutzername und Name sind erforderlich.', 'error');
return;
}
if (!editingUser && form.password.length < 8) {
addToast('Das Passwort muss mindestens 8 Zeichen haben.', 'error');
return;
}
if (editingUser && form.password && form.password.length < 8) {
addToast('Das neue Passwort muss mindestens 8 Zeichen haben.', 'error');
return;
}
setIsSaving(true);
try {
const payload: UserInput = {
username: form.username.trim(),
name: form.name.trim(),
email: form.email.trim() || null,
role: form.role,
is_active: form.is_active,
color: form.color,
avatar: form.avatar || null,
};
if (form.password) payload.password = form.password;
if (editingUser) {
const updated = await usersApi.update(editingUser.id, payload);
addToast(`Benutzer "${updated.name}" wurde aktualisiert.`, 'success');
// Eigene Daten sofort in der laufenden Sitzung uebernehmen.
if (currentUser && updated.id === currentUser.id) updateUser(updated);
} else {
const created = await usersApi.create(payload);
addToast(`Benutzer "${created.name}" wurde angelegt.`, 'success');
}
setIsModalOpen(false);
await load();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
setIsSaving(false);
}
};
const confirmDelete = async () => {
if (!userToDelete) return;
try {
await usersApi.remove(userToDelete.id);
addToast(`Benutzer "${userToDelete.name}" wurde gelöscht.`, 'success');
await load();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
} finally {
setUserToDelete(null);
}
};
return (
<div>
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h3 className="text-base text-ink-900">Benutzer ({users.length})</h3>
<p className="text-xs text-ink-400">
Rolle Admin sieht die Einstellungen, Rolle User nicht.
</p>
</div>
<button
onClick={openCreate}
className="flex shrink-0 items-center gap-2 rounded-xl bg-brand-500 px-4 py-2.5 text-sm font-bold text-white shadow-card transition hover:bg-brand-600"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Benutzer anlegen
</button>
</div>
{isLoading ? (
<div className="flex justify-center py-14">
<span className="h-6 w-6 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
</div>
) : (
<ul className="space-y-2">
{users.map((user) => (
<li
key={user.id}
className="flex items-center gap-3 rounded-xl border border-ink-200 bg-surface px-4 py-3"
>
<UserAvatar
user={user}
size={40}
color={user.is_active ? undefined : '#c2c2c2'}
className={user.is_active ? undefined : 'grayscale'}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-bold text-ink-900">{user.name}</span>
<span className="text-xs text-ink-400">@{user.username}</span>
<RoleBadge role={user.role} />
{user.id === currentUser?.id && (
<span className="rounded-md border border-sky-200 bg-sky-50 px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-sky-700">
Ich
</span>
)}
{!user.is_active && (
<span className="rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-amber-700">
Deaktiviert
</span>
)}
</div>
{user.email && <p className="truncate text-xs text-ink-400">{user.email}</p>}
</div>
<div className="flex shrink-0 items-center gap-2">
<button
onClick={() => openEdit(user)}
className="rounded-lg border border-ink-200 px-3 py-1.5 text-xs font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700"
>
Bearbeiten
</button>
{user.id !== currentUser?.id && (
<button
onClick={() => setUserToDelete(user)}
className="rounded-lg p-2 text-ink-400 transition hover:bg-red-50 hover:text-red-600"
aria-label={`${user.name} löschen`}
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
)}
</div>
</li>
))}
</ul>
)}
{isModalOpen && (
<Modal
title={editingUser ? `${editingUser.name} bearbeiten` : 'Neuer Benutzer'}
subtitle={editingUser ? `@${editingUser.username}` : 'Zugang für das perinco CRM anlegen'}
onClose={() => setIsModalOpen(false)}
size="md"
footer={
<div className="flex gap-3">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="flex-1 rounded-xl border border-ink-200 py-3 text-sm font-bold text-ink-600 transition hover:bg-ink-100"
>
Abbrechen
</button>
<button
type="submit"
form="user-form"
disabled={isSaving}
className="flex-[2] rounded-xl bg-brand-500 py-3 text-sm font-bold text-white transition hover:bg-brand-600 disabled:opacity-60"
>
{isSaving ? 'Speichere...' : 'Speichern'}
</button>
</div>
}
>
<form id="user-form" onSubmit={handleSubmit} className="space-y-4">
<div>
<label className={labelClass}>Foto</label>
<AvatarPicker
value={form.avatar}
onChange={(value) => set('avatar', value)}
initials={form.name.charAt(0).toUpperCase() || '?'}
/>
</div>
<div>
<label className={labelClass}>Benutzername *</label>
<input
required
value={form.username}
onChange={(event) => set('username', event.target.value)}
className={inputClass}
autoComplete="off"
/>
</div>
<div>
<label className={labelClass}>Anzeigename *</label>
<input
required
value={form.name}
onChange={(event) => set('name', event.target.value)}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>E-Mail</label>
<input
type="email"
value={form.email}
onChange={(event) => set('email', event.target.value)}
className={inputClass}
autoComplete="off"
/>
</div>
<div>
<label className={labelClass}>Rolle</label>
<select
value={form.role}
onChange={(event) => set('role', event.target.value as Role)}
className={inputClass}
>
<option value="user">User &ndash; ohne Einstellungen</option>
<option value="admin">Admin &ndash; mit Einstellungen</option>
</select>
</div>
<div>
<label className={labelClass}>
{editingUser ? 'Neues Passwort (optional)' : 'Passwort *'}
</label>
<input
type="password"
required={!editingUser}
value={form.password}
onChange={(event) => set('password', event.target.value)}
placeholder={editingUser ? 'Leer lassen, um es nicht zu ändern' : 'Mindestens 8 Zeichen'}
className={inputClass}
autoComplete="new-password"
/>
</div>
<div>
<label className={labelClass}>Kalenderfarbe</label>
<ColorPicker
value={form.color}
fallback={userColor(editingUser ? { id: editingUser.id, color: null } : { id: 0 })}
onChange={(color) => set('color', color)}
/>
</div>
<label className="flex cursor-pointer items-center gap-3 rounded-xl border border-ink-200 px-4 py-3">
<input
type="checkbox"
checked={form.is_active}
onChange={(event) => set('is_active', event.target.checked)}
className="h-4 w-4 accent-brand-500"
/>
<span className="text-sm font-semibold text-ink-700">Konto aktiv</span>
</label>
</form>
</Modal>
)}
{userToDelete && (
<ConfirmDialog
title="Benutzer löschen?"
message={`"${userToDelete.name}" verliert den Zugang. Angelegte Kontakte bleiben erhalten, verlieren aber die Zuordnung.`}
onConfirm={confirmDelete}
onCancel={() => setUserToDelete(null)}
/>
)}
</div>
);
};