Files
leocrm/frontend/src/pages/SettingsUsers.tsx
T
Agent Zero abbe7a18fc fix(audit): P0-P3 audit fixes — 838 ruff errors → 0, 30 F821 bugs fixed, 118 files changed
- P0: hooks.py 3-tuple fix, trigger_dispatcher Contract, contacts/plugin unregister_actions_by_owner
- P0: 5 test files — check_permission mocks removed, hardcoded DB credential → env var
- P1: attachment_service DmsFile via Contract helper, restore_registry/history_hooks dedup
- P1: mail/plugin restore unregister, mcp_client datetime.now(UTC), saved_views/filters patterns
- P1: ProtectedRoute fail-closed, 13 test assertion fixes (bcrypt, DB-URLs, SECRET_KEYs)
- P2: deprecated notifications → post_system_message (3 files), forgejo Base, report_generator lazy import
- P2: webhooks permissions, deps.py/roles.py plugin perms removed, import_export default
- P2: address/tags/entity_links patterns removed, worker.py Contract-Umgehungen fixed
- P2: 28 frontend TODOs (hardcoded constants, deprecated notification API)
- P3: dead code, duplicates, deprecated imports, private attr, __import__ inline
- P3: 8 frontend TODOs (LucideIcons, inline styles, XSS, i18n)
- ruff: 838 → 0 (612 auto-fix + 246 manual + 27 F821 regression fix)
- F821: 30 → 0 (AutomationDefinition, DmsFile, user_id, Path, Any, String)
- Contract-Umgehungen: 2 neue gefunden (worker.py:169, worker.py:280) und gefixt
2026-08-16 01:17:18 +02:00

315 lines
11 KiB
TypeScript

