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>
364 lines
13 KiB
TypeScript
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 – ohne Einstellungen</option>
|
|
<option value="admin">Admin – 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>
|
|
);
|
|
};
|