moved tenant role permission functionality to tenant page, removed tenant role permission page

This commit is contained in:
Ida 2026-06-04 13:08:52 +02:00
parent 059bbe956a
commit dfa36c17ef
17 changed files with 863 additions and 618 deletions

View file

@ -39,7 +39,7 @@ import { GDPRPage } from './pages/GDPR';
import StorePage from './pages/Store';
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
import { FeatureViewPage } from './pages/FeatureView';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage } from './pages/admin';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage } from './pages/admin';
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
@ -213,7 +213,6 @@ function App() {
<Route path="feature-users" element={<AdminFeatureInstanceUsersPage />} />
<Route path="invitations" element={<AdminInvitationsPage />} />
<Route path="mandate-roles" element={<AdminMandateRolesPage />} />
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
<Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} />
<Route path="billing">
<Route index element={<Navigate to="/billing/admin" replace />} />

View file

@ -175,6 +175,16 @@
box-shadow: 0 3px 8px rgba(74, 111, 165, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.actionButton.roles {
background: linear-gradient(180deg, #6b9a7a 0%, #38a169 100%);
color: white;
}
.actionButton.roles:hover {
background: linear-gradient(180deg, #38a169 0%, #2f855a 100%);
box-shadow: 0 3px 8px rgba(56, 161, 105, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.actionButton.copy {
background: linear-gradient(180deg, #8494a7 0%, #6b7b8d 100%);
color: white;

View file

@ -0,0 +1,91 @@
import React, { useState } from 'react';
import { FaShieldAlt } from 'react-icons/fa';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { Popup } from '../../../UiComponents/Popup';
import { MandateRolesPermissionsPanel } from '../../../admin/MandateRolesPermissionsPanel';
import { mandateDisplayLabel } from '../../../../utils/mandateDisplayUtils';
import styles from '../ActionButton.module.css';
export interface RolesActionButtonProps<T = Record<string, unknown>> {
row: T;
disabled?: boolean | { disabled: boolean; message?: string };
loading?: boolean;
className?: string;
title?: string;
idField?: string;
labelField?: string;
nameField?: string;
}
export function RolesActionButton<T = Record<string, unknown>>({
row,
disabled = false,
loading = false,
className = '',
title,
idField = 'id',
labelField = 'label',
nameField = 'name',
}: RolesActionButtonProps<T>) {
const { t } = useLanguage();
const [isPopupOpen, setIsPopupOpen] = useState(false);
const isDisabled = typeof disabled === 'boolean' ? disabled : disabled?.disabled || false;
const disabledMessage = typeof disabled === 'object' ? disabled?.message : undefined;
const rowRecord = row as Record<string, unknown>;
const mandateId = String(rowRecord[idField] ?? '');
const mandateLabel = mandateDisplayLabel({
label: rowRecord[labelField] as string | undefined,
name: rowRecord[nameField] as string | undefined,
id: mandateId,
});
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (!isDisabled && !loading && mandateId) {
setIsPopupOpen(true);
}
};
const buttonTitle = title || t('Rollen & Berechtigungen');
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
return (
<>
<button
type="button"
onClick={handleClick}
className={`${styles.actionButton} ${styles.roles} ${loading ? styles.loading : ''} ${isDisabled ? styles.disabled : ''} ${className}`}
title={finalTitle}
disabled={isDisabled || loading || !mandateId}
>
<span className={styles.actionIcon}>
{loading ? '⏳' : <FaShieldAlt />}
</span>
</button>
<Popup
isOpen={isPopupOpen}
title={`${t('Rollen-Berechtigungen')}: ${mandateLabel}`}
onClose={() => setIsPopupOpen(false)}
size="xlarge"
closable={true}
closeOnBackdropClick={false}
>
{isPopupOpen && mandateId && (
<MandateRolesPermissionsPanel
mandateId={mandateId}
mandateLabel={mandateLabel}
showRoleActions={true}
showCreateRole={true}
scopeFilter="mandate"
showInfoBox={true}
/>
)}
</Popup>
</>
);
}
export default RolesActionButton;

View file

@ -0,0 +1,2 @@
export { RolesActionButton } from './RolesActionButton';
export type { RolesActionButtonProps } from './RolesActionButton';

View file

@ -5,6 +5,7 @@ export { ViewActionButton } from './ViewActionButton';
export { CopyActionButton } from './CopyActionButton';
export { RemoveActionButton } from './RemoveActionButton';
export { DownloadActionButton } from './DownloadActionButton';
export { RolesActionButton } from './RolesActionButton';
// Generic Custom Action Button (for entity-specific actions)
export { CustomActionButton } from './CustomActionButton';
@ -16,4 +17,5 @@ export type { ViewActionButtonProps } from './ViewActionButton';
export type { CopyActionButtonProps } from './CopyActionButton';
export type { RemoveActionButtonProps } from './RemoveActionButton';
export type { DownloadActionButtonProps } from './DownloadActionButton';
export type { RolesActionButtonProps } from './RolesActionButton';
export type { CustomActionButtonProps } from './CustomActionButton';

View file

@ -7,6 +7,7 @@ import {
DeleteActionButton,
ViewActionButton,
CopyActionButton,
RolesActionButton,
CustomActionButton
} from '../ActionButtons';
import { FaDownload, FaLink, FaPlay } from 'react-icons/fa';
@ -52,7 +53,7 @@ export interface FormGeneratorListProps<T = any> {
loading?: boolean;
emptyMessage?: string;
actionButtons?: {
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play';
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'roles' | 'connect' | 'play';
onAction?: (row: T) => Promise<void> | void;
disabled?: (row: T, hookData?: any) => boolean | { disabled: boolean; message?: string };
loading?: (row: T, hookData?: any) => boolean;
@ -858,6 +859,18 @@ export function FormGeneratorList<T extends Record<string, any>>({
return <ViewActionButton key={actionIndex} {...baseProps} onView={actionButton.onAction || (() => {})} isViewing={isProcessing} hookData={hookData} />;
case 'copy':
return <CopyActionButton key={actionIndex} {...baseProps} onCopy={actionButton.onAction} isCopying={isProcessing} contentField={actionButton.contentField} />;
case 'roles':
return (
<RolesActionButton
key={actionIndex}
row={row}
disabled={disabledResult}
loading={isLoading}
className={actionButton.className}
title={actionTitle}
idField={actionButton.idField ?? 'id'}
/>
);
case 'connect':
return <CustomActionButton
key={actionIndex}

View file

@ -64,6 +64,7 @@ import {
DeleteActionButton,
ViewActionButton,
CopyActionButton,
RolesActionButton,
CustomActionButton
} from '../ActionButtons';
import { formatUnixTimestamp } from '../../../utils/time';
@ -264,7 +265,7 @@ export interface FormGeneratorTableProps<T = any> {
idField?: string; // Field name for unique row identifier (default: 'id')
// Standard action buttons (edit, delete, view, copy, connect, play)
actionButtons?: {
type: 'edit' | 'delete' | 'view' | 'copy' | 'connect' | 'play';
type: 'edit' | 'delete' | 'view' | 'copy' | 'roles' | 'connect' | 'play';
onAction?: (row: T) => Promise<void> | void;
visible?: (row: T, hookData?: any) => boolean;
disabled?: (row: T, hookData?: any) => boolean | { disabled: boolean; message?: string };
@ -2692,6 +2693,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
case 'delete': return <DeleteActionButton key={`a-${ai}`} {...bp} containerRef={{ current: actionButtonsRefs.current.get(index) || null }} hookData={hookData} />;
case 'view': return <ViewActionButton key={`a-${ai}`} {...bp} onView={ab.onAction || (() => {})} isViewing={isProc} hookData={hookData} />;
case 'copy': return <CopyActionButton key={`a-${ai}`} {...bp} onCopy={ab.onAction} isCopying={isProc} contentField={ab.contentField} />;
case 'roles': return <RolesActionButton key={`a-${ai}`} row={row} disabled={dis} loading={isLd} className={bp.className} title={abTitle} idField={ab.idField ?? 'id'} />;
default: return null;
}
})}

View file

@ -46,6 +46,12 @@
min-width: 600px;
}
.xlarge {
width: min(1400px, 96vw);
max-width: 96vw;
min-width: min(1100px, 96vw);
}
.fullscreen {
width: 95vw;
height: 95vh;
@ -186,7 +192,9 @@
.content {
padding: 24px;
overflow-y: auto;
overflow-x: hidden;
flex: 1;
min-width: 0;
}
/* Footer section */

View file

@ -21,7 +21,7 @@ export interface PopupProps {
children: React.ReactNode;
footerContent?: React.ReactNode;
className?: string;
size?: 'small' | 'medium' | 'large' | 'fullscreen';
size?: 'small' | 'medium' | 'large' | 'xlarge' | 'fullscreen';
closable?: boolean;
closeOnBackdropClick?: boolean;
closeOnEscape?: boolean;

View file

@ -0,0 +1,214 @@
/* Table-style role list: one row per role card header */
.rolesTable {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
.rolesTableHead,
.roleHeaderTable {
display: grid;
align-items: center;
gap: 0.75rem 1rem;
width: 100%;
min-width: 0;
}
.rolesTableHead {
padding: 0.5rem 1.25rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary, #666);
border-bottom: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-tertiary, #f8f9fa);
border-radius: 8px 8px 0 0;
}
.roleHeaderTable {
grid-template-columns:
24px
minmax(100px, 0.9fr)
minmax(140px, 2fr)
minmax(120px, 0.75fr)
minmax(64px, 0.35fr);
}
.roleHeaderTableWithActions {
grid-template-columns:
24px
minmax(100px, 0.9fr)
minmax(140px, 2fr)
minmax(120px, 0.75fr)
minmax(64px, 0.35fr)
72px;
}
.rolesTableHeadWithActions {
grid-template-columns:
24px
minmax(100px, 0.9fr)
minmax(140px, 2fr)
minmax(120px, 0.75fr)
minmax(64px, 0.35fr)
72px;
}
.roleHeaderTableRow {
padding: 0.75rem 1.25rem;
cursor: pointer;
transition: background-color 0.2s ease;
}
.roleHeaderTableRow:hover {
background: var(--bg-tertiary, #f8f9fa);
}
.cellExpand {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.cellLabel {
font-weight: 600;
color: var(--text-primary, #1a1a1a);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.cellDescription {
color: var(--text-secondary, #666);
font-size: 0.875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.cellScope,
.cellUsers {
font-size: 0.875rem;
color: var(--text-secondary, #666);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.cellScope strong,
.cellUsers strong {
color: var(--text-primary, #1a1a1a);
font-weight: 600;
}
.roleHeaderActions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
flex-shrink: 0;
}
.roleActionBtn {
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
border: none;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
min-width: 28px;
min-height: 28px;
transition: all 0.2s ease;
}
.roleActionBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.roleActionBtnEdit {
background: linear-gradient(180deg, #5a82b5 0%, var(--color-secondary, #4A6FA5) 100%);
color: white;
}
.roleActionBtnDelete {
background: linear-gradient(180deg, #8494a7 0%, #6b7b8d 100%);
color: white;
}
.roleActionBtnDelete:hover:not(:disabled) {
background: linear-gradient(180deg, #d44040 0%, var(--color-red, #C53030) 100%);
}
.panelRoot {
min-width: 0;
max-width: 100%;
}
.panelHeaderRow {
display: flex;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
min-width: 0;
}
.panelInfoBox {
flex: 1;
min-width: 0;
margin-bottom: 0;
align-items: flex-start;
box-sizing: border-box;
}
.panelCreateButton {
flex-shrink: 0;
align-self: flex-start;
white-space: nowrap;
}
.panelInfoBoxIcon {
flex-shrink: 0;
margin-top: 0.15em;
}
.panelInfoBoxText {
flex: 1;
min-width: 0;
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
line-height: 1.5;
}
.roleContentInfoBox {
align-items: flex-start;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
.roleContentInfoBoxText {
flex: 1;
min-width: 0;
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
line-height: 1.5;
}
.roleCardTable {
overflow: hidden;
}
.roleCardTable .roleHeaderTableRow {
border: none;
}

View file

@ -0,0 +1,510 @@
/**
* MandateRolesPermissionsPanel
*
* Expandable role list with AccessRulesEditor per role.
* Used in RolesActionButton popup on Admin Mandates.
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import {
useMandateRoles,
type Role,
type RoleCreate,
type RoleUpdate,
} from '../../hooks/useMandateRoles';
import { useApiRequest } from '../../hooks/useApi';
import { fetchAttributes } from '../../api/attributesApi';
import { AccessRulesEditor } from '../AccessRules';
import { FormGeneratorForm, type AttributeDefinition } from '../FormGenerator/FormGeneratorForm';
import { Popup } from '../UiComponents/Popup';
import { useToast } from '../../contexts/ToastContext';
import { useLanguage } from '../../providers/language/LanguageContext';
import {
FaUserShield,
FaSync,
FaChevronDown,
FaChevronRight,
FaPlus,
} from 'react-icons/fa';
import { MdModeEdit } from 'react-icons/md';
import { IoIosTrash } from 'react-icons/io';
import adminStyles from '../../pages/admin/Admin.module.css';
import panelStyles from './MandateRolesPermissionsPanel.module.css';
export interface MandateRolesPermissionsPanelProps {
mandateId: string;
mandateLabel?: string;
/** When false, hide per-role edit/delete (e.g. standalone permissions page). Default true. */
showRoleActions?: boolean;
/** Backend scope filter. Default mandate-only for mandates popup. */
scopeFilter?: 'all' | 'mandate' | 'global';
/** Show info box above role list. Default true when showRoleActions, else from parent. */
showInfoBox?: boolean;
/** Increment to trigger refetch (e.g. parent refresh button). */
refreshToken?: number;
/** Show «Neue Rolle» button and create flow. Default: same as showRoleActions. */
showCreateRole?: boolean;
}
const SCOPE_FILTER_DEFAULT: MandateRolesPermissionsPanelProps['scopeFilter'] = 'mandate';
function getTextValue(value: string | Record<string, string> | undefined): string {
if (!value) return '';
if (typeof value === 'string') return value;
return Object.values(value).find(v => !!v) || '';
}
function isTemplateRole(role: Role): boolean {
return !!role.isSystemRole || !role.mandateId;
}
export const MandateRolesPermissionsPanel: React.FC<MandateRolesPermissionsPanelProps> = ({
mandateId,
showRoleActions = true,
scopeFilter = SCOPE_FILTER_DEFAULT,
showInfoBox,
refreshToken = 0,
showCreateRole,
}) => {
const { t } = useLanguage();
const { request } = useApiRequest();
const { showError, showWarning } = useToast();
const {
roles,
loading,
error,
fetchRoles,
createRole,
updateRole,
deleteRole,
} = useMandateRoles();
const [expandedRoleId, setExpandedRoleId] = useState<string | null>(null);
const [editingRole, setEditingRole] = useState<Role | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
const [deletingRoleId, setDeletingRoleId] = useState<string | null>(null);
const shouldShowInfoBox = showInfoBox ?? showRoleActions;
const shouldShowCreateRole = showCreateRole ?? showRoleActions;
const needsRoleFormAttributes = showRoleActions || shouldShowCreateRole;
const loadRoles = useCallback(async () => {
if (!mandateId) return;
await fetchRoles(mandateId, { scopeFilter });
}, [mandateId, scopeFilter, fetchRoles]);
useEffect(() => {
loadRoles();
}, [loadRoles, refreshToken]);
useEffect(() => {
if (needsRoleFormAttributes) {
fetchAttributes(request, 'RoleView')
.then(setBackendAttributes)
.catch(() => setBackendAttributes([]));
}
}, [request, needsRoleFormAttributes]);
const scopeTypeLabel = useCallback(
(scopeType?: Role['scopeType']) => {
switch (scopeType) {
case 'system':
return t('System-Template');
case 'global':
return t('Template');
case 'mandate':
return t('Mandant');
default:
return '—';
}
},
[t],
);
const createFields: AttributeDefinition[] = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType'];
const fields = backendAttributes
.filter(attr => !excludedFields.includes(attr.name))
.map(attr => ({ ...attr })) as AttributeDefinition[];
if (fields.length > 0) {
fields.push({
name: 'scope',
label: t('Geltungsbereich'),
type: 'enum' as AttributeDefinition['type'],
required: true,
default: scopeFilter === 'global' ? 'global' : 'mandate',
options: [
{ value: 'mandate', label: t('Nur dieser Mandant') },
{ value: 'global', label: t('Template bei neuen Mandanten') },
],
});
}
return fields;
}, [backendAttributes, scopeFilter, t]);
const editFields: AttributeDefinition[] = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType'];
return backendAttributes
.filter(attr => !excludedFields.includes(attr.name))
.map(attr => ({
...attr,
readonly: attr.name === 'roleLabel' ? true : attr.readonly,
})) as AttributeDefinition[];
}, [backendAttributes]);
const handleCreateRole = async (data: {
roleLabel: string;
description?: Record<string, string>;
scope: 'mandate' | 'global';
}) => {
if (!mandateId) return;
setIsSubmitting(true);
try {
const roleData: RoleCreate = {
roleLabel: data.roleLabel.toLowerCase().replace(/\s+/g, '_'),
description: data.description,
mandateId: data.scope === 'mandate' ? mandateId : undefined,
};
const result = await createRole(roleData, mandateId);
if (result.success) {
setShowCreateModal(false);
await loadRoles();
} else {
showError(t('Fehler'), result.error || t('Fehler beim Erstellen der Rolle'));
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('Fehler beim Erstellen der Rolle');
showError(t('Fehler'), message);
} finally {
setIsSubmitting(false);
}
};
const toggleRole = (roleId: string) => {
setExpandedRoleId(prev => (prev === roleId ? null : roleId));
};
const handleEditRole = async (data: RoleUpdate) => {
if (!editingRole) return;
setIsSubmitting(true);
try {
const updateData: RoleUpdate = {
roleLabel: data.roleLabel,
description: data.description,
};
const result = await updateRole(editingRole.id, updateData);
if (result.success) {
setEditingRole(null);
await loadRoles();
} else {
showError(t('Fehler'), result.error || t('Fehler beim Aktualisieren der Rolle'));
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('Fehler beim Aktualisieren der Rolle');
showError(t('Fehler'), message);
} finally {
setIsSubmitting(false);
}
};
const handleDeleteRole = async (role: Role) => {
if (role.isSystemRole) {
showWarning(t('Nicht erlaubt'), t('System-Rollen können nicht gelöscht werden.'));
return;
}
if (!window.confirm(t('Rolle «{label}» wirklich löschen?', { label: role.roleLabel }))) {
return;
}
setDeletingRoleId(role.id);
try {
const result = await deleteRole(role.id);
if (result.success) {
if (expandedRoleId === role.id) {
setExpandedRoleId(null);
}
await loadRoles();
} else {
showError(t('Fehler'), result.error || t('Fehler beim Löschen der Rolle'));
}
} finally {
setDeletingRoleId(null);
}
};
if (error) {
return (
<div className={adminStyles.errorContainer}>
<span className={adminStyles.errorIcon}></span>
<p className={adminStyles.errorMessage}>
{t('Fehler beim Laden')}: {error}
</p>
<button type="button" className={adminStyles.secondaryButton} onClick={() => loadRoles()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
);
}
return (
<div className={panelStyles.panelRoot}>
{(shouldShowInfoBox || shouldShowCreateRole) && (
<div className={panelStyles.panelHeaderRow}>
{shouldShowInfoBox && (
<div className={`${adminStyles.infoBox} ${panelStyles.panelInfoBox}`}>
<FaUserShield
className={panelStyles.panelInfoBoxIcon}
style={{ marginRight: '0.5rem', color: 'var(--text-secondary, #666)' }}
/>
<span className={panelStyles.panelInfoBoxText}>
{t('Klicken Sie auf eine Rolle, um deren Berechtigungen (AccessRules) zu bearbeiten.')}{' '}
<strong>{t('Template-Rollen')}</strong>{' '}
{t('sind schreibgeschützt Änderungen an Templates wirken sich nur auf neu erstellte Mandanten aus.')}{' '}
<strong>{t('Mandanten-Rollen')}</strong> {t('sind direkt bearbeitbar.')}
</span>
</div>
)}
{shouldShowCreateRole && (
<button
type="button"
className={`${adminStyles.primaryButton} ${panelStyles.panelCreateButton}`}
onClick={() => setShowCreateModal(true)}
disabled={loading}
>
<FaPlus /> {t('Neue Rolle')}
</button>
)}
</div>
)}
{loading && (
<div className={adminStyles.loadingContainer}>
<div className={adminStyles.spinner} />
<span>{t('Lade Rollen')}</span>
</div>
)}
{!loading && roles.length === 0 && (
<div className={adminStyles.emptyState}>
<FaUserShield className={adminStyles.emptyIcon} />
<p>{t('Keine Rollen gefunden')}</p>
<p className={adminStyles.emptyHint}>
{scopeFilter === 'mandate'
? t('Es gibt noch keine Mandanten-Rollen. System-Rollen werden bei der Mandant-Erstellung automatisch kopiert.')
: scopeFilter === 'global'
? t('Es gibt noch keine Rollentemplates')
: t('Es gibt noch keine Rollen')}
</p>
</div>
)}
{!loading && roles.length > 0 && (
<div className={panelStyles.rolesTable}>
<div
className={`${panelStyles.rolesTableHead} ${
showRoleActions ? panelStyles.rolesTableHeadWithActions : ''
}`}
aria-hidden="true"
>
<span />
<span>{t('Bezeichnung')}</span>
<span>{t('Beschreibung')}</span>
<span>{t('Geltungsbereich')}</span>
<span>{t('Benutzer')}</span>
{showRoleActions && <span>{t('Aktionen')}</span>}
</div>
{roles.map(role => {
const systemLocked = !!role.isSystemRole;
const isExpanded = expandedRoleId === role.id;
const headerGridClass = showRoleActions
? panelStyles.roleHeaderTableWithActions
: '';
return (
<div
key={role.id}
className={`${adminStyles.roleCard} ${panelStyles.roleCardTable}`}
>
<div
className={`${panelStyles.roleHeaderTable} ${headerGridClass} ${panelStyles.roleHeaderTableRow}`}
onClick={() => toggleRole(role.id)}
role="button"
tabIndex={0}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleRole(role.id);
}
}}
>
<span className={`${adminStyles.expandIcon} ${panelStyles.cellExpand}`}>
{isExpanded ? <FaChevronDown /> : <FaChevronRight />}
</span>
<span className={panelStyles.cellLabel} title={role.roleLabel}>
{role.roleLabel}
</span>
<span
className={panelStyles.cellDescription}
title={getTextValue(role.description)}
>
{getTextValue(role.description) || '—'}
</span>
<span className={panelStyles.cellScope}>
<strong>{scopeTypeLabel(role.scopeType)}</strong>
</span>
<span className={panelStyles.cellUsers}>
<strong>{role.userCount ?? 0}</strong>
</span>
{showRoleActions && (
<div className={panelStyles.roleHeaderActions}>
<button
type="button"
className={`${panelStyles.roleActionBtn} ${panelStyles.roleActionBtnEdit}`}
title={
systemLocked
? t('System-Rollen können nicht bearbeitet werden')
: t('Rolle bearbeiten')
}
disabled={systemLocked}
onClick={e => {
e.stopPropagation();
if (!systemLocked) setEditingRole(role);
}}
>
<MdModeEdit />
</button>
<button
type="button"
className={`${panelStyles.roleActionBtn} ${panelStyles.roleActionBtnDelete}`}
title={
systemLocked
? t('System-Rollen können nicht gelöscht werden')
: t('Rolle löschen')
}
disabled={systemLocked || deletingRoleId === role.id}
onClick={e => {
e.stopPropagation();
void handleDeleteRole(role);
}}
>
{deletingRoleId === role.id ? '⏳' : <IoIosTrash />}
</button>
</div>
)}
</div>
{isExpanded && (
<div className={adminStyles.roleContent}>
{isTemplateRole(role) && (
<div
className={`${adminStyles.infoBox} ${panelStyles.roleContentInfoBox}`}
style={{
marginBottom: '0.75rem',
background: 'var(--warning-bg, #fffbeb)',
borderColor: 'var(--warning-color, #d69e2e)',
}}
>
<FaUserShield
className={panelStyles.panelInfoBoxIcon}
style={{ marginRight: '0.5rem', color: 'var(--warning-color, #d69e2e)' }}
/>
<span className={panelStyles.roleContentInfoBoxText}>
{t('Dies ist eine')} <strong>{t('Template-Rolle')}</strong>.{' '}
{t(
'Änderungen an den Berechtigungen wirken sich nur auf neu erstellte Mandanten aus. Bestehende Mandanten-Instanzen werden nicht aktualisiert.',
)}
</span>
</div>
)}
<AccessRulesEditor
roleId={role.id}
roleName={role.roleLabel}
isTemplate={isTemplateRole(role)}
readOnly={false}
apiBasePath="/api/rbac"
mandateId={mandateId}
/>
</div>
)}
</div>
);
})}
</div>
)}
{showCreateModal && (
<Popup
isOpen={true}
title={t('Neue Rolle erstellen')}
onClose={() => !isSubmitting && setShowCreateModal(false)}
size="medium"
closable={!isSubmitting}
>
{createFields.length === 0 ? (
<div className={adminStyles.loadingContainer}>
<div className={adminStyles.spinner} />
<span>{t('Lade Formular')}</span>
</div>
) : (
<FormGeneratorForm
attributes={createFields}
mode="create"
onSubmit={handleCreateRole}
onCancel={() => setShowCreateModal(false)}
submitButtonText={isSubmitting ? t('Erstelle…') : t('Rolle erstellen')}
cancelButtonText={t('Abbrechen')}
/>
)}
</Popup>
)}
{editingRole && (
<Popup
isOpen={true}
title={`${t('Rolle bearbeiten')}: ${editingRole.roleLabel}`}
onClose={() => !isSubmitting && setEditingRole(null)}
size="medium"
closable={!isSubmitting}
>
{editFields.length === 0 ? (
<div className={adminStyles.loadingContainer}>
<div className={adminStyles.spinner} />
<span>{t('Lade Formular')}</span>
</div>
) : (
<>
<div
className={`${adminStyles.infoBox} ${panelStyles.panelInfoBox}`}
style={{ marginBottom: '1rem' }}
>
<FaUserShield className={panelStyles.panelInfoBoxIcon} style={{ marginRight: 8 }} />
<span className={panelStyles.panelInfoBoxText}>
{t('Geltungsbereich')}:{' '}
<strong>
{editingRole.mandateId ? t('Mandanten-Instanz') : t('Template (global)')}
</strong>{' '}
({t('kann nicht geändert werden')})
</span>
</div>
<FormGeneratorForm
attributes={editFields}
data={editingRole}
mode="edit"
onSubmit={handleEditRole}
onCancel={() => setEditingRole(null)}
submitButtonText={isSubmitting ? t('Speichern') : t('Speichern')}
cancelButtonText={t('Abbrechen')}
/>
</>
)}
</Popup>
)}
</div>
);
};
export default MandateRolesPermissionsPanel;

View file

@ -66,7 +66,6 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'page.admin.mandates': <FaBuilding />,
'page.admin.roles': <FaKey />,
'page.admin.role-permissions': <FaShieldAlt />,
'page.admin.mandateRolePermissions': <FaShieldAlt />,
'page.admin.user-mandates': <FaUserTag />,
'page.admin.userMandates': <FaUserTag />,
'page.admin.feature-roles': <FaCube />,

View file

@ -18,6 +18,8 @@ export interface Role {
featureCode?: string;
isSystemRole?: boolean;
isTemplate?: boolean;
scopeType?: 'system' | 'global' | 'mandate';
userCount?: number;
createdAt?: number;
updatedAt?: number;
}

View file

@ -1,603 +0,0 @@
/**
* AdminMandateRolePermissionsPage
*
* Admin page for managing access rules (permissions) for mandate-level roles.
* Similar to TrusteeInstanceRolesView but for mandate/global roles.
*
* Shows:
* - System roles (admin, user, viewer) - read-only permissions
* - Global roles (mandateId=null) - editable permissions
* - Mandate-specific roles (mandateId=xyz) - editable permissions
*
* Each role can be expanded to show/edit its AccessRules via AccessRulesEditor.
*
* Includes a "Cleanup Duplicates" tool to find and remove duplicate AccessRules.
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useMandateRoles, type Role } from '../../hooks/useMandateRoles';
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
import { AccessRulesEditor } from '../../components/AccessRules';
import api from '../../api';
import {
FaUserShield,
FaShieldAlt,
FaSync,
FaChevronDown,
FaChevronRight,
FaGlobe,
FaBuilding,
FaFilter,
FaBroom,
FaTimes,
FaExclamationTriangle,
FaCheckCircle
} from 'react-icons/fa';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
// Types for cleanup result
interface DuplicateGroup {
roleId: string;
context: string;
item: string;
totalCount: number;
keepId: string;
deleteCount: number;
deleteIds: string[];
}
interface CleanupResult {
totalRules: number;
uniqueSignatures: number;
duplicateGroups: number;
duplicateRulesToDelete: number;
deletedCount: number;
details: DuplicateGroup[];
}
interface TemplateFixDetail {
userMandateRoleId: string;
userMandateId: string;
mandateId: string;
templateRoleId: string;
templateRoleLabel: string;
instanceRoleId?: string;
action: string;
}
interface TemplateFixResult {
totalUserMandateRoles: number;
invalidAssignments: number;
fixedCount: number;
details: TemplateFixDetail[];
}
export const AdminMandateRolePermissionsPage: React.FC = () => {
const { t } = useLanguage();
const {
roles,
loading,
error,
fetchRoles,
} = useMandateRoles();
const { fetchMandates } = useUserMandates();
// State
const [mandates, setMandates] = useState<Mandate[]>([]);
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('mandate');
const [expandedRoleId, setExpandedRoleId] = useState<string | null>(null);
// Cleanup state
const [showCleanupModal, setShowCleanupModal] = useState(false);
const [cleanupLoading, setCleanupLoading] = useState(false);
const [cleanupResult, setCleanupResult] = useState<CleanupResult | null>(null);
const [templateFixResult, setTemplateFixResult] = useState<TemplateFixResult | null>(null);
const [cleanupError, setCleanupError] = useState<string | null>(null);
const [cleanupPhase, setCleanupPhase] = useState<'idle' | 'preview' | 'done'>('idle');
// Load mandates on mount
useEffect(() => {
const loadMandates = async () => {
const data = await fetchMandates();
setMandates(data);
if (data.length > 0 && !selectedMandateId) {
setSelectedMandateId(data[0].id);
}
};
loadMandates();
}, [fetchMandates]);
// Load roles when mandate or scopeFilter changes
useEffect(() => {
if (selectedMandateId) {
fetchRoles(selectedMandateId, { scopeFilter });
}
}, [selectedMandateId, scopeFilter, fetchRoles]);
// Refetch roles
const handleRefresh = useCallback(async () => {
if (selectedMandateId) {
await fetchRoles(selectedMandateId, { scopeFilter });
}
}, [selectedMandateId, scopeFilter, fetchRoles]);
const getTextValue = (value: string | Record<string, string> | undefined): string => {
if (!value) return '';
if (typeof value === 'string') return value;
return Object.values(value).find(v => !!v) || '';
};
// Toggle role expansion
const toggleRole = (roleId: string) => {
setExpandedRoleId(prev => prev === roleId ? null : roleId);
};
// Check if a role is a template (not bound to a specific mandate)
const _isTemplateRole = (role: Role): boolean => {
return !!role.isSystemRole || !role.mandateId;
};
// Get scope badge
const getScopeBadge = (role: Role) => {
if (role.isSystemRole) {
return (
<span className={styles.badge} style={{ background: 'var(--warning-color, #d69e2e)', color: 'white' }}>
<FaUserShield style={{ marginRight: 4 }} /> {t('System-Template')}
</span>
);
}
if (!role.mandateId) {
return (
<span className={styles.badge} style={{ background: 'var(--info-color, #3182ce)', color: 'white' }}>
<FaGlobe style={{ marginRight: 4 }} /> {t('Template')}
</span>
);
}
return (
<span className={styles.badge} style={{ background: 'var(--success-color, #38a169)', color: 'white' }}>
<FaBuilding style={{ marginRight: 4 }} /> {t('Mandant')}
</span>
);
};
// --- Cleanup functions ---
const _openCleanupModal = useCallback(async () => {
setShowCleanupModal(true);
setCleanupError(null);
setCleanupResult(null);
setTemplateFixResult(null);
setCleanupPhase('idle');
setCleanupLoading(true);
try {
const response = await api.post('/api/rbac/cleanup/duplicate-rules?dryRun=true');
const data = response.data;
setCleanupResult(data.duplicateRules || data);
setTemplateFixResult(data.templateRoleAssignments || null);
setCleanupPhase('preview');
} catch (err: any) {
setCleanupError(err?.response?.data?.detail || err?.message || t('Fehler beim Laden der Duplikate'));
} finally {
setCleanupLoading(false);
}
}, [t]);
const _executeCleanup = useCallback(async () => {
setCleanupLoading(true);
setCleanupError(null);
try {
const response = await api.post('/api/rbac/cleanup/duplicate-rules?dryRun=false');
const data = response.data;
setCleanupResult(data.duplicateRules || data);
setTemplateFixResult(data.templateRoleAssignments || null);
setCleanupPhase('done');
// Refresh roles after cleanup
if (selectedMandateId) {
fetchRoles(selectedMandateId, { scopeFilter });
}
} catch (err: any) {
setCleanupError(err?.response?.data?.detail || err?.message || t('Fehler beim Bereinigen'));
} finally {
setCleanupLoading(false);
}
}, [selectedMandateId, scopeFilter, fetchRoles, t]);
const _closeCleanupModal = useCallback(() => {
setShowCleanupModal(false);
setCleanupResult(null);
setCleanupError(null);
setCleanupPhase('idle');
}, []);
// Filter options for scope
const scopeOptions = useMemo(() => [
{ value: 'mandate', label: t('Mandanten-Rollen') },
{ value: 'all', label: t('Alle inkl. Templates') },
{ value: 'global', label: t('Nur Templates') },
], [t]);
if (error) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>
{t('Fehler beim Laden')}: {error}
</p>
<button className={styles.secondaryButton} onClick={handleRefresh}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</div>
);
}
return (
<div className={styles.adminPage}>
{/* Header */}
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>
<FaShieldAlt style={{ marginRight: '0.5rem' }} />
{t('Rollen-Berechtigungen')}
</h1>
<p className={styles.pageSubtitle}>
{t('Verwalten Sie die Zugriffsrechte für Mandanten- und globale Rollen')}
</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={_openCleanupModal}
disabled={loading}
title={t('Doppelte Regeln finden und bereinigen')}
>
<FaBroom /> {t('Duplikate bereinigen')}
</button>
<button
className={styles.secondaryButton}
onClick={handleRefresh}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
</div>
</div>
{/* Filters */}
<div className={styles.filterBar}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>{t('Mandant')}</label>
<select
className={styles.filterSelect}
value={selectedMandateId}
onChange={(e) => setSelectedMandateId(e.target.value)}
>
{mandates.map(mandate => (
<option key={mandate.id} value={mandate.id}>
{mandateDisplayLabel(mandate)}
</option>
))}
</select>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaFilter style={{ marginRight: 4 }} /> {t('Bereich')}:
</label>
<select
className={styles.filterSelect}
value={scopeFilter}
onChange={(e) => setScopeFilter(e.target.value as 'all' | 'mandate' | 'global')}
>
{scopeOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
{/* Info Box */}
<div className={styles.infoBox}>
<FaShieldAlt style={{ marginRight: '0.5rem' }} />
<span>
{t('Klicken Sie auf eine Rolle, um deren Berechtigungen (AccessRules) zu bearbeiten.')}{' '}
<strong>{t('Template-Rollen')}</strong>{' '}
{t('sind schreibgeschützt Änderungen an Templates wirken sich nur auf neu erstellte Mandanten aus.')}{' '}
<strong>{t('Mandanten-Rollen')}</strong> {t('sind direkt bearbeitbar.')}
</span>
</div>
{/* Loading State */}
{loading && (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('Lade Rollen')}</span>
</div>
)}
{/* Empty State */}
{!loading && roles.length === 0 && (
<div className={styles.emptyState}>
<FaUserShield className={styles.emptyIcon} />
<p>{t('Keine Rollen gefunden')}</p>
<p className={styles.emptyHint}>
{scopeFilter === 'mandate'
? t('Es gibt noch keine Mandanten-Rollen. System-Rollen werden bei der Mandant-Erstellung automatisch kopiert.')
: scopeFilter === 'global'
? t('Es gibt noch keine Rollentemplates')
: t('Es gibt noch keine Rollen')}
</p>
</div>
)}
{/* Roles List */}
{!loading && roles.length > 0 && (
<div className={styles.rolesList}>
{roles.map(role => (
<div key={role.id} className={styles.roleCard}>
{/* Role Header - Clickable to expand */}
<div
className={styles.roleHeader}
onClick={() => toggleRole(role.id)}
>
<div className={styles.roleInfo}>
<span className={styles.expandIcon}>
{expandedRoleId === role.id ? <FaChevronDown /> : <FaChevronRight />}
</span>
<span className={styles.roleLabel}>{role.roleLabel}</span>
<span className={styles.roleDescription}>
{getTextValue(role.description)}
</span>
</div>
<div className={styles.roleBadges}>
{getScopeBadge(role)}
</div>
</div>
{/* Expanded Content - AccessRulesEditor */}
{expandedRoleId === role.id && (
<div className={styles.roleContent}>
{_isTemplateRole(role) && (
<div className={styles.infoBox} style={{ marginBottom: '0.75rem', background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
<FaUserShield style={{ marginRight: '0.5rem', color: 'var(--warning-color, #d69e2e)' }} />
<span>
{t('Dies ist eine')} <strong>{t('Template-Rolle')}</strong>.{' '}
{t('Änderungen an den Berechtigungen wirken sich nur auf neu erstellte Mandanten aus. Bestehende Mandanten-Instanzen werden nicht aktualisiert.')}
</span>
</div>
)}
<AccessRulesEditor
roleId={role.id}
roleName={role.roleLabel}
isTemplate={_isTemplateRole(role)}
readOnly={false}
apiBasePath="/api/rbac"
mandateId={selectedMandateId}
/>
</div>
)}
</div>
))}
</div>
)}
{/* Cleanup Duplicates Modal */}
{showCleanupModal && (
<div className={styles.modalOverlay}>
<div className={styles.modal} style={{ maxWidth: '750px' }}>
<div className={styles.modalHeader}>
<h3 className={styles.modalTitle}>
<FaBroom style={{ marginRight: '0.5rem' }} />
{t('Doppelte Regeln bereinigen')}
</h3>
<button className={styles.modalClose} onClick={_closeCleanupModal}>
<FaTimes />
</button>
</div>
<div className={styles.modalContent}>
{/* Loading */}
{cleanupLoading && (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{cleanupPhase === 'idle' ? t('Analysiere Duplikate') : t('Bereinige Duplikate')}</span>
</div>
)}
{/* Error */}
{cleanupError && (
<div style={{ padding: '1rem', background: '#fed7d7', borderRadius: '6px', color: '#c53030', marginBottom: '1rem' }}>
<FaExclamationTriangle style={{ marginRight: '0.5rem' }} />
{cleanupError}
</div>
)}
{/* Results */}
{cleanupResult && !cleanupLoading && (
<>
{/* Summary Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '0.75rem', marginBottom: '1.25rem' }}>
<div style={{ padding: '0.75rem', background: 'var(--bg-secondary)', borderRadius: '8px', textAlign: 'center', border: '1px solid var(--border-color)' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text-primary)' }}>{cleanupResult.totalRules}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{t('Regeln total')}</div>
</div>
<div style={{ padding: '0.75rem', background: 'var(--bg-secondary)', borderRadius: '8px', textAlign: 'center', border: '1px solid var(--border-color)' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text-primary)' }}>{cleanupResult.uniqueSignatures}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{t('Eindeutige Regeln')}</div>
</div>
<div style={{ padding: '0.75rem', background: cleanupResult.duplicateGroups > 0 ? '#fff5f5' : '#f0fff4', borderRadius: '8px', textAlign: 'center', border: `1px solid ${cleanupResult.duplicateGroups > 0 ? '#fc8181' : '#9ae6b4'}` }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: cleanupResult.duplicateGroups > 0 ? '#c53030' : '#2f855a' }}>{cleanupResult.duplicateGroups}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{t('Duplikat-Gruppen')}</div>
</div>
<div style={{ padding: '0.75rem', background: cleanupResult.duplicateRulesToDelete > 0 ? '#fff5f5' : '#f0fff4', borderRadius: '8px', textAlign: 'center', border: `1px solid ${cleanupResult.duplicateRulesToDelete > 0 ? '#fc8181' : '#9ae6b4'}` }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: cleanupResult.duplicateRulesToDelete > 0 ? '#c53030' : '#2f855a' }}>
{cleanupPhase === 'done' ? cleanupResult.deletedCount : cleanupResult.duplicateRulesToDelete}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>
{cleanupPhase === 'done' ? t('Gelöscht') : t('Zu löschen')}
</div>
</div>
</div>
{/* Status Message */}
{cleanupPhase === 'done' && (
<div style={{ padding: '0.75rem 1rem', background: '#f0fff4', borderRadius: '6px', color: '#2f855a', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem', border: '1px solid #9ae6b4' }}>
<FaCheckCircle />
<span><strong>{cleanupResult.deletedCount}</strong> {t('Doppelte Regeln wurden erfolgreich entfernt')}</span>
</div>
)}
{cleanupPhase === 'preview' && cleanupResult.duplicateGroups === 0 && (
<div style={{ padding: '0.75rem 1rem', background: '#f0fff4', borderRadius: '6px', color: '#2f855a', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem', border: '1px solid #9ae6b4' }}>
<FaCheckCircle />
<span>{t('Keine Duplikate gefunden, alles sauber')}</span>
</div>
)}
{/* Details Table */}
{cleanupResult.details && cleanupResult.details.length > 0 && (
<div style={{ marginTop: '0.5rem' }}>
<h4 style={{ fontSize: '0.875rem', fontWeight: 600, marginBottom: '0.5rem', color: 'var(--text-secondary)' }}>
{t('Duplikat-Details')}{' '}
{cleanupResult.details.length < cleanupResult.duplicateGroups &&
`(${cleanupResult.details.length} ${t('von')} ${cleanupResult.duplicateGroups})`}
</h4>
<div style={{ maxHeight: '300px', overflowY: 'auto', border: '1px solid var(--border-color)', borderRadius: '6px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8125rem' }}>
<thead>
<tr style={{ background: 'var(--bg-secondary)', position: 'sticky', top: 0 }}>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'left', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>{t('Kontext')}</th>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'left', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>{t('Item')}</th>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'center', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>{t('Total')}</th>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'center', fontWeight: 600, borderBottom: '1px solid var(--border-color)', color: '#c53030' }}>{t('Duplikate')}</th>
</tr>
</thead>
<tbody>
{cleanupResult.details.map((group, idx) => (
<tr key={idx} style={{ borderBottom: '1px solid var(--border-color)' }}>
<td style={{ padding: '0.375rem 0.75rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', background: 'var(--bg-tertiary)', padding: '0.125rem 0.375rem', borderRadius: '3px' }}>
{group.context}
</span>
</td>
<td style={{ padding: '0.375rem 0.75rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', background: 'var(--bg-tertiary)', padding: '0.125rem 0.375rem', borderRadius: '3px' }}>
{group.item}
</span>
</td>
<td style={{ padding: '0.375rem 0.75rem', textAlign: 'center' }}>{group.totalCount}</td>
<td style={{ padding: '0.375rem 0.75rem', textAlign: 'center', color: '#c53030', fontWeight: 600 }}>{group.deleteCount}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Template Role Assignments Section */}
{templateFixResult && templateFixResult.invalidAssignments > 0 && (
<div style={{ marginTop: '1.25rem', borderTop: '1px solid var(--border-color)', paddingTop: '1rem' }}>
<h4 style={{ fontSize: '0.9375rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text-primary)' }}>
{t('Template-Rollen-Zuweisungen')}
</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '0.75rem', marginBottom: '1rem' }}>
<div style={{ padding: '0.75rem', background: 'var(--bg-secondary)', borderRadius: '8px', textAlign: 'center', border: '1px solid var(--border-color)' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text-primary)' }}>{templateFixResult.totalUserMandateRoles}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{t('Rollenzuweisungen total')}</div>
</div>
<div style={{ padding: '0.75rem', background: templateFixResult.invalidAssignments > 0 ? '#fff5f5' : '#f0fff4', borderRadius: '8px', textAlign: 'center', border: `1px solid ${templateFixResult.invalidAssignments > 0 ? '#fc8181' : '#9ae6b4'}` }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: templateFixResult.invalidAssignments > 0 ? '#c53030' : '#2f855a' }}>{templateFixResult.invalidAssignments}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{t('Template statt Instanz')}</div>
</div>
<div style={{ padding: '0.75rem', background: 'var(--bg-secondary)', borderRadius: '8px', textAlign: 'center', border: '1px solid var(--border-color)' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: templateFixResult.fixedCount > 0 ? '#2f855a' : 'var(--text-primary)' }}>
{cleanupPhase === 'done' ? templateFixResult.fixedCount : templateFixResult.invalidAssignments}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>
{cleanupPhase === 'done' ? t('Repariert') : t('Zu reparieren')}
</div>
</div>
</div>
{templateFixResult.details && templateFixResult.details.length > 0 && (
<div style={{ maxHeight: '200px', overflowY: 'auto', border: '1px solid var(--border-color)', borderRadius: '6px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8125rem' }}>
<thead>
<tr style={{ background: 'var(--bg-secondary)', position: 'sticky', top: 0 }}>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'left', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>{t('Rolle')}</th>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'left', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>{t('Mandant')}</th>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'center', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>{t('Aktion')}</th>
</tr>
</thead>
<tbody>
{templateFixResult.details.map((detail, idx) => (
<tr key={idx} style={{ borderBottom: '1px solid var(--border-color)' }}>
<td style={{ padding: '0.375rem 0.75rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', background: 'var(--bg-tertiary)', padding: '0.125rem 0.375rem', borderRadius: '3px' }}>
{detail.templateRoleLabel}
</span>
</td>
<td style={{ padding: '0.375rem 0.75rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', background: 'var(--bg-tertiary)', padding: '0.125rem 0.375rem', borderRadius: '3px' }}>
{detail.mandateId.substring(0, 8)}...
</span>
</td>
<td style={{ padding: '0.375rem 0.75rem', textAlign: 'center' }}>
<span style={{
fontSize: '0.75rem',
padding: '0.125rem 0.5rem',
borderRadius: '10px',
background: detail.action.includes('replace') ? '#ebf8ff' : detail.action.includes('delete') ? '#fff5f5' : '#f7fafc',
color: detail.action.includes('replace') ? '#2b6cb0' : detail.action.includes('delete') ? '#c53030' : '#718096',
}}>
{detail.action}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{templateFixResult && templateFixResult.invalidAssignments === 0 && (
<div style={{ marginTop: '1rem', padding: '0.5rem 0.75rem', background: '#f0fff4', borderRadius: '6px', color: '#2f855a', fontSize: '0.875rem', display: 'flex', alignItems: 'center', gap: '0.5rem', border: '1px solid #9ae6b4' }}>
<FaCheckCircle />
<span>{t('Keine fehlerhaften Templaterollenzuweisungen')}</span>
</div>
)}
</>
)}
</div>
<div className={styles.modalFooter}>
<button className={styles.secondaryButton} onClick={_closeCleanupModal}>
{cleanupPhase === 'done' ? t('Schließen') : t('Abbrechen')}
</button>
{cleanupPhase === 'preview' && cleanupResult && (cleanupResult.duplicateRulesToDelete > 0 || (templateFixResult && templateFixResult.invalidAssignments > 0)) && (
<button
className={styles.dangerButton}
onClick={_executeCleanup}
disabled={cleanupLoading}
>
<FaBroom /> {t('Bereinigung ausführen')}
</button>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default AdminMandateRolePermissionsPage;

View file

@ -19,7 +19,7 @@ import { useMandateRoles, type Role, type RoleCreate, type RoleUpdate, type Pagi
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaUserShield, FaBuilding, FaShieldAlt, FaCube } from 'react-icons/fa';
import { FaPlus, FaSync, FaUserShield, FaBuilding, FaCube } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import { useApiRequest } from '../../hooks/useApi';
import { fetchAttributes } from '../../api/attributesApi';
@ -267,13 +267,6 @@ export const AdminMandateRolesPage: React.FC = () => {
<p className={styles.pageSubtitle}>{t('Verwalten Sie systemweite und globale')}</p>
</div>
<div className={styles.headerActions}>
<button
type="button"
className={styles.secondaryButton}
onClick={() => navigate('/admin/mandate-role-permissions')}
>
<FaShieldAlt /> {t('Rollen-Berechtigungen')}
</button>
<button
type="button"
className={styles.secondaryButton}

View file

@ -251,6 +251,10 @@ export const AdminMandatesPage: React.FC = () => {
onAction: handleEditClick,
title: t('Bearbeiten'),
}] : []),
...(canUpdate ? [{
type: 'roles' as const,
title: t('Rollen & Berechtigungen'),
}] : []),
...(canDelete ? [{
type: 'delete' as const,
title: t('Soft-Löschen deaktivieren'),

View file

@ -13,7 +13,6 @@ export { AdminInvitationsPage } from './AdminInvitationsPage';
export { AdminMandateRolesPage } from './AdminMandateRolesPage';
export { AdminFeatureRolesPage } from './AdminFeatureRolesPage';
export { AdminFeatureInstanceUsersPage } from './AdminFeatureInstanceUsersPage';
export { AdminMandateRolePermissionsPage } from './AdminMandateRolePermissionsPage';
export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage';
export { AdminLogsPage } from './AdminLogsPage';
export { AdminLanguagesPage } from './AdminLanguagesPage';