Files
leocrm/frontend/src/pages/SettingsGroups.tsx
T

689 lines
26 KiB
TypeScript
Raw Normal View History

import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
useGroups,
useCreateGroup,
useUpdateGroup,
useDeleteGroup,
usePermissions,
useGroupMembers,
useAddGroupMember,
useRemoveGroupMember,
useUsers,
} from '@/api/hooks';
import type { PermissionItem, Group, GroupMember } from '@/api/hooks';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Select } from '@/components/ui/Select';
import { EmptyState } from '@/components/ui/EmptyState';
import { Skeleton } from '@/components/ui/Skeleton';
import { useToast } from '@/components/ui/Toast';
import { Modal } from '@/components/ui/Modal';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
function isPermissionGranted(permissions: Record<string, any>, permKey: string): boolean {
if (!permissions) return false;
if (typeof permissions === 'boolean') return permissions;
if (Array.isArray(permissions)) return permissions.includes(permKey);
if (typeof permissions === 'object') {
if (permKey in permissions) {
const val = permissions[permKey];
return val === true || val === 'true' || val === 1;
}
if ('all' in permissions && permissions.all === true) return true;
}
return false;
}
function togglePermission(
permissions: Record<string, any>,
permKey: string
): Record<string, any> {
const current = isPermissionGranted(permissions, permKey);
return { ...permissions, [permKey]: !current };
}
function getGrantedPermissionKeys(permissions: Record<string, any>): string[] {
if (!permissions) return [];
if (Array.isArray(permissions)) return permissions as string[];
if (typeof permissions === 'object') {
return Object.keys(permissions).filter(
(k) => k !== 'all' && permissions[k] === true
);
}
return [];
}
function toggleDeniedPermission(denied: string[], permKey: string): string[] {
if (denied.includes(permKey)) {
return denied.filter((k) => k !== permKey);
}
return [...denied, permKey];
}
interface EditingGroup extends Partial<Group> {
id?: string;
name: string;
description: string;
permissions: Record<string, any>;
denied_permissions: string[];
field_permissions: Record<string, any>;
}
export function SettingsGroupsPage() {
const { t } = useTranslation();
const toast = useToast();
const { data, isLoading, isError, error, refetch } = useGroups();
const { data: permissionsData, isLoading: permissionsLoading } = usePermissions();
const createGroupMutation = useCreateGroup();
const updateGroupMutation = useUpdateGroup();
const deleteGroupMutation = useDeleteGroup();
const [createOpen, setCreateOpen] = useState(false);
const [newGroupName, setNewGroupName] = useState('');
const [newGroupDescription, setNewGroupDescription] = useState('');
const [newGroupPermissions, setNewGroupPermissions] = useState<Record<string, any>>({});
const [newGroupDenied, setNewGroupDenied] = useState<string[]>([]);
const [editingGroup, setEditingGroup] = useState<EditingGroup | null>(null);
const [confirmDelete, setConfirmDelete] = useState<Group | null>(null);
// Member management state
const [memberSearchUserId, setMemberSearchUserId] = useState('');
const groups: Group[] = data?.items ?? [];
const allPermissions: PermissionItem[] = useMemo(() => {
return permissionsData?.all ?? [];
}, [permissionsData]);
const permissionLabelMap = useMemo(() => {
const map = new Map<string, string>();
for (const perm of allPermissions) {
map.set(perm.key, perm.label);
}
return map;
}, [allPermissions]);
const groupedPermissions = useMemo(() => {
const groups: Record<string, PermissionItem[]> = {};
for (const perm of allPermissions) {
if (!groups[perm.category]) {
groups[perm.category] = [];
}
groups[perm.category].push(perm);
}
return groups;
}, [allPermissions]);
const handleCreateGroup = async () => {
if (!newGroupName.trim()) {
toast.error(t('validation.required', 'Pflichtfeld'));
return;
}
try {
await createGroupMutation.mutateAsync({
name: newGroupName.trim(),
description: newGroupDescription.trim() || null,
permissions: newGroupPermissions,
denied_permissions: newGroupDenied,
field_permissions: {},
});
toast.success(t('settings.groupCreated', 'Gruppe erstellt'));
setNewGroupName('');
setNewGroupDescription('');
setNewGroupPermissions({});
setNewGroupDenied([]);
setCreateOpen(false);
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleTogglePermission = (permKey: string, target: 'new' | 'edit') => {
if (target === 'new') {
setNewGroupPermissions((prev) => togglePermission(prev, permKey));
} else if (editingGroup) {
setEditingGroup({
...editingGroup,
permissions: togglePermission(editingGroup.permissions || {}, permKey),
});
}
};
const handleToggleDenied = (permKey: string, target: 'new' | 'edit') => {
if (target === 'new') {
setNewGroupDenied((prev) => toggleDeniedPermission(prev, permKey));
} else if (editingGroup) {
setEditingGroup({
...editingGroup,
denied_permissions: toggleDeniedPermission(editingGroup.denied_permissions || [], permKey),
});
}
};
const handleSaveGroup = async () => {
if (!editingGroup || !editingGroup.id) return;
if (!editingGroup.name.trim()) {
toast.error(t('validation.required', 'Pflichtfeld'));
return;
}
try {
await updateGroupMutation.mutateAsync({
id: editingGroup.id,
data: {
name: editingGroup.name.trim(),
description: editingGroup.description.trim() || null,
permissions: editingGroup.permissions,
denied_permissions: editingGroup.denied_permissions,
field_permissions: editingGroup.field_permissions || {},
},
});
toast.success(t('settings.groupUpdated', 'Gruppe aktualisiert'));
setEditingGroup(null);
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleDeleteGroup = async () => {
if (!confirmDelete) return;
try {
await deleteGroupMutation.mutateAsync(confirmDelete.id);
toast.success(t('settings.groupDeleted', 'Gruppe gelöscht'));
setConfirmDelete(null);
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const startEdit = (group: Group) => {
setEditingGroup({
id: group.id,
name: group.name,
description: group.description || '',
permissions: group.permissions || {},
denied_permissions: group.denied_permissions || [],
field_permissions: group.field_permissions || {},
});
};
if (isLoading || permissionsLoading) {
return (
<div className="p-6 max-w-4xl mx-auto" data-testid="settings-groups-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>
);
}
if (isError) {
return (
<div className="p-6 max-w-4xl mx-auto" data-testid="settings-groups-page">
<h1 className="text-2xl font-bold text-secondary-900 mb-6">
{t('settings.groups', 'Gruppen')}
</h1>
<EmptyState
title={t('common.error')}
description={error?.message || t('common.error')}
action={<Button onClick={() => refetch()}>{t('common.reset', 'Erneut versuchen')}</Button>}
/>
</div>
);
}
return (
<div className="p-6 max-w-4xl mx-auto" data-testid="settings-groups-page">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-secondary-900">
{t('settings.groups', 'Gruppen')}
</h1>
<Button onClick={() => setCreateOpen(true)} data-testid="create-group-btn">
{t('settings.createGroup', 'Gruppe erstellen')}
</Button>
</div>
{groups.length === 0 ? (
<EmptyState
title={t('settings.noGroups', 'Keine Gruppen vorhanden')}
action={<Button onClick={() => setCreateOpen(true)}>{t('settings.createGroup', 'Gruppe erstellen')}</Button>}
/>
) : (
<div className="space-y-4">
{groups.map((group) => {
const grantedKeys = getGrantedPermissionKeys(group.permissions);
const deniedCount = (group.denied_permissions || []).length;
return (
<Card key={group.id}>
<div className="flex items-center justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-secondary-900">{group.name}</h3>
{group.description && (
<p className="text-sm text-secondary-500 mt-1">{group.description}</p>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => startEdit(group)}
data-testid={`edit-group-${group.id}`}
>
{t('common.edit')}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => setConfirmDelete(group)}
data-testid={`delete-group-${group.id}`}
>
{t('common.delete')}
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2 items-center">
{grantedKeys.length === 0 ? (
<span className="text-sm text-secondary-500"></span>
) : (
grantedKeys.slice(0, 10).map((perm) => {
const label = permissionLabelMap.get(perm) || perm;
return <Badge key={perm} variant="primary">{label}</Badge>;
})
)}
{grantedKeys.length > 10 && (
<Badge variant="default">+{grantedKeys.length - 10}</Badge>
)}
{deniedCount > 0 && (
<Badge variant="danger">
{deniedCount} {t('settings.denied', 'verweigert')}
</Badge>
)}
</div>
</Card>
);
})}
</div>
)}
{/* Create Group Modal */}
<Modal
open={createOpen}
onClose={() => setCreateOpen(false)}
title={t('settings.createGroup', 'Gruppe erstellen')}
size="lg"
>
<div className="space-y-4" data-testid="create-group-form">
<Input
label={t('settings.groupName', 'Gruppenname')}
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
placeholder={t('settings.groupName', 'Gruppenname')}
data-testid="new-group-name"
/>
<Input
label={t('settings.groupDescription', 'Beschreibung')}
value={newGroupDescription}
onChange={(e) => setNewGroupDescription(e.target.value)}
placeholder={t('settings.groupDescription', 'Beschreibung')}
data-testid="new-group-description"
/>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
{t('settings.permissions', 'Berechtigungen')}
</label>
<div className="space-y-2 max-h-48 overflow-y-auto border border-secondary-200 rounded-md p-3">
{allPermissions.length === 0 && (
<p className="text-sm text-secondary-500">{t('settings.noPermissions', 'Keine Berechtigungen verfügbar')}</p>
)}
{Object.entries(groupedPermissions).map(([category, perms]) => (
<div key={category} className="mb-3">
<p className="text-xs font-semibold text-secondary-500 uppercase mb-1">
{category === 'system' ? t('settings.systemPermissions', 'System') : t('settings.pluginPermissions', 'Plugins')}
</p>
{perms.map((perm) => (
<label key={perm.key} className="flex items-center gap-2 p-2 rounded-md hover:bg-secondary-50 cursor-pointer min-h-touch">
<input
type="checkbox"
checked={isPermissionGranted(newGroupPermissions, perm.key)}
onChange={() => handleTogglePermission(perm.key, 'new')}
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-secondary-900">{perm.label}</span>
</label>
))}
</div>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
{t('settings.deniedPermissions', 'Verweigerte Berechtigungen')}
</label>
<div className="space-y-2 max-h-32 overflow-y-auto border border-secondary-200 rounded-md p-3">
{allPermissions.length === 0 && (
<p className="text-sm text-secondary-500">{t('settings.noPermissions', 'Keine Berechtigungen verfügbar')}</p>
)}
{allPermissions.map((perm) => (
<label key={`denied-${perm.key}`} className="flex items-center gap-2 p-2 rounded-md hover:bg-secondary-50 cursor-pointer min-h-touch">
<input
type="checkbox"
checked={newGroupDenied.includes(perm.key)}
onChange={() => handleToggleDenied(perm.key, 'new')}
className="w-4 h-4 rounded border-danger-300 text-danger-600 focus:ring-danger-500"
/>
<span className="text-sm text-secondary-900">{perm.label}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setCreateOpen(false)}>{t('common.cancel')}</Button>
<Button
onClick={handleCreateGroup}
isLoading={createGroupMutation.isPending}
data-testid="save-group-btn"
>
{t('common.save')}
</Button>
</div>
</div>
</Modal>
{/* Edit Group Modal */}
<Modal
open={!!editingGroup}
onClose={() => setEditingGroup(null)}
title={t('settings.groups', 'Gruppen') + ' — ' + (editingGroup?.name || '')}
size="xl"
>
{editingGroup && (
<EditGroupContent
group={editingGroup}
onChange={setEditingGroup}
onTogglePermission={(key) => handleTogglePermission(key, 'edit')}
onToggleDenied={(key) => handleToggleDenied(key, 'edit')}
allPermissions={allPermissions}
groupedPermissions={groupedPermissions}
permissionLabelMap={permissionLabelMap}
onSave={handleSaveGroup}
onCancel={() => setEditingGroup(null)}
isSaving={updateGroupMutation.isPending}
memberSearchUserId={memberSearchUserId}
setMemberSearchUserId={setMemberSearchUserId}
/>
)}
</Modal>
<ConfirmDialog
open={!!confirmDelete}
title={t('common.delete')}
message={`${t('common.delete')}: ${confirmDelete?.name}?`}
variant="danger"
onConfirm={handleDeleteGroup}
onCancel={() => setConfirmDelete(null)}
/>
</div>
);
}
// ─── Edit Group Content with Tabs ─────────────────────────────────────────────
interface EditGroupContentProps {
group: EditingGroup;
onChange: (group: EditingGroup) => void;
onTogglePermission: (key: string) => void;
onToggleDenied: (key: string) => void;
allPermissions: PermissionItem[];
groupedPermissions: Record<string, PermissionItem[]>;
permissionLabelMap: Map<string, string>;
onSave: () => void;
onCancel: () => void;
isSaving: boolean;
memberSearchUserId: string;
setMemberSearchUserId: (val: string) => void;
}
function EditGroupContent({
group,
onChange,
onTogglePermission,
onToggleDenied,
allPermissions,
groupedPermissions,
permissionLabelMap,
onSave,
onCancel,
isSaving,
memberSearchUserId,
setMemberSearchUserId,
}: EditGroupContentProps) {
const { t } = useTranslation();
const toast = useToast();
const [activeTab, setActiveTab] = useState<'details' | 'permissions' | 'denied' | 'members'>('details');
const groupId = group.id || null;
const { data: membersData, isLoading: membersLoading } = useGroupMembers(groupId);
const { data: usersData } = useUsers(1, 100);
const addMemberMutation = useAddGroupMember();
const removeMemberMutation = useRemoveGroupMember();
const members: GroupMember[] = membersData?.items ?? [];
const allUsers = usersData?.items ?? [];
// Users not yet in this group
const availableUsers = useMemo(() => {
const memberIds = new Set(members.map((m) => m.user_id));
return allUsers.filter((u: any) => !memberIds.has(u.id));
}, [allUsers, members]);
const userSelectOptions = useMemo(() => {
return availableUsers.map((u: any) => ({
value: u.id,
label: `${u.name || u.email} (${u.email})`,
}));
}, [availableUsers]);
const handleAddMember = async () => {
if (!groupId || !memberSearchUserId) return;
try {
await addMemberMutation.mutateAsync({ groupId, userId: memberSearchUserId });
toast.success(t('settings.memberAdded', 'Mitglied hinzugefügt'));
setMemberSearchUserId('');
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleRemoveMember = async (userId: string) => {
if (!groupId) return;
try {
await removeMemberMutation.mutateAsync({ groupId, userId });
toast.success(t('settings.memberRemoved', 'Mitglied entfernt'));
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const tabBtnClass = (tab: string) =>
`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === tab
? 'bg-primary-100 text-primary-700'
: 'text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900'
}`;
return (
<div className="space-y-4" data-testid="edit-group-form">
{/* Tab Navigation */}
<div className="flex gap-2 border-b border-secondary-200 pb-2">
<button className={tabBtnClass('details')} onClick={() => setActiveTab('details')} data-testid="tab-details">
{t('settings.groupDetails', 'Details')}
</button>
<button className={tabBtnClass('permissions')} onClick={() => setActiveTab('permissions')} data-testid="tab-permissions">
{t('settings.permissions', 'Berechtigungen')}
</button>
<button className={tabBtnClass('denied')} onClick={() => setActiveTab('denied')} data-testid="tab-denied">
{t('settings.deniedPermissions', 'Verweigert')}
</button>
<button className={tabBtnClass('members')} onClick={() => setActiveTab('members')} data-testid="tab-members">
{t('settings.members', 'Mitglieder')}
</button>
</div>
{/* Details Tab */}
{activeTab === 'details' && (
<div className="space-y-4">
<Input
label={t('settings.groupName', 'Gruppenname')}
value={group.name}
onChange={(e) => onChange({ ...group, name: e.target.value })}
data-testid="edit-group-name"
/>
<Input
label={t('settings.groupDescription', 'Beschreibung')}
value={group.description}
onChange={(e) => onChange({ ...group, description: e.target.value })}
data-testid="edit-group-description"
/>
</div>
)}
{/* Permissions Tab */}
{activeTab === 'permissions' && (
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
{t('settings.permissions', 'Berechtigungen')}
</label>
<div className="space-y-2 max-h-64 overflow-y-auto border border-secondary-200 rounded-md p-3">
{allPermissions.length === 0 && (
<p className="text-sm text-secondary-500">{t('settings.noPermissions', 'Keine Berechtigungen verfügbar')}</p>
)}
{Object.entries(groupedPermissions).map(([category, perms]) => (
<div key={category} className="mb-3">
<p className="text-xs font-semibold text-secondary-500 uppercase mb-1">
{category === 'system' ? t('settings.systemPermissions', 'System') : t('settings.pluginPermissions', 'Plugins')}
</p>
{perms.map((perm) => (
<label key={perm.key} className="flex items-center gap-2 p-2 rounded-md hover:bg-secondary-50 cursor-pointer min-h-touch">
<input
type="checkbox"
checked={isPermissionGranted(group.permissions || {}, perm.key)}
onChange={() => onTogglePermission(perm.key)}
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-secondary-900">{perm.label}</span>
</label>
))}
</div>
))}
</div>
</div>
)}
{/* Denied Permissions Tab */}
{activeTab === 'denied' && (
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
{t('settings.deniedPermissions', 'Verweigerte Berechtigungen')}
</label>
<p className="text-xs text-secondary-500 mb-2">
{t('settings.deniedPermissionsHint', 'Diese Berechtigungen werden explizit verweigert, auch wenn sie über Rollen gewährt wurden.')}
</p>
<div className="space-y-2 max-h-64 overflow-y-auto border border-secondary-200 rounded-md p-3">
{allPermissions.length === 0 && (
<p className="text-sm text-secondary-500">{t('settings.noPermissions', 'Keine Berechtigungen verfügbar')}</p>
)}
{allPermissions.map((perm) => (
<label key={`denied-edit-${perm.key}`} className="flex items-center gap-2 p-2 rounded-md hover:bg-secondary-50 cursor-pointer min-h-touch">
<input
type="checkbox"
checked={(group.denied_permissions || []).includes(perm.key)}
onChange={() => onToggleDenied(perm.key)}
className="w-4 h-4 rounded border-danger-300 text-danger-600 focus:ring-danger-500"
/>
<span className="text-sm text-secondary-900">{perm.label}</span>
</label>
))}
</div>
</div>
)}
{/* Members Tab */}
{activeTab === 'members' && (
<div className="space-y-4">
<div className="flex gap-2 items-end">
<div className="flex-1">
<Select
label={t('settings.addMember', 'Mitglied hinzufügen')}
options={userSelectOptions}
placeholder={t('settings.selectUser', 'Benutzer auswählen')}
value={memberSearchUserId}
onChange={(e) => setMemberSearchUserId(e.target.value)}
data-testid="member-user-select"
/>
</div>
<Button
onClick={handleAddMember}
isLoading={addMemberMutation.isPending}
disabled={!memberSearchUserId}
data-testid="add-member-btn"
>
{t('common.add', 'Hinzufügen')}
</Button>
</div>
<div className="border border-secondary-200 rounded-md">
{membersLoading ? (
<div className="p-4">
<Skeleton className="h-12" />
</div>
) : members.length === 0 ? (
<div className="p-4 text-sm text-secondary-500">
{t('settings.noMembers', 'Keine Mitglieder')}
</div>
) : (
<div className="divide-y divide-secondary-200" data-testid="group-members-list">
{members.map((member) => (
<div key={member.user_id} className="flex items-center justify-between p-3">
<div className="flex-1 min-w-0">
<p className="font-medium text-secondary-900 truncate">{member.name}</p>
<p className="text-sm text-secondary-500 truncate">{member.email}</p>
</div>
<Badge variant={member.is_active === false ? 'danger' : 'success'} className="mr-2">
{member.is_active === false ? t('common.inactive') : t('common.active')}
</Badge>
<Button
variant="danger"
size="sm"
onClick={() => handleRemoveMember(member.user_id)}
data-testid={`remove-member-${member.user_id}`}
>
{t('common.remove', 'Entfernen')}
</Button>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Save / Cancel */}
<div className="flex justify-end gap-3 pt-4 border-t border-secondary-200">
<Button variant="secondary" onClick={onCancel}>{t('common.cancel')}</Button>
<Button
onClick={onSave}
isLoading={isSaving}
data-testid="update-group-btn"
>
{t('common.save')}
</Button>
</div>
</div>
);
}