diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index d37e541..e0e6780 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -88,10 +88,18 @@ export interface PermissionItem { plugin_name?: string; } +export interface FieldDefinition { + module: string; + field: string; + label: string; + sensitivity: string; +} + export interface PermissionsResponse { system: PermissionItem[]; plugins: PermissionItem[]; all: PermissionItem[]; + field_definitions?: FieldDefinition[]; } export interface Plugin { @@ -907,3 +915,100 @@ export function useDeleteAddress() { }, }); } + +// ════════════════════════════════════════════════════════════════════════════ +// Group hooks +// ════════════════════════════════════════════════════════════════════════════ + +export interface Group { + id: string; + name: string; + description: string | null; + permissions: Record; + denied_permissions: string[]; + field_permissions: Record; + permission_version: number; +} + +export interface GroupMember { + user_id: string; + email: string; + name: string; + is_active: boolean; +} + +export function useGroups() { + return useQuery({ + queryKey: ['groups'], + queryFn: () => apiGet<{ items: Group[] }>('/groups'), + }); +} + +export function useCreateGroup() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: Partial) => apiPost('/groups', data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['groups'] }); + }, + }); +} + +export function useUpdateGroup() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: Partial }) => + apiPatch(`/groups/${id}`, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['groups'] }); + }, + }); +} + +export function useDeleteGroup() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiDelete(`/groups/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['groups'] }); + }, + }); +} + +export function useGroupMembers(groupId: string | null) { + return useQuery({ + queryKey: ['groupMembers', groupId], + queryFn: () => apiGet<{ items: GroupMember[] }>(`/groups/${groupId}/members`), + enabled: !!groupId, + }); +} + +export function useAddGroupMember() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => + apiPost(`/groups/${groupId}/members`, { user_id: userId }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['groupMembers'] }); + }, + }); +} + +export function useRemoveGroupMember() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => + apiDelete(`/groups/${groupId}/members/${userId}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['groupMembers'] }); + }, + }); +} + +export function useUserGroups(userId: string | null) { + return useQuery({ + queryKey: ['userGroups', userId], + queryFn: () => apiGet<{ items: Group[] }>(`/groups/user/${userId}`), + enabled: !!userId, + }); +} diff --git a/frontend/src/hooks/usePermission.ts b/frontend/src/hooks/usePermission.ts new file mode 100644 index 0000000..8d89859 --- /dev/null +++ b/frontend/src/hooks/usePermission.ts @@ -0,0 +1,37 @@ +import { useAuthStore } from '@/store/authStore'; + +export function usePermission() { + const user = useAuthStore((state) => state.user); + + const hasPermission = (permission: string): boolean => { + if (user?.is_system_admin) return true; + const perms = user?.permissions || []; + // Check exact match and wildcards + for (const p of perms) { + if (p === permission) return true; + if (p === '*:*') return true; + const pParts = p.split(':'); + const rParts = permission.split(':'); + if (pParts.length === rParts.length) { + let match = true; + for (let i = 0; i < pParts.length; i++) { + if (pParts[i] !== '*' && pParts[i] !== rParts[i]) { + match = false; + break; + } + } + if (match) return true; + } + } + return false; + }; + + const hasFieldAccess = (module: string, field: string): 'hidden' | 'readonly' | 'read' => { + if (user?.is_system_admin) return 'read'; + const fieldPerms = user?.field_permissions || {}; + const modulePerms = fieldPerms[module] || {}; + return modulePerms[field] || 'read'; + }; + + return { hasPermission, hasFieldAccess }; +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 697856a..f3eab19 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -10,6 +10,7 @@ export function SettingsPage() { { to: '/settings/system', label: t('systemSettings.title'), icon: '\u2699\ufe0f' }, { to: '/settings/roles', label: t('settings.roles'), icon: '\ud83d\udd11' }, { to: '/settings/users', label: t('settings.users'), icon: '\ud83d\udc65' }, + { to: '/settings/groups', label: t('settings.groups', 'Gruppen'), icon: '\ud83d\udc65\ud83d\udcac' }, { to: '/settings/plugins', label: t('settings.plugins'), icon: '\ud83e\udde9' }, { to: '/settings/currencies', label: t('currencies.title'), icon: '\ud83d\udcb0' }, { to: '/settings/taxes', label: t('taxes.title'), icon: '\ud83d\udccb' }, diff --git a/frontend/src/pages/SettingsGroups.tsx b/frontend/src/pages/SettingsGroups.tsx new file mode 100644 index 0000000..94f8bd2 --- /dev/null +++ b/frontend/src/pages/SettingsGroups.tsx @@ -0,0 +1,688 @@ +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, 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, + permKey: string +): Record { + const current = isPermissionGranted(permissions, permKey); + return { ...permissions, [permKey]: !current }; +} + +function getGrantedPermissionKeys(permissions: Record): 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 { + id?: string; + name: string; + description: string; + permissions: Record; + denied_permissions: string[]; + field_permissions: Record; +} + +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>({}); + const [newGroupDenied, setNewGroupDenied] = useState([]); + const [editingGroup, setEditingGroup] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(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(); + for (const perm of allPermissions) { + map.set(perm.key, perm.label); + } + return map; + }, [allPermissions]); + + const groupedPermissions = useMemo(() => { + const groups: Record = {}; + 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 ( +
+ +
+ + + +
+
+ ); + } + + if (isError) { + return ( +
+

+ {t('settings.groups', 'Gruppen')} +

+ refetch()}>{t('common.reset', 'Erneut versuchen')}} + /> +
+ ); + } + + return ( +
+
+

+ {t('settings.groups', 'Gruppen')} +

+ +
+ + {groups.length === 0 ? ( + setCreateOpen(true)}>{t('settings.createGroup', 'Gruppe erstellen')}} + /> + ) : ( +
+ {groups.map((group) => { + const grantedKeys = getGrantedPermissionKeys(group.permissions); + const deniedCount = (group.denied_permissions || []).length; + return ( + +
+
+

{group.name}

+ {group.description && ( +

{group.description}

+ )} +
+
+ + +
+
+
+ {grantedKeys.length === 0 ? ( + + ) : ( + grantedKeys.slice(0, 10).map((perm) => { + const label = permissionLabelMap.get(perm) || perm; + return {label}; + }) + )} + {grantedKeys.length > 10 && ( + +{grantedKeys.length - 10} + )} + {deniedCount > 0 && ( + + {deniedCount} {t('settings.denied', 'verweigert')} + + )} +
+
+ ); + })} +
+ )} + + {/* Create Group Modal */} + setCreateOpen(false)} + title={t('settings.createGroup', 'Gruppe erstellen')} + size="lg" + > +
+ setNewGroupName(e.target.value)} + placeholder={t('settings.groupName', 'Gruppenname')} + data-testid="new-group-name" + /> + setNewGroupDescription(e.target.value)} + placeholder={t('settings.groupDescription', 'Beschreibung')} + data-testid="new-group-description" + /> +
+ +
+ {allPermissions.length === 0 && ( +

{t('settings.noPermissions', 'Keine Berechtigungen verfügbar')}

+ )} + {Object.entries(groupedPermissions).map(([category, perms]) => ( +
+

+ {category === 'system' ? t('settings.systemPermissions', 'System') : t('settings.pluginPermissions', 'Plugins')} +

+ {perms.map((perm) => ( + + ))} +
+ ))} +
+
+
+ +
+ {allPermissions.length === 0 && ( +

{t('settings.noPermissions', 'Keine Berechtigungen verfügbar')}

+ )} + {allPermissions.map((perm) => ( + + ))} +
+
+
+ + +
+
+
+ + {/* Edit Group Modal */} + setEditingGroup(null)} + title={t('settings.groups', 'Gruppen') + ' — ' + (editingGroup?.name || '')} + size="xl" + > + {editingGroup && ( + 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} + /> + )} + + + setConfirmDelete(null)} + /> +
+ ); +} + +// ─── 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; + permissionLabelMap: Map; + 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 ( +
+ {/* Tab Navigation */} +
+ + + + +
+ + {/* Details Tab */} + {activeTab === 'details' && ( +
+ onChange({ ...group, name: e.target.value })} + data-testid="edit-group-name" + /> + onChange({ ...group, description: e.target.value })} + data-testid="edit-group-description" + /> +
+ )} + + {/* Permissions Tab */} + {activeTab === 'permissions' && ( +
+ +
+ {allPermissions.length === 0 && ( +

{t('settings.noPermissions', 'Keine Berechtigungen verfügbar')}

+ )} + {Object.entries(groupedPermissions).map(([category, perms]) => ( +
+

+ {category === 'system' ? t('settings.systemPermissions', 'System') : t('settings.pluginPermissions', 'Plugins')} +

+ {perms.map((perm) => ( + + ))} +
+ ))} +
+
+ )} + + {/* Denied Permissions Tab */} + {activeTab === 'denied' && ( +
+ +

+ {t('settings.deniedPermissionsHint', 'Diese Berechtigungen werden explizit verweigert, auch wenn sie über Rollen gewährt wurden.')} +

+
+ {allPermissions.length === 0 && ( +

{t('settings.noPermissions', 'Keine Berechtigungen verfügbar')}

+ )} + {allPermissions.map((perm) => ( + + ))} +
+
+ )} + + {/* Members Tab */} + {activeTab === 'members' && ( +
+
+
+ handleToggleDenied(perm.key, 'new')} + className="w-4 h-4 rounded border-danger-300 text-danger-600 focus:ring-danger-500" + /> + {perm.label} + + ))} +
+
+ + +
+ + {/* Permissions Tab */} + {editTab === 'permissions' && ( +
+ +
+ {allPermissions.length === 0 && ( +

{t('settings.noPermissions')}

+ )} + {Object.entries(groupedPermissions).map(([category, perms]) => ( +
+

+ {category === 'system' ? t('settings.systemPermissions') : t('settings.pluginPermissions')} +

+ {perms.map((perm) => ( + + ))} +
+ ))} +
+
+ )} + + {/* Denied Permissions Tab */} + {editTab === 'denied' && ( +
+ +

+ {t('settings.deniedPermissionsHint', 'Diese Berechtigungen werden explizit verweigert.')} +

+
+ {allPermissions.length === 0 && ( +

{t('settings.noPermissions')}

+ )} + {allPermissions.map((perm) => ( + + ))} +
+
+ )} + + {/* Field Permissions Tab */} + {editTab === 'fields' && ( +
+ + {fieldDefinitions.length === 0 ? ( +

+ {t('settings.noFieldDefinitions', 'Keine Feld-Definitionen verfügbar')} +

+ ) : ( +
+ {Object.entries(groupedFieldDefinitions).map(([moduleName, fields]) => ( +
+

{moduleName}

+ {fields.map((fd) => { + const currentVal = (editingRole.field_permissions || {})[moduleName]?.[fd.field] || 'default'; + return ( +
+
+ {fd.label} + ({fd.sensitivity}) +
+ +
+ ); + })} +
))}
- ))} + )}
-
+ )} +