523 lines
22 KiB
TypeScript
523 lines
22 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useRoles, useCreateRole, useUpdateRole, useDeleteRole, usePermissions } 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';
|
|
import { Badge } from '@/components/ui/Badge';
|
|
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';
|
|
|
|
interface Role {
|
|
id: string;
|
|
name: string;
|
|
permissions: Record<string, any>;
|
|
denied_permissions?: string[];
|
|
field_permissions?: Record<string, any>;
|
|
}
|
|
|
|
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];
|
|
}
|
|
|
|
export function SettingsRolesPage() {
|
|
const { t } = useTranslation();
|
|
const toast = useToast();
|
|
const { data, isLoading, isError, error, refetch } = useRoles();
|
|
const { data: permissionsData, isLoading: permissionsLoading } = usePermissions();
|
|
const createRoleMutation = useCreateRole();
|
|
const updateRoleMutation = useUpdateRole();
|
|
const deleteRoleMutation = useDeleteRole();
|
|
|
|
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 ?? [];
|
|
|
|
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 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'));
|
|
return;
|
|
}
|
|
try {
|
|
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'));
|
|
}
|
|
};
|
|
|
|
const handleTogglePermission = (permKey: string, target: 'new' | 'edit') => {
|
|
if (target === 'new') {
|
|
setNewRolePermissions((prev) => togglePermission(prev, permKey));
|
|
} else if (editingRole) {
|
|
setEditingRole({
|
|
...editingRole,
|
|
permissions: togglePermission(editingRole.permissions, permKey),
|
|
});
|
|
}
|
|
};
|
|
|
|
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 {
|
|
await updateRoleMutation.mutateAsync({
|
|
id: editingRole.id,
|
|
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);
|
|
} catch (err: any) {
|
|
toast.error(err.message || t('common.error'));
|
|
}
|
|
};
|
|
|
|
const handleDeleteRole = async () => {
|
|
if (!confirmDelete) return;
|
|
try {
|
|
await deleteRoleMutation.mutateAsync(confirmDelete.id);
|
|
toast.success(t('settings.roleDeleted'));
|
|
setConfirmDelete(null);
|
|
} catch (err: any) {
|
|
toast.error(err.message || t('common.error'));
|
|
}
|
|
};
|
|
|
|
if (isLoading || permissionsLoading) {
|
|
return (
|
|
<div className="p-6 max-w-4xl mx-auto" data-testid="settings-roles-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-roles-page">
|
|
<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>}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 max-w-4xl mx-auto" data-testid="settings-roles-page">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h1 className="text-2xl font-bold text-secondary-900">{t('settings.roles')}</h1>
|
|
<Button onClick={() => setCreateOpen(true)} data-testid="create-role-btn">
|
|
{t('settings.createRole')}
|
|
</Button>
|
|
</div>
|
|
|
|
{roles.length === 0 ? (
|
|
<EmptyState
|
|
title={t('settings.noRoles')}
|
|
action={<Button onClick={() => setCreateOpen(true)}>{t('settings.createRole')}</Button>}
|
|
/>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{roles.map((role) => {
|
|
const grantedKeys = getGrantedPermissionKeys(role.permissions);
|
|
return (
|
|
<Card key={role.id}>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-lg font-semibold text-secondary-900">{role.name}</h3>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setEditingRole(role)}
|
|
data-testid={`edit-role-${role.id}`}
|
|
>
|
|
{t('common.edit')}
|
|
</Button>
|
|
<Button
|
|
variant="danger"
|
|
size="sm"
|
|
onClick={() => setConfirmDelete(role)}
|
|
data-testid={`delete-role-${role.id}`}
|
|
>
|
|
{t('common.delete')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{grantedKeys.length === 0 ? (
|
|
<span className="text-sm text-secondary-500">—</span>
|
|
) : (
|
|
grantedKeys.map((perm) => {
|
|
const label = permissionLabelMap.get(perm) || perm;
|
|
return <Badge key={perm} variant="primary">{label}</Badge>;
|
|
})
|
|
)}
|
|
</div>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title={t('settings.createRole')}>
|
|
<div className="space-y-4" data-testid="create-role-form">
|
|
<Input
|
|
label={t('settings.roleName')}
|
|
value={newRoleName}
|
|
onChange={(e) => setNewRoleName(e.target.value)}
|
|
placeholder={t('settings.roleName')}
|
|
data-testid="new-role-name"
|
|
/>
|
|
<div>
|
|
<label className="block text-sm font-medium text-secondary-700 mb-2">{t('settings.permissions')}</label>
|
|
<div className="space-y-2">
|
|
{allPermissions.length === 0 && (
|
|
<p className="text-sm text-secondary-500">{t('settings.noPermissions')}</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') : t('settings.pluginPermissions')}
|
|
</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(newRolePermissions, 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')}</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
|
|
onClick={handleCreateRole}
|
|
isLoading={createRoleMutation.isPending}
|
|
data-testid="save-role-btn"
|
|
>
|
|
{t('common.save')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
<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
|
|
label={t('settings.roleName')}
|
|
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 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>
|
|
)}
|
|
{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') : t('settings.pluginPermissions')}
|
|
</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(editingRole.permissions, perm.key)}
|
|
onChange={() => handleTogglePermission(perm.key, 'edit')}
|
|
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 */}
|
|
{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
|
|
onClick={handleSaveRole}
|
|
isLoading={updateRoleMutation.isPending}
|
|
data-testid="update-role-btn"
|
|
>
|
|
{t('common.save')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
|
|
<ConfirmDialog
|
|
open={!!confirmDelete}
|
|
title={t('common.delete')}
|
|
message={`${t('common.delete')}: ${confirmDelete?.name}?`}
|
|
variant="danger"
|
|
onConfirm={handleDeleteRole}
|
|
onCancel={() => setConfirmDelete(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|