feat: phase 4 - frontend groups UI, permission-matrix, usePermission hook, field-level permissions tab, authStore permissions
This commit is contained in:
@@ -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<string, any>;
|
||||
denied_permissions: string[];
|
||||
field_permissions: Record<string, any>;
|
||||
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<Group>) => apiPost('/groups', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['groups'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateGroup() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<Group> }) =>
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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<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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRoles, useCreateRole, useUpdateRole, useDeleteRole, usePermissions } from '@/api/hooks';
|
||||
import type { PermissionItem } from '@/api/hooks';
|
||||
import type { PermissionItem, FieldDefinition } from '@/api/hooks';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
@@ -16,6 +16,7 @@ interface Role {
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: Record<string, any>;
|
||||
denied_permissions?: string[];
|
||||
field_permissions?: Record<string, any>;
|
||||
}
|
||||
|
||||
@@ -52,6 +53,13 @@ function getGrantedPermissionKeys(permissions: Record<string, any>): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
function toggleDeniedPermission(denied: string[], permKey: string): string[] {
|
||||
if (denied.includes(permKey)) {
|
||||
return denied.filter((k) => k !== permKey);
|
||||
}
|
||||
return [...denied, permKey];
|
||||
}
|
||||
|
||||
export function SettingsRolesPage() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
@@ -64,8 +72,10 @@ export function SettingsRolesPage() {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newRoleName, setNewRoleName] = useState('');
|
||||
const [newRolePermissions, setNewRolePermissions] = useState<Record<string, any>>({});
|
||||
const [newRoleDenied, setNewRoleDenied] = useState<string[]>([]);
|
||||
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<Role | null>(null);
|
||||
const [editTab, setEditTab] = useState<'permissions' | 'denied' | 'fields'>('permissions');
|
||||
|
||||
const roles: Role[] = data?.items ?? [];
|
||||
|
||||
@@ -92,6 +102,21 @@ export function SettingsRolesPage() {
|
||||
return groups;
|
||||
}, [allPermissions]);
|
||||
|
||||
const fieldDefinitions: FieldDefinition[] = useMemo(() => {
|
||||
return permissionsData?.field_definitions ?? [];
|
||||
}, [permissionsData]);
|
||||
|
||||
const groupedFieldDefinitions = useMemo(() => {
|
||||
const groups: Record<string, FieldDefinition[]> = {};
|
||||
for (const fd of fieldDefinitions) {
|
||||
if (!groups[fd.module]) {
|
||||
groups[fd.module] = [];
|
||||
}
|
||||
groups[fd.module].push(fd);
|
||||
}
|
||||
return groups;
|
||||
}, [fieldDefinitions]);
|
||||
|
||||
const handleCreateRole = async () => {
|
||||
if (!newRoleName.trim()) {
|
||||
toast.error(t('validation.required'));
|
||||
@@ -101,11 +126,13 @@ export function SettingsRolesPage() {
|
||||
await createRoleMutation.mutateAsync({
|
||||
name: newRoleName.trim(),
|
||||
permissions: newRolePermissions,
|
||||
denied_permissions: newRoleDenied,
|
||||
field_permissions: {},
|
||||
});
|
||||
} as any);
|
||||
toast.success(t('settings.roleCreated'));
|
||||
setNewRoleName('');
|
||||
setNewRolePermissions({});
|
||||
setNewRoleDenied([]);
|
||||
setCreateOpen(false);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
@@ -123,6 +150,34 @@ export function SettingsRolesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleDenied = (permKey: string, target: 'new' | 'edit') => {
|
||||
if (target === 'new') {
|
||||
setNewRoleDenied((prev) => toggleDeniedPermission(prev, permKey));
|
||||
} else if (editingRole) {
|
||||
setEditingRole({
|
||||
...editingRole,
|
||||
denied_permissions: toggleDeniedPermission(editingRole.denied_permissions || [], permKey),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFieldPermChange = (module: string, field: string, value: 'hidden' | 'readonly' | 'read' | 'default') => {
|
||||
if (!editingRole) return;
|
||||
const fieldPerms = { ...(editingRole.field_permissions || {}) };
|
||||
if (!fieldPerms[module]) {
|
||||
fieldPerms[module] = {};
|
||||
}
|
||||
if (value === 'default') {
|
||||
delete fieldPerms[module][field];
|
||||
if (Object.keys(fieldPerms[module]).length === 0) {
|
||||
delete fieldPerms[module];
|
||||
}
|
||||
} else {
|
||||
fieldPerms[module][field] = value;
|
||||
}
|
||||
setEditingRole({ ...editingRole, field_permissions: fieldPerms });
|
||||
};
|
||||
|
||||
const handleSaveRole = async () => {
|
||||
if (!editingRole) return;
|
||||
try {
|
||||
@@ -131,8 +186,9 @@ export function SettingsRolesPage() {
|
||||
data: {
|
||||
name: editingRole.name,
|
||||
permissions: editingRole.permissions,
|
||||
denied_permissions: editingRole.denied_permissions || [],
|
||||
field_permissions: editingRole.field_permissions || {},
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
toast.success(t('settings.roleUpdated'));
|
||||
setEditingRole(null);
|
||||
@@ -171,10 +227,9 @@ export function SettingsRolesPage() {
|
||||
<h1 className="text-2xl font-bold text-secondary-900 mb-6">{t('settings.roles')}</h1>
|
||||
<EmptyState
|
||||
title={t('common.error')}
|
||||
description={error?.message || t('common.error')}
|
||||
action={<Button onClick={() => refetch()}>{t('common.reset')}</Button>}
|
||||
>
|
||||
<p className="text-sm text-secondary-500">{error?.message || t('common.error')}</p>
|
||||
</EmptyState>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -271,6 +326,27 @@ export function SettingsRolesPage() {
|
||||
))}
|
||||
</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')}</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={newRoleDenied.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
|
||||
@@ -284,7 +360,7 @@ export function SettingsRolesPage() {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal open={!!editingRole} onClose={() => setEditingRole(null)} title={t('settings.roles') + ' — ' + (editingRole?.name || '')}>
|
||||
<Modal open={!!editingRole} onClose={() => setEditingRole(null)} title={t('settings.roles') + ' — ' + (editingRole?.name || '')} size="xl">
|
||||
{editingRole && (
|
||||
<div className="space-y-4" data-testid="edit-role-form">
|
||||
<Input
|
||||
@@ -292,9 +368,36 @@ export function SettingsRolesPage() {
|
||||
value={editingRole.name}
|
||||
onChange={(e) => setEditingRole({ ...editingRole, name: e.target.value })}
|
||||
/>
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex gap-2 border-b border-secondary-200 pb-2">
|
||||
<button
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${editTab === 'permissions' ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900'}`}
|
||||
onClick={() => setEditTab('permissions')}
|
||||
data-testid="role-tab-permissions"
|
||||
>
|
||||
{t('settings.permissions')}
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${editTab === 'denied' ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900'}`}
|
||||
onClick={() => setEditTab('denied')}
|
||||
data-testid="role-tab-denied"
|
||||
>
|
||||
{t('settings.deniedPermissions', 'Verweigert')}
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${editTab === 'fields' ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900'}`}
|
||||
onClick={() => setEditTab('fields')}
|
||||
data-testid="role-tab-fields"
|
||||
>
|
||||
{t('settings.fieldPermissions', 'Feld-Berechtigungen')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Permissions Tab */}
|
||||
{editTab === 'permissions' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">{t('settings.permissions')}</label>
|
||||
<div className="space-y-2">
|
||||
<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')}</p>
|
||||
)}
|
||||
@@ -318,6 +421,80 @@ export function SettingsRolesPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Denied Permissions Tab */}
|
||||
{editTab === '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.')}
|
||||
</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')}</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={(editingRole.denied_permissions || []).includes(perm.key)}
|
||||
onChange={() => handleToggleDenied(perm.key, 'edit')}
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Field Permissions Tab */}
|
||||
{editTab === 'fields' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
{t('settings.fieldPermissions', 'Feld-Berechtigungen')}
|
||||
</label>
|
||||
{fieldDefinitions.length === 0 ? (
|
||||
<p className="text-sm text-secondary-500">
|
||||
{t('settings.noFieldDefinitions', 'Keine Feld-Definitionen verfügbar')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto border border-secondary-200 rounded-md p-3">
|
||||
{Object.entries(groupedFieldDefinitions).map(([moduleName, fields]) => (
|
||||
<div key={moduleName} className="mb-3">
|
||||
<p className="text-xs font-semibold text-secondary-500 uppercase mb-1">{moduleName}</p>
|
||||
{fields.map((fd) => {
|
||||
const currentVal = (editingRole.field_permissions || {})[moduleName]?.[fd.field] || 'default';
|
||||
return (
|
||||
<div key={`${moduleName}-${fd.field}`} className="flex items-center justify-between p-2 rounded-md hover:bg-secondary-50">
|
||||
<div className="flex-1">
|
||||
<span className="text-sm text-secondary-900">{fd.label}</span>
|
||||
<span className="text-xs text-secondary-400 ml-2">({fd.sensitivity})</span>
|
||||
</div>
|
||||
<select
|
||||
value={currentVal}
|
||||
onChange={(e) => handleFieldPermChange(moduleName, fd.field, e.target.value as any)}
|
||||
className="text-sm rounded-md border border-secondary-300 px-2 py-1 focus:ring-primary-500 focus:border-primary-500"
|
||||
data-testid={`field-perm-${moduleName}-${fd.field}`}
|
||||
>
|
||||
<option value="default">{t('settings.fieldDefault', 'Standard')}</option>
|
||||
<option value="read">{t('settings.fieldRead', 'Lesen')}</option>
|
||||
<option value="readonly">{t('settings.fieldReadonly', 'Nur-Lesen')}</option>
|
||||
<option value="hidden">{t('settings.fieldHidden', 'Versteckt')}</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setEditingRole(null)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
|
||||
@@ -18,6 +18,7 @@ import { SettingsPage } from '@/pages/Settings';
|
||||
import { SettingsProfilePage } from '@/pages/SettingsProfile';
|
||||
import { SettingsRolesPage } from '@/pages/SettingsRoles';
|
||||
import { SettingsUsersPage } from '@/pages/SettingsUsers';
|
||||
import { SettingsGroupsPage } from '@/pages/SettingsGroups';
|
||||
import { SettingsPluginsPage } from '@/pages/SettingsPlugins';
|
||||
import { SettingsSystemPage } from '@/pages/SettingsSystem';
|
||||
import { SettingsCurrenciesPage } from '@/pages/SettingsCurrencies';
|
||||
@@ -76,6 +77,7 @@ const router = createBrowserRouter([
|
||||
{ path: 'profile', element: <SettingsProfilePage /> },
|
||||
{ path: 'roles', element: <SettingsRolesPage /> },
|
||||
{ path: 'users', element: <SettingsUsersPage /> },
|
||||
{ path: 'groups', element: <SettingsGroupsPage /> },
|
||||
{ path: 'plugins', element: <SettingsPluginsPage /> },
|
||||
{ path: 'system', element: <SettingsSystemPage /> },
|
||||
{ path: 'currencies', element: <SettingsCurrenciesPage /> },
|
||||
|
||||
@@ -14,6 +14,9 @@ export interface User {
|
||||
role: string;
|
||||
avatar_url: string | null;
|
||||
tenants: Tenant[];
|
||||
permissions?: string[];
|
||||
is_system_admin?: boolean;
|
||||
field_permissions?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
@@ -26,6 +29,7 @@ export interface AuthState {
|
||||
setAuthenticated: (authed: boolean) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setPermissions: (perms: string[], isSystemAdmin: boolean, fieldPerms: Record<string, any>) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
@@ -45,6 +49,17 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
setAuthenticated: (authed) => set({ isAuthenticated: authed }),
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
setError: (error) => set({ error }),
|
||||
setPermissions: (perms, isSystemAdmin, fieldPerms) =>
|
||||
set((state) => ({
|
||||
user: state.user
|
||||
? {
|
||||
...state.user,
|
||||
permissions: perms,
|
||||
is_system_admin: isSystemAdmin,
|
||||
field_permissions: fieldPerms,
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
logout: () =>
|
||||
set({
|
||||
user: null,
|
||||
|
||||
Reference in New Issue
Block a user