2026-07-03 20:11:04 +00:00
|
|
|
import React, { useState, useMemo } from 'react';
|
2026-06-29 11:01:39 +02:00
|
|
|
import { useTranslation } from 'react-i18next';
|
2026-07-03 20:11:04 +00:00
|
|
|
import { useUsers, useCreateUser, useUpdateUser, useDeleteUser, useRoles } from '@/api/hooks';
|
2026-06-29 11:01:39 +02:00
|
|
|
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';
|
|
|
|
|
|
2026-07-03 20:11:04 +00:00
|
|
|
const LEGACY_ROLES = [
|
2026-06-29 11:01:39 +02:00
|
|
|
{ value: 'admin', label: 'Administrator' },
|
|
|
|
|
{ value: 'manager', label: 'Manager' },
|
|
|
|
|
{ value: 'user', label: 'Mitarbeiter' },
|
|
|
|
|
{ value: 'guest', label: 'Gast' },
|
2026-07-03 20:11:04 +00:00
|
|
|
{ value: 'viewer', label: 'Viewer' },
|
2026-06-29 11:01:39 +02:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
export function SettingsUsersPage() {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const toast = useToast();
|
|
|
|
|
const { data, isLoading } = useUsers(1, 100);
|
2026-07-03 20:11:04 +00:00
|
|
|
const { data: rolesData } = useRoles();
|
2026-06-29 11:01:39 +02:00
|
|
|
const createUserMutation = useCreateUser();
|
|
|
|
|
const updateUserMutation = useUpdateUser();
|
|
|
|
|
const deleteUserMutation = useDeleteUser();
|
|
|
|
|
|
|
|
|
|
const [inviteOpen, setInviteOpen] = useState(false);
|
|
|
|
|
const [inviteEmail, setInviteEmail] = useState('');
|
2026-07-03 20:11:04 +00:00
|
|
|
const [inviteName, setInviteName] = useState('');
|
|
|
|
|
const [invitePassword, setInvitePassword] = useState('');
|
|
|
|
|
const [inviteRoleId, setInviteRoleId] = useState('');
|
2026-06-29 11:01:39 +02:00
|
|
|
const [confirmDeactivate, setConfirmDeactivate] = useState<any>(null);
|
2026-07-03 20:11:04 +00:00
|
|
|
const [confirmDelete, setConfirmDelete] = useState<any>(null);
|
2026-06-29 11:01:39 +02:00
|
|
|
|
|
|
|
|
const users = data?.items ?? [];
|
2026-07-03 20:11:04 +00:00
|
|
|
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';
|
|
|
|
|
};
|
2026-06-29 11:01:39 +02:00
|
|
|
|
|
|
|
|
const handleInvite = async () => {
|
2026-07-03 20:11:04 +00:00
|
|
|
if (!inviteEmail.trim() || !inviteName.trim() || !invitePassword.trim()) {
|
2026-06-29 11:01:39 +02:00
|
|
|
toast.error(t('validation.required'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-07-03 20:11:04 +00:00
|
|
|
if (invitePassword.length < 8) {
|
|
|
|
|
toast.error(t('auth.passwordTooShort'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-29 11:01:39 +02:00
|
|
|
try {
|
2026-07-03 20:11:04 +00:00
|
|
|
const payload: any = {
|
2026-06-29 11:01:39 +02:00
|
|
|
email: inviteEmail.trim(),
|
2026-07-03 20:11:04 +00:00
|
|
|
name: inviteName.trim(),
|
|
|
|
|
password: invitePassword,
|
|
|
|
|
is_active: true,
|
|
|
|
|
};
|
|
|
|
|
if (inviteRoleId.startsWith('role_id:')) {
|
|
|
|
|
payload.role_id = inviteRoleId.substring('role_id:'.length);
|
|
|
|
|
payload.role = 'viewer';
|
|
|
|
|
} else {
|
|
|
|
|
payload.role = inviteRoleId || 'viewer';
|
|
|
|
|
}
|
|
|
|
|
await createUserMutation.mutateAsync(payload);
|
2026-06-29 11:01:39 +02:00
|
|
|
toast.success(t('settings.userInvited'));
|
|
|
|
|
setInviteEmail('');
|
2026-07-03 20:11:04 +00:00
|
|
|
setInviteName('');
|
|
|
|
|
setInvitePassword('');
|
|
|
|
|
setInviteRoleId('');
|
2026-06-29 11:01:39 +02:00
|
|
|
setInviteOpen(false);
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
toast.error(err.message || t('common.error'));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-03 20:11:04 +00:00
|
|
|
const handleRoleChange = async (userId: string, newRoleValue: string) => {
|
2026-06-29 11:01:39 +02:00
|
|
|
try {
|
2026-07-03 20:11:04 +00:00
|
|
|
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 });
|
2026-06-29 11:01:39 +02:00
|
|
|
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'));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-03 20:11:04 +00:00
|
|
|
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'));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-29 11:01:39 +02:00
|
|
|
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={() => 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
|
2026-07-03 20:11:04 +00:00
|
|
|
name={user.name || user.email}
|
2026-06-29 11:01:39 +02:00
|
|
|
src={user.avatar_url}
|
|
|
|
|
size="sm"
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<p className="font-medium text-secondary-900 truncate">
|
2026-07-03 20:11:04 +00:00
|
|
|
{user.name}
|
2026-06-29 11:01:39 +02:00
|
|
|
</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
|
2026-07-03 20:11:04 +00:00
|
|
|
options={roleOptions}
|
|
|
|
|
value={getRoleValueForUser(user)}
|
2026-06-29 11:01:39 +02:00
|
|
|
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>
|
2026-07-03 20:11:04 +00:00
|
|
|
<Button
|
|
|
|
|
variant="danger"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setConfirmDelete(user)}
|
|
|
|
|
data-testid={`delete-user-${user.id}`}
|
|
|
|
|
aria-label={t('common.delete')}
|
|
|
|
|
>
|
|
|
|
|
{t('common.delete')}
|
|
|
|
|
</Button>
|
2026-06-29 11:01:39 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<Modal open={inviteOpen} onClose={() => setInviteOpen(false)} title={t('settings.inviteUser')}>
|
|
|
|
|
<div className="space-y-4" data-testid="invite-user-form">
|
2026-07-03 20:11:04 +00:00
|
|
|
<Input
|
|
|
|
|
label={t('settings.name')}
|
|
|
|
|
type="text"
|
|
|
|
|
value={inviteName}
|
|
|
|
|
onChange={(e) => setInviteName(e.target.value)}
|
|
|
|
|
placeholder={t('settings.name')}
|
|
|
|
|
data-testid="invite-name"
|
|
|
|
|
/>
|
2026-06-29 11:01:39 +02:00
|
|
|
<Input
|
|
|
|
|
label={t('settings.inviteEmail')}
|
|
|
|
|
type="email"
|
|
|
|
|
value={inviteEmail}
|
|
|
|
|
onChange={(e) => setInviteEmail(e.target.value)}
|
|
|
|
|
placeholder="neu.mitarbeiter@firma.de"
|
|
|
|
|
data-testid="invite-email"
|
|
|
|
|
/>
|
2026-07-03 20:11:04 +00:00
|
|
|
<Input
|
|
|
|
|
label={t('auth.password')}
|
|
|
|
|
type="password"
|
|
|
|
|
value={invitePassword}
|
|
|
|
|
onChange={(e) => setInvitePassword(e.target.value)}
|
|
|
|
|
placeholder="********"
|
|
|
|
|
data-testid="invite-password"
|
|
|
|
|
/>
|
2026-06-29 11:01:39 +02:00
|
|
|
<Select
|
|
|
|
|
label={t('settings.inviteRole')}
|
2026-07-03 20:11:04 +00:00
|
|
|
options={roleOptions}
|
|
|
|
|
value={inviteRoleId}
|
|
|
|
|
onChange={(e) => setInviteRoleId(e.target.value)}
|
2026-06-29 11:01:39 +02:00
|
|
|
/>
|
|
|
|
|
<div className="flex justify-end gap-3">
|
|
|
|
|
<Button variant="secondary" onClick={() => setInviteOpen(false)}>{t('common.cancel')}</Button>
|
|
|
|
|
<Button
|
|
|
|
|
onClick={handleInvite}
|
|
|
|
|
isLoading={createUserMutation.isPending}
|
|
|
|
|
data-testid="send-invite-btn"
|
|
|
|
|
>
|
|
|
|
|
{t('settings.inviteUser')}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</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)}
|
|
|
|
|
/>
|
2026-07-03 20:11:04 +00:00
|
|
|
|
|
|
|
|
<ConfirmDialog
|
|
|
|
|
open={!!confirmDelete}
|
|
|
|
|
title={t('common.delete')}
|
|
|
|
|
message={`${t('settings.deleteUserConfirm')}: ${confirmDelete?.name || confirmDelete?.email}?`}
|
|
|
|
|
variant="danger"
|
|
|
|
|
onConfirm={handleDeleteUser}
|
|
|
|
|
onCancel={() => setConfirmDelete(null)}
|
|
|
|
|
/>
|
2026-06-29 11:01:39 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|