import React, { useState, useMemo } from 'react';
// TODO: P2-F22 — Replace hardcoded LEGACY_ROLES with /roles API
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useUsers, useCreateUser, useUpdateUser, useDeleteUser, useRoles } from '@/api/hooks';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { EmptyState } from '@/components/ui/EmptyState';
import { Skeleton } from '@/components/ui/Skeleton';
import { Avatar } from '@/components/ui/Avatar';
import { Modal } from '@/components/ui/Modal';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { useToast } from '@/components/ui/Toast';
const LEGACY_ROLES = [
{ value: 'admin', label: 'Administrator' },
{ value: 'manager', label: 'Manager' },
{ value: 'user', label: 'Mitarbeiter' },
{ value: 'guest', label: 'Gast' },
{ value: 'viewer', label: 'Viewer' },
];
// ── Zod Schema ──
const inviteSchema = z.object({
name: z.string().min(1, 'required'),
email: z.string().min(1, 'required').email('invalidEmail'),
password: z.string().min(8, 'passwordTooShort'),
role_id: z.string().optional().default(''),
});
type InviteFormData = z.infer<typeof inviteSchema>;
export function SettingsUsersPage() {
const { t } = useTranslation();
const toast = useToast();
const { data, isLoading } = useUsers(1, 100);
const { data: rolesData } = useRoles();
const createUserMutation = useCreateUser();
const updateUserMutation = useUpdateUser();
const deleteUserMutation = useDeleteUser();
const [inviteOpen, setInviteOpen] = useState(false);
const [confirmDeactivate, setConfirmDeactivate] = useState<any>(null);
const [confirmDelete, setConfirmDelete] = useState<any>(null);
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<InviteFormData>({
resolver: zodResolver(inviteSchema),
defaultValues: { name: '', email: '', password: '', role_id: '' },
});
const users = data?.items ?? [];
const customRoles = rolesData?.items ?? [];
const roleOptions = useMemo(() => {
const options = [...LEGACY_ROLES];
for (const role of customRoles) {
options.push({ value: `role_id:${role.id}`, label: role.name });
}
return options;
}, [customRoles]);
const roleLabelMap = useMemo(() => {
const map = new Map<string, string>();
for (const opt of roleOptions) {
map.set(opt.value, opt.label);
}
return map;
}, [roleOptions]);
const getRoleValueForUser = (user: any): string => {
if (user.role_id) {
const key = `role_id:${user.role_id}`;
if (roleLabelMap.has(key)) return key;
}
if (user.role) {
const legacy = LEGACY_ROLES.find((r) => r.value === user.role);
if (legacy) return legacy.value;
}
return 'viewer';
};
const onSubmit = async (data: InviteFormData) => {
try {
const payload: any = {
email: data.email.trim(),
name: data.name.trim(),
password: data.password,
is_active: true,
};
if (data.role_id.startsWith('role_id:')) {
payload.role_id = data.role_id.substring('role_id:'.length);
payload.role = 'viewer';
} else {
payload.role = data.role_id || 'viewer';
}
await createUserMutation.mutateAsync(payload);
toast.success(t('settings.userInvited'));
reset({ name: '', email: '', password: '', role_id: '' });
setInviteOpen(false);
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleRoleChange = async (userId: string, newRoleValue: string) => {
try {
const data: any = {};
if (newRoleValue.startsWith('role_id:')) {
data.role_id = newRoleValue.substring('role_id:'.length);
data.role = 'viewer';
} else {
data.role = newRoleValue;
data.role_id = null;
}
await updateUserMutation.mutateAsync({ id: userId, data });
toast.success(t('settings.assignRole') + ' — OK');
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleToggleActive = async () => {
if (!confirmDeactivate) return;
try {
if (confirmDeactivate.is_active === false) {
await updateUserMutation.mutateAsync({ id: confirmDeactivate.id, data: { is_active: true } });
toast.success(t('settings.userActivated'));
} else {
await updateUserMutation.mutateAsync({ id: confirmDeactivate.id, data: { is_active: false } });
toast.success(t('settings.userDeactivated'));
}
setConfirmDeactivate(null);
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleDeleteUser = async () => {
if (!confirmDelete) return;
try {
await deleteUserMutation.mutateAsync(confirmDelete.id);
toast.success(t('settings.userDeleted'));
setConfirmDelete(null);
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const errorMsg = (key: string | undefined) => {
if (!key) return undefined;
if (key === 'required') return t('validation.required');
if (key === 'invalidEmail') return t('validation.email');
if (key === 'passwordTooShort') return t('auth.passwordTooShort');
return key;
};
if (isLoading) {
return (
<div className="p-6 max-w-4xl mx-auto" data-testid="settings-users-page">
<Skeleton className="h-8 w-48 mb-6" />
<div className="space-y-3">
<Skeleton className="h-16" />
<Skeleton className="h-16" />
<Skeleton className="h-16" />
</div>
</div>
);
}
return (
<div className="p-6 max-w-4xl mx-auto" data-testid="settings-users-page">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-secondary-900">{t('settings.users')}</h1>
<Button onClick={() => { reset({ name: '', email: '', password: '', role_id: '' }); setInviteOpen(true); }} data-testid="invite-user-btn">
{t('settings.inviteUser')}
</Button>
</div>
{users.length === 0 ? (
<EmptyState
title={t('settings.noUsers')}
action={<Button onClick={() => setInviteOpen(true)}>{t('settings.inviteUser')}</Button>}
/>
) : (
<div className="space-y-3">
{users.map((user: any) => (
<Card key={user.id}>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Avatar
name={user.name || user.email}
src={user.avatar_url}
size="sm"
/>
<div className="flex-1 min-w-0">
<p className="font-medium text-secondary-900 truncate">
{user.name}
</p>
<p className="text-sm text-secondary-500 truncate">{user.email}</p>
</div>
<Badge variant={user.is_active === false ? 'danger' : 'success'}>
{user.is_active === false ? t('common.inactive') : t('common.active')}
</Badge>
</div>
<div className="flex items-center gap-2">
<Select
options={roleOptions}
value={getRoleValueForUser(user)}
onChange={(e) => handleRoleChange(user.id, e.target.value)}
aria-label={t('settings.assignRole')}
className="w-36"
/>
<Button
variant={user.is_active === false ? 'primary' : 'danger'}
size="sm"
onClick={() => setConfirmDeactivate(user)}
data-testid={`toggle-active-${user.id}`}
>
{user.is_active === false ? t('settings.activate') : t('settings.deactivate')}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => setConfirmDelete(user)}
data-testid={`delete-user-${user.id}`}
aria-label={t('common.delete')}
>
{t('common.delete')}
</Button>
</div>
</div>
</Card>
))}
</div>
)}
<Modal open={inviteOpen} onClose={() => setInviteOpen(false)} title={t('settings.inviteUser')}>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" data-testid="invite-user-form">
<Input
label={t('settings.name')}
type="text"
{...register('name')}
error={errorMsg(errors.name?.message)}
placeholder={t('settings.name')}
data-testid="invite-name"
/>
<Input
label={t('settings.inviteEmail')}
type="email"
{...register('email')}
error={errorMsg(errors.email?.message)}
placeholder="neu.mitarbeiter@firma.de"
data-testid="invite-email"
/>
<Input
label={t('auth.password')}
type="password"
{...register('password')}
error={errorMsg(errors.password?.message)}
placeholder="********"
data-testid="invite-password"
/>
<Select
label={t('settings.inviteRole')}
options={roleOptions}
{...register('role_id')}
/>
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setInviteOpen(false)}>{t('common.cancel')}</Button>
<Button
type="submit"
isLoading={isSubmitting}
data-testid="send-invite-btn"
>
{t('settings.inviteUser')}
</Button>
</div>
</form>
</Modal>
<ConfirmDialog
open={!!confirmDeactivate}
title={confirmDeactivate?.is_active === false ? t('settings.activate') : t('settings.deactivate')}
message={
confirmDeactivate?.is_active === false
? `${t('settings.activate')}: ${confirmDeactivate?.email}?`
: `${t('settings.deactivate')}: ${confirmDeactivate?.email}?`
}
variant={confirmDeactivate?.is_active === false ? 'primary' : 'danger'}
onConfirm={handleToggleActive}
onCancel={() => setConfirmDeactivate(null)}
/>
<ConfirmDialog
open={!!confirmDelete}
title={t('common.delete')}
message={`${t('settings.deleteUserConfirm')}: ${confirmDelete?.name || confirmDelete?.email}?`}
variant="danger"
onConfirm={handleDeleteUser}
onCancel={() => setConfirmDelete(null)}
/>
</div>
);
}