rbac rules tested and fixed
This commit is contained in:
parent
6a406d885d
commit
bf4ddc6fd5
22 changed files with 1526 additions and 344 deletions
32
src/api.ts
32
src/api.ts
|
|
@ -20,6 +20,24 @@ const resolveHostnameToIP = async (hostname: string): Promise<string | null> =>
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract mandate/instance context from current URL
|
||||||
|
* URL pattern: /mandates/:mandateId/:featureCode/:instanceId/...
|
||||||
|
*/
|
||||||
|
const getContextFromUrl = (): { mandateId?: string; instanceId?: string } => {
|
||||||
|
const pathname = window.location.pathname;
|
||||||
|
const match = pathname.match(/^\/mandates\/([^/]+)\/([^/]+)\/([^/]+)/);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
mandateId: match[1],
|
||||||
|
instanceId: match[3]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
import { getApiBaseUrl } from '../config/config';
|
import { getApiBaseUrl } from '../config/config';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
|
|
@ -27,7 +45,7 @@ const api = axios.create({
|
||||||
withCredentials: true
|
withCredentials: true
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add a request interceptor to add the auth token and log backend IP
|
// Add a request interceptor to add the auth token, context headers, and log backend IP
|
||||||
api.interceptors.request.use(
|
api.interceptors.request.use(
|
||||||
async (config) => {
|
async (config) => {
|
||||||
// Log backend information
|
// Log backend information
|
||||||
|
|
@ -63,6 +81,18 @@ api.interceptors.request.use(
|
||||||
console.log('🍪 Using httpOnly cookies for authentication (automatic)');
|
console.log('🍪 Using httpOnly cookies for authentication (automatic)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add multi-tenant context headers from URL (if not already set)
|
||||||
|
// This ensures Feature-Instance roles are loaded for permission checks
|
||||||
|
const context = getContextFromUrl();
|
||||||
|
if (config.headers) {
|
||||||
|
if (context.mandateId && !config.headers['X-Mandate-Id']) {
|
||||||
|
config.headers['X-Mandate-Id'] = context.mandateId;
|
||||||
|
}
|
||||||
|
if (context.instanceId && !config.headers['X-Instance-Id']) {
|
||||||
|
config.headers['X-Instance-Id'] = context.instanceId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add CSRF token to all requests (including GET requests for certain endpoints)
|
// Add CSRF token to all requests (including GET requests for certain endpoints)
|
||||||
// Some endpoints like /api/realestate/* require CSRF tokens even for GET requests
|
// Some endpoints like /api/realestate/* require CSRF tokens even for GET requests
|
||||||
const method = config.method?.toLowerCase();
|
const method = config.method?.toLowerCase();
|
||||||
|
|
|
||||||
|
|
@ -725,6 +725,19 @@ export async function createPositionDocument(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updatePositionDocument(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
linkId: string,
|
||||||
|
data: Partial<TrusteePositionDocument>
|
||||||
|
): Promise<TrusteePositionDocument> {
|
||||||
|
return await request({
|
||||||
|
url: `${_getTrusteeBaseUrl(instanceId)}/position-documents/${linkId}`,
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function deletePositionDocument(
|
export async function deletePositionDocument(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
|
|
|
||||||
|
|
@ -533,3 +533,260 @@
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
* Access Rules Table (Checkbox Matrix)
|
||||||
|
* ============================================================================= */
|
||||||
|
|
||||||
|
.tableWrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0 -0.5rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accessRulesTable {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
min-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accessRulesTable th,
|
||||||
|
.accessRulesTable td {
|
||||||
|
padding: 0.5rem 0.375rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accessRulesTable th {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accessRulesTable tbody tr:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.colObject {
|
||||||
|
text-align: left !important;
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colView {
|
||||||
|
width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colGroupHeader {
|
||||||
|
border-left: 2px solid var(--border-color);
|
||||||
|
background: var(--bg-tertiary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colGroupHeader:nth-of-type(3) {
|
||||||
|
background: rgba(72, 187, 120, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colGroupHeader:nth-of-type(4) {
|
||||||
|
background: rgba(66, 153, 225, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colGroupHeader:nth-of-type(5) {
|
||||||
|
background: rgba(237, 100, 166, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subHeader th {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
padding: 0.25rem 0.375rem;
|
||||||
|
background: var(--bg-primary) !important;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subHeader th:nth-child(n+3):nth-child(-n+6) {
|
||||||
|
background: rgba(72, 187, 120, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subHeader th:nth-child(n+7):nth-child(-n+10) {
|
||||||
|
background: rgba(66, 153, 225, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subHeader th:nth-child(n+11):nth-child(-n+14) {
|
||||||
|
background: rgba(237, 100, 166, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.objectCell {
|
||||||
|
text-align: left !important;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.objectIcon {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.objectCode {
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxCell {
|
||||||
|
width: 32px;
|
||||||
|
padding: 0.375rem 0.25rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxCell input[type="checkbox"] {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--primary-color);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxCell input[type="checkbox"]:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsCell {
|
||||||
|
width: 40px;
|
||||||
|
padding: 0.375rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ruleRow td {
|
||||||
|
padding: 0.5rem 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ruleRow td:nth-child(n+3):nth-child(-n+6) {
|
||||||
|
background: rgba(72, 187, 120, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ruleRow td:nth-child(n+7):nth-child(-n+10) {
|
||||||
|
background: rgba(66, 153, 225, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ruleRow td:nth-child(n+11):nth-child(-n+14) {
|
||||||
|
background: rgba(237, 100, 166, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle between Card and Table View */
|
||||||
|
.viewToggleButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewToggleButton:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewToggleButton.active {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Object Selector */
|
||||||
|
.objectSelector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.objectSelectorLabel {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleCustomButton {
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleCustomButton:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add Rule Matrix (Checkbox Style) */
|
||||||
|
.addRuleMatrix {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrixHeader {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 80px repeat(3, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrixGroup {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrixRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 80px repeat(3, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrixLabel {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrixCell {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrixCell input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,14 @@
|
||||||
*
|
*
|
||||||
* Main component for editing RBAC access rules for a role.
|
* Main component for editing RBAC access rules for a role.
|
||||||
* Provides tabbed interface for DATA, UI, and RESOURCE rules.
|
* Provides tabbed interface for DATA, UI, and RESOURCE rules.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Checkbox-based compact table for DATA rules
|
||||||
|
* - Card view for UI/RESOURCE rules
|
||||||
|
* - Object catalog dropdown for adding new rules
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
FaTable,
|
FaTable,
|
||||||
FaDesktop,
|
FaDesktop,
|
||||||
|
|
@ -16,6 +21,8 @@ import {
|
||||||
FaSave,
|
FaSave,
|
||||||
FaUndo,
|
FaUndo,
|
||||||
FaSpinner,
|
FaSpinner,
|
||||||
|
FaThList,
|
||||||
|
FaTh,
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
import {
|
import {
|
||||||
useAccessRules,
|
useAccessRules,
|
||||||
|
|
@ -24,7 +31,9 @@ import {
|
||||||
type AccessLevel,
|
type AccessLevel,
|
||||||
type AccessRuleCreate,
|
type AccessRuleCreate,
|
||||||
} from '../../hooks/useAccessRules';
|
} from '../../hooks/useAccessRules';
|
||||||
|
import { useCatalogObjects, type CatalogObject } from '../../hooks/useCatalogObjects';
|
||||||
import { AccessLevelSelect } from './AccessLevelSelect';
|
import { AccessLevelSelect } from './AccessLevelSelect';
|
||||||
|
import { AccessRulesTable } from './AccessRulesTable';
|
||||||
import styles from './AccessRules.module.css';
|
import styles from './AccessRules.module.css';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -39,6 +48,7 @@ interface AccessRulesEditorProps {
|
||||||
onSave?: () => void;
|
onSave?: () => void;
|
||||||
apiBasePath?: string;
|
apiBasePath?: string;
|
||||||
mandateId?: string;
|
mandateId?: string;
|
||||||
|
featureCode?: string; // Filter catalog objects to this feature only
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabType = 'DATA' | 'UI' | 'RESOURCE' | 'JSON';
|
type TabType = 'DATA' | 'UI' | 'RESOURCE' | 'JSON';
|
||||||
|
|
@ -150,18 +160,32 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
|
||||||
|
|
||||||
interface AddRuleFormProps {
|
interface AddRuleFormProps {
|
||||||
context: RuleContext;
|
context: RuleContext;
|
||||||
|
availableObjects: CatalogObject[];
|
||||||
onAdd: (rule: AccessRuleCreate) => void;
|
onAdd: (rule: AccessRuleCreate) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, onAdd, onCancel }) => {
|
const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, onAdd, onCancel }) => {
|
||||||
const [item, setItem] = useState('');
|
const [item, setItem] = useState('');
|
||||||
|
const [useCustom, setUseCustom] = useState(false);
|
||||||
const [view, setView] = useState(true);
|
const [view, setView] = useState(true);
|
||||||
const [read, setRead] = useState<AccessLevel>('n');
|
const [read, setRead] = useState<AccessLevel>('n');
|
||||||
const [create, setCreate] = useState<AccessLevel>('n');
|
const [create, setCreate] = useState<AccessLevel>('n');
|
||||||
const [update, setUpdate] = useState<AccessLevel>('n');
|
const [update, setUpdate] = useState<AccessLevel>('n');
|
||||||
const [del, setDel] = useState<AccessLevel>('n');
|
const [del, setDel] = useState<AccessLevel>('n');
|
||||||
|
|
||||||
|
// Group objects by feature
|
||||||
|
const groupedObjects = useMemo(() => {
|
||||||
|
const grouped: Record<string, CatalogObject[]> = {};
|
||||||
|
availableObjects.forEach(obj => {
|
||||||
|
if (!grouped[obj.featureCode]) {
|
||||||
|
grouped[obj.featureCode] = [];
|
||||||
|
}
|
||||||
|
grouped[obj.featureCode].push(obj);
|
||||||
|
});
|
||||||
|
return grouped;
|
||||||
|
}, [availableObjects]);
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const newRule: AccessRuleCreate = {
|
const newRule: AccessRuleCreate = {
|
||||||
|
|
@ -176,18 +200,33 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, onAdd, onCancel }) =
|
||||||
const getPlaceholder = () => {
|
const getPlaceholder = () => {
|
||||||
switch (context) {
|
switch (context) {
|
||||||
case 'DATA':
|
case 'DATA':
|
||||||
return 'z.B. TrusteeContract oder TrusteeContract.salary';
|
return 'z.B. data.feature.trustee.TrusteePosition';
|
||||||
case 'UI':
|
case 'UI':
|
||||||
return 'z.B. nav.trustee oder button.export';
|
return 'z.B. ui.feature.trustee.dashboard';
|
||||||
case 'RESOURCE':
|
case 'RESOURCE':
|
||||||
return 'z.B. ai.model.anthropic oder connector.sharepoint';
|
return 'z.B. resource.feature.trustee.documents.create';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getLabel = (obj: CatalogObject): string => {
|
||||||
|
return obj.label.de || obj.label.en || obj.objectKey;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className={styles.addRuleForm} onSubmit={handleSubmit}>
|
<form className={styles.addRuleForm} onSubmit={handleSubmit}>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label className={styles.formLabel}>Item (Dot-Notation)</label>
|
<div className={styles.objectSelectorLabel}>
|
||||||
|
<label className={styles.formLabel}>Objekt auswählen</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.toggleCustomButton}
|
||||||
|
onClick={() => setUseCustom(!useCustom)}
|
||||||
|
>
|
||||||
|
{useCustom ? '← Aus Katalog wählen' : 'Freie Eingabe →'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{useCustom ? (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={item}
|
value={item}
|
||||||
|
|
@ -196,8 +235,27 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, onAdd, onCancel }) =
|
||||||
className={styles.formInput}
|
className={styles.formInput}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={item}
|
||||||
|
onChange={(e) => setItem(e.target.value)}
|
||||||
|
className={styles.formSelect}
|
||||||
|
>
|
||||||
|
<option value="">-- Global (alle Objekte) --</option>
|
||||||
|
{Object.entries(groupedObjects).map(([feature, objs]) => (
|
||||||
|
<optgroup key={feature} label={feature.toUpperCase()}>
|
||||||
|
{objs.map(obj => (
|
||||||
|
<option key={obj.objectKey} value={obj.objectKey}>
|
||||||
|
{obj.objectKey} - {getLabel(obj)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
<span className={styles.formHint}>
|
<span className={styles.formHint}>
|
||||||
Leer lassen für globale Regel. Längster Match gewinnt.
|
Leer lassen für globale Regel. Längster Match gewinnt bei Wildcards (z.B. data.feature.trustee.*).
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -214,23 +272,46 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, onAdd, onCancel }) =
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{context === 'DATA' && (
|
{context === 'DATA' && (
|
||||||
<div className={styles.permissionsGrid} style={{ marginTop: '0.5rem' }}>
|
<div className={styles.addRuleMatrix}>
|
||||||
<div className={styles.permissionItem}>
|
{/* Header Row */}
|
||||||
<span className={styles.permissionLabel}>Read</span>
|
<div className={styles.matrixHeader}>
|
||||||
<AccessLevelSelect value={read} onChange={setRead} compact />
|
<div className={styles.matrixLabel}></div>
|
||||||
|
<div className={styles.matrixGroup}>Eigene (m)</div>
|
||||||
|
<div className={styles.matrixGroup}>Gruppe (g)</div>
|
||||||
|
<div className={styles.matrixGroup}>Alle (a)</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.permissionItem}>
|
|
||||||
<span className={styles.permissionLabel}>Create</span>
|
{/* CRUD Rows */}
|
||||||
<AccessLevelSelect value={create} onChange={setCreate} compact />
|
{(['create', 'read', 'update', 'delete'] as const).map(op => {
|
||||||
|
const value = op === 'delete' ? del : op === 'create' ? create : op === 'update' ? update : read;
|
||||||
|
const setValue = op === 'delete' ? setDel : op === 'create' ? setCreate : op === 'update' ? setUpdate : setRead;
|
||||||
|
const labels = { create: 'Create', read: 'Read', update: 'Update', delete: 'Delete' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={op} className={styles.matrixRow}>
|
||||||
|
<div className={styles.matrixLabel}>{labels[op]}</div>
|
||||||
|
{(['m', 'g', 'a'] as const).map(level => (
|
||||||
|
<div key={level} className={styles.matrixCell}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value === level || (level === 'm' && (value === 'g' || value === 'a')) || (level === 'g' && value === 'a')}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setValue(level);
|
||||||
|
} else {
|
||||||
|
// Deactivate: set to level below
|
||||||
|
const hierarchy: AccessLevel[] = ['n', 'm', 'g', 'a'];
|
||||||
|
const idx = hierarchy.indexOf(level);
|
||||||
|
setValue(hierarchy[idx - 1] || 'n');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={`${labels[op]} - ${level === 'm' ? 'Eigene' : level === 'g' ? 'Gruppe' : 'Alle'}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.permissionItem}>
|
))}
|
||||||
<span className={styles.permissionLabel}>Update</span>
|
|
||||||
<AccessLevelSelect value={update} onChange={setUpdate} compact />
|
|
||||||
</div>
|
|
||||||
<div className={styles.permissionItem}>
|
|
||||||
<span className={styles.permissionLabel}>Delete</span>
|
|
||||||
<AccessLevelSelect value={del} onChange={setDel} compact />
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -253,6 +334,7 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, onAdd, onCancel }) =
|
||||||
interface RulesSectionProps {
|
interface RulesSectionProps {
|
||||||
context: RuleContext;
|
context: RuleContext;
|
||||||
rules: AccessRule[];
|
rules: AccessRule[];
|
||||||
|
availableObjects: CatalogObject[];
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
onUpdate: (ruleId: string, updates: Partial<AccessRule>) => void;
|
onUpdate: (ruleId: string, updates: Partial<AccessRule>) => void;
|
||||||
onDelete: (ruleId: string) => void;
|
onDelete: (ruleId: string) => void;
|
||||||
|
|
@ -262,12 +344,14 @@ interface RulesSectionProps {
|
||||||
const RulesSection: React.FC<RulesSectionProps> = ({
|
const RulesSection: React.FC<RulesSectionProps> = ({
|
||||||
context,
|
context,
|
||||||
rules,
|
rules,
|
||||||
|
availableObjects,
|
||||||
readOnly,
|
readOnly,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onDelete,
|
onDelete,
|
||||||
onAdd,
|
onAdd,
|
||||||
}) => {
|
}) => {
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
const [useTableView, setUseTableView] = useState(context === 'DATA'); // Default to table for DATA
|
||||||
|
|
||||||
const handleAdd = (rule: AccessRuleCreate) => {
|
const handleAdd = (rule: AccessRuleCreate) => {
|
||||||
onAdd(rule);
|
onAdd(rule);
|
||||||
|
|
@ -297,6 +381,26 @@ const RulesSection: React.FC<RulesSectionProps> = ({
|
||||||
<span className={styles.sectionTitle}>
|
<span className={styles.sectionTitle}>
|
||||||
{rules.length} {rules.length === 1 ? 'Regel' : 'Regeln'}
|
{rules.length} {rules.length === 1 ? 'Regel' : 'Regeln'}
|
||||||
</span>
|
</span>
|
||||||
|
<div className={styles.headerActions}>
|
||||||
|
{/* View Toggle */}
|
||||||
|
{context === 'DATA' && rules.length > 0 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className={`${styles.viewToggleButton} ${useTableView ? styles.active : ''}`}
|
||||||
|
onClick={() => setUseTableView(true)}
|
||||||
|
title="Tabellenansicht"
|
||||||
|
>
|
||||||
|
<FaThList />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.viewToggleButton} ${!useTableView ? styles.active : ''}`}
|
||||||
|
onClick={() => setUseTableView(false)}
|
||||||
|
title="Kartenansicht"
|
||||||
|
>
|
||||||
|
<FaTh />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className={styles.addButton}
|
className={styles.addButton}
|
||||||
onClick={() => setShowAddForm(true)}
|
onClick={() => setShowAddForm(true)}
|
||||||
|
|
@ -304,11 +408,13 @@ const RulesSection: React.FC<RulesSectionProps> = ({
|
||||||
<FaPlus /> Neue Regel
|
<FaPlus /> Neue Regel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showAddForm && (
|
{showAddForm && (
|
||||||
<AddRuleForm
|
<AddRuleForm
|
||||||
context={context}
|
context={context}
|
||||||
|
availableObjects={availableObjects}
|
||||||
onAdd={handleAdd}
|
onAdd={handleAdd}
|
||||||
onCancel={() => setShowAddForm(false)}
|
onCancel={() => setShowAddForm(false)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -324,6 +430,14 @@ const RulesSection: React.FC<RulesSectionProps> = ({
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : useTableView && context === 'DATA' ? (
|
||||||
|
<AccessRulesTable
|
||||||
|
rules={rules}
|
||||||
|
context={context}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
rules.map(rule => (
|
rules.map(rule => (
|
||||||
<RuleCard
|
<RuleCard
|
||||||
|
|
@ -413,6 +527,7 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
|
||||||
onSave,
|
onSave,
|
||||||
apiBasePath = '/api/rbac',
|
apiBasePath = '/api/rbac',
|
||||||
mandateId,
|
mandateId,
|
||||||
|
featureCode,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
rules,
|
rules,
|
||||||
|
|
@ -427,6 +542,9 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
|
||||||
removeRuleLocally,
|
removeRuleLocally,
|
||||||
} = useAccessRules(roleId, apiBasePath, mandateId);
|
} = useAccessRules(roleId, apiBasePath, mandateId);
|
||||||
|
|
||||||
|
// Catalog objects for dropdown selection
|
||||||
|
const { objects: catalogObjects, fetchObjects } = useCatalogObjects();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('DATA');
|
const [activeTab, setActiveTab] = useState<TabType>('DATA');
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
const [originalRules, setOriginalRules] = useState<AccessRule[]>([]);
|
const [originalRules, setOriginalRules] = useState<AccessRule[]>([]);
|
||||||
|
|
@ -438,6 +556,17 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
|
||||||
});
|
});
|
||||||
}, [fetchRules]);
|
}, [fetchRules]);
|
||||||
|
|
||||||
|
// Load catalog objects - filter by featureCode if provided
|
||||||
|
useEffect(() => {
|
||||||
|
fetchObjects(undefined, featureCode, mandateId);
|
||||||
|
}, [fetchObjects, featureCode, mandateId]);
|
||||||
|
|
||||||
|
// Get objects for current tab
|
||||||
|
const currentContextObjects = useMemo(() => {
|
||||||
|
if (activeTab === 'JSON') return [];
|
||||||
|
return catalogObjects[activeTab] || [];
|
||||||
|
}, [catalogObjects, activeTab]);
|
||||||
|
|
||||||
// Track changes
|
// Track changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHasChanges(JSON.stringify(rules) !== JSON.stringify(originalRules));
|
setHasChanges(JSON.stringify(rules) !== JSON.stringify(originalRules));
|
||||||
|
|
@ -568,6 +697,7 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
|
||||||
<RulesSection
|
<RulesSection
|
||||||
context="DATA"
|
context="DATA"
|
||||||
rules={groupedRules.DATA}
|
rules={groupedRules.DATA}
|
||||||
|
availableObjects={catalogObjects.DATA || []}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
onUpdate={handleUpdate}
|
onUpdate={handleUpdate}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
|
@ -578,6 +708,7 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
|
||||||
<RulesSection
|
<RulesSection
|
||||||
context="UI"
|
context="UI"
|
||||||
rules={groupedRules.UI}
|
rules={groupedRules.UI}
|
||||||
|
availableObjects={catalogObjects.UI || []}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
onUpdate={handleUpdate}
|
onUpdate={handleUpdate}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
|
@ -588,6 +719,7 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
|
||||||
<RulesSection
|
<RulesSection
|
||||||
context="RESOURCE"
|
context="RESOURCE"
|
||||||
rules={groupedRules.RESOURCE}
|
rules={groupedRules.RESOURCE}
|
||||||
|
availableObjects={catalogObjects.RESOURCE || []}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
onUpdate={handleUpdate}
|
onUpdate={handleUpdate}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
|
|
||||||
246
src/components/AccessRules/AccessRulesTable.tsx
Normal file
246
src/components/AccessRules/AccessRulesTable.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
/**
|
||||||
|
* AccessRulesTable
|
||||||
|
*
|
||||||
|
* Checkbox-based compact table for editing RBAC access rules.
|
||||||
|
* Shows all permissions in a matrix format similar to Unix permissions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { FaTable, FaDesktop, FaServer, FaTrash } from 'react-icons/fa';
|
||||||
|
import { type AccessRule, type RuleContext, type AccessLevel } from '../../hooks/useAccessRules';
|
||||||
|
import styles from './AccessRules.module.css';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TYPES
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface AccessRulesTableProps {
|
||||||
|
rules: AccessRule[];
|
||||||
|
context: RuleContext;
|
||||||
|
readOnly?: boolean;
|
||||||
|
onUpdate: (ruleId: string, updates: Partial<AccessRule>) => void;
|
||||||
|
onDelete: (ruleId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// HELPER FUNCTIONS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if access level is at least the specified minimum level.
|
||||||
|
* Hierarchy: n (none) < m (mine) < g (group) < a (all)
|
||||||
|
*/
|
||||||
|
const hasLevel = (level: AccessLevel | null | undefined, minLevel: 'm' | 'g' | 'a'): boolean => {
|
||||||
|
if (!level || level === 'n') return false;
|
||||||
|
const hierarchy = ['n', 'm', 'g', 'a'];
|
||||||
|
return hierarchy.indexOf(level) >= hierarchy.indexOf(minLevel);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the new access level when a checkbox is toggled.
|
||||||
|
*/
|
||||||
|
const calculateNewLevel = (
|
||||||
|
currentLevel: AccessLevel | null | undefined,
|
||||||
|
targetLevel: 'm' | 'g' | 'a',
|
||||||
|
checked: boolean
|
||||||
|
): AccessLevel => {
|
||||||
|
if (checked) {
|
||||||
|
// Activating: set to target level
|
||||||
|
return targetLevel;
|
||||||
|
} else {
|
||||||
|
// Deactivating: set to level below target
|
||||||
|
const hierarchy: AccessLevel[] = ['n', 'm', 'g', 'a'];
|
||||||
|
const targetIndex = hierarchy.indexOf(targetLevel);
|
||||||
|
return hierarchy[targetIndex - 1] || 'n';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// RULE ROW COMPONENT
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface AccessRuleRowProps {
|
||||||
|
rule: AccessRule;
|
||||||
|
isDataContext: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
|
onUpdate: (ruleId: string, updates: Partial<AccessRule>) => void;
|
||||||
|
onDelete: (ruleId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
|
||||||
|
rule,
|
||||||
|
isDataContext,
|
||||||
|
readOnly,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
}) => {
|
||||||
|
const handleLevelToggle = (
|
||||||
|
field: 'read' | 'create' | 'update' | 'delete',
|
||||||
|
targetLevel: 'm' | 'g' | 'a',
|
||||||
|
checked: boolean
|
||||||
|
) => {
|
||||||
|
const currentLevel = rule[field] as AccessLevel | null | undefined;
|
||||||
|
const newLevel = calculateNewLevel(currentLevel, targetLevel, checked);
|
||||||
|
onUpdate(rule.id, { [field]: newLevel });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get icon for context
|
||||||
|
const getContextIcon = () => {
|
||||||
|
switch (rule.context) {
|
||||||
|
case 'DATA': return <FaTable />;
|
||||||
|
case 'UI': return <FaDesktop />;
|
||||||
|
case 'RESOURCE': return <FaServer />;
|
||||||
|
default: return <FaTable />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className={styles.ruleRow}>
|
||||||
|
{/* Object Name */}
|
||||||
|
<td className={styles.objectCell}>
|
||||||
|
<span className={styles.objectIcon}>{getContextIcon()}</span>
|
||||||
|
<code className={styles.objectCode}>{rule.item || '(global)'}</code>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* View Checkbox */}
|
||||||
|
<td className={styles.checkboxCell}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rule.view}
|
||||||
|
onChange={(e) => onUpdate(rule.id, { view: e.target.checked })}
|
||||||
|
disabled={readOnly}
|
||||||
|
title="Sichtbar"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* CRUD Checkboxes for DATA context */}
|
||||||
|
{isDataContext && (
|
||||||
|
<>
|
||||||
|
{/* EIGENE (m) */}
|
||||||
|
{(['create', 'read', 'update', 'delete'] as const).map(op => (
|
||||||
|
<td key={`m-${op}`} className={styles.checkboxCell}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hasLevel(rule[op] as AccessLevel, 'm')}
|
||||||
|
onChange={(e) => handleLevelToggle(op, 'm', e.target.checked)}
|
||||||
|
disabled={readOnly}
|
||||||
|
title={`${op.charAt(0).toUpperCase() + op.slice(1)} - Eigene`}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* GRUPPE (g) */}
|
||||||
|
{(['create', 'read', 'update', 'delete'] as const).map(op => (
|
||||||
|
<td key={`g-${op}`} className={styles.checkboxCell}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hasLevel(rule[op] as AccessLevel, 'g')}
|
||||||
|
onChange={(e) => handleLevelToggle(op, 'g', e.target.checked)}
|
||||||
|
disabled={readOnly}
|
||||||
|
title={`${op.charAt(0).toUpperCase() + op.slice(1)} - Gruppe`}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* ALLE (a) */}
|
||||||
|
{(['create', 'read', 'update', 'delete'] as const).map(op => (
|
||||||
|
<td key={`a-${op}`} className={styles.checkboxCell}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hasLevel(rule[op] as AccessLevel, 'a')}
|
||||||
|
onChange={(e) => handleLevelToggle(op, 'a', e.target.checked)}
|
||||||
|
disabled={readOnly}
|
||||||
|
title={`${op.charAt(0).toUpperCase() + op.slice(1)} - Alle`}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Button */}
|
||||||
|
<td className={styles.actionsCell}>
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
className={`${styles.iconButton} ${styles.danger}`}
|
||||||
|
onClick={() => onDelete(rule.id)}
|
||||||
|
title="Regel löschen"
|
||||||
|
>
|
||||||
|
<FaTrash />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MAIN TABLE COMPONENT
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const AccessRulesTable: React.FC<AccessRulesTableProps> = ({
|
||||||
|
rules,
|
||||||
|
context,
|
||||||
|
readOnly,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
}) => {
|
||||||
|
const isDataContext = context === 'DATA';
|
||||||
|
|
||||||
|
if (rules.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.tableWrapper}>
|
||||||
|
<table className={styles.accessRulesTable}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className={styles.colObject}>Objekt (Dot-Notation)</th>
|
||||||
|
<th className={styles.colView}>View</th>
|
||||||
|
{isDataContext && (
|
||||||
|
<>
|
||||||
|
<th className={styles.colGroupHeader} colSpan={4}>Eigene (m)</th>
|
||||||
|
<th className={styles.colGroupHeader} colSpan={4}>Gruppe (g)</th>
|
||||||
|
<th className={styles.colGroupHeader} colSpan={4}>Alle (a)</th>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<th className={styles.colActions}></th>
|
||||||
|
</tr>
|
||||||
|
{isDataContext && (
|
||||||
|
<tr className={styles.subHeader}>
|
||||||
|
<th></th>
|
||||||
|
<th></th>
|
||||||
|
<th title="Create">C</th>
|
||||||
|
<th title="Read">R</th>
|
||||||
|
<th title="Update">U</th>
|
||||||
|
<th title="Delete">D</th>
|
||||||
|
<th title="Create">C</th>
|
||||||
|
<th title="Read">R</th>
|
||||||
|
<th title="Update">U</th>
|
||||||
|
<th title="Delete">D</th>
|
||||||
|
<th title="Create">C</th>
|
||||||
|
<th title="Read">R</th>
|
||||||
|
<th title="Update">U</th>
|
||||||
|
<th title="Delete">D</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rules.map(rule => (
|
||||||
|
<AccessRuleRow
|
||||||
|
key={rule.id}
|
||||||
|
rule={rule}
|
||||||
|
isDataContext={isDataContext}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AccessRulesTable;
|
||||||
|
|
@ -6,3 +6,4 @@
|
||||||
|
|
||||||
export { AccessRulesEditor } from './AccessRulesEditor';
|
export { AccessRulesEditor } from './AccessRulesEditor';
|
||||||
export { AccessLevelSelect } from './AccessLevelSelect';
|
export { AccessLevelSelect } from './AccessLevelSelect';
|
||||||
|
export { AccessRulesTable } from './AccessRulesTable';
|
||||||
|
|
|
||||||
|
|
@ -1590,7 +1590,22 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
const actionTitle = typeof actionButton.title === 'function'
|
const actionTitle = typeof actionButton.title === 'function'
|
||||||
? actionButton.title(row)
|
? actionButton.title(row)
|
||||||
: actionButton.title;
|
: actionButton.title;
|
||||||
const disabledResult = actionButton.disabled ? actionButton.disabled(row) : false;
|
|
||||||
|
// Row-level permission check - uses _permissions from backend API
|
||||||
|
// Backend delivers per-record permissions: { _permissions: { canEdit, canDelete } }
|
||||||
|
let disabledResult: boolean | { disabled: boolean; message?: string } = false;
|
||||||
|
if (actionButton.disabled) {
|
||||||
|
// Explicit disabled function takes precedence
|
||||||
|
disabledResult = actionButton.disabled(row, hookData);
|
||||||
|
} else if (row._permissions) {
|
||||||
|
// Use per-record permissions from backend
|
||||||
|
if (actionButton.type === 'edit' && row._permissions.canUpdate === false) {
|
||||||
|
disabledResult = true;
|
||||||
|
} else if (actionButton.type === 'delete' && row._permissions.canDelete === false) {
|
||||||
|
disabledResult = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isLoading = actionButton.loading ? actionButton.loading(row) : false;
|
const isLoading = actionButton.loading ? actionButton.loading(row) : false;
|
||||||
const isProcessing = actionButton.isProcessing ? actionButton.isProcessing(row) : false;
|
const isProcessing = actionButton.isProcessing ? actionButton.isProcessing(row) : false;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -248,6 +248,26 @@
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Loading State */
|
||||||
|
.loadingState {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
color: var(--text-tertiary, #888);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
/* Empty State */
|
/* Empty State */
|
||||||
.emptyState {
|
.emptyState {
|
||||||
padding: 1.5rem 1rem;
|
padding: 1.5rem 1rem;
|
||||||
|
|
|
||||||
|
|
@ -4,88 +4,84 @@
|
||||||
* Hierarchische Navigation für das Multi-Tenant-System.
|
* Hierarchische Navigation für das Multi-Tenant-System.
|
||||||
* Verwendet TreeNavigation für flexible Baumstruktur.
|
* Verwendet TreeNavigation für flexible Baumstruktur.
|
||||||
*
|
*
|
||||||
* Struktur:
|
* Navigation wird vollständig vom Backend geladen (/api/navigation).
|
||||||
* - SYSTEM (immer verfügbar)
|
* Backend liefert Blocks-Struktur mit Static und Dynamic Blocks.
|
||||||
|
* UI mappt uiComponent zu Icons via pageRegistry.
|
||||||
|
*
|
||||||
|
* Struktur (gemäss Navigation-API-Konzept):
|
||||||
|
* - SYSTEM (static block, order: 10)
|
||||||
|
* - MEINE FEATURES (dynamic block, order: 15)
|
||||||
* - Mandant 1
|
* - Mandant 1
|
||||||
* - Feature A
|
* - Feature A
|
||||||
* - Instanz 1 (mit Views)
|
* - Instanz 1 (mit Views)
|
||||||
* - Instanz 2 (mit Views)
|
* - WORKFLOWS (static block, order: 20)
|
||||||
* - Feature B
|
* - BASISDATEN (static block, order: 30)
|
||||||
* - Instanz 3 (mit Views)
|
* - MIGRATE TO FEATURES (static block, order: 40)
|
||||||
* - Mandant 2
|
* - ADMINISTRATION (static block, order: 200)
|
||||||
* - ...
|
|
||||||
* - ADMINISTRATION (nur für SysAdmin)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useMandates, useFeatureStore } from '../../stores/featureStore';
|
import { useNavigation } from '../../hooks/useNavigation';
|
||||||
import { useCurrentUser } from '../../hooks/useUsers';
|
import type {
|
||||||
import { FEATURE_REGISTRY, getLabel } from '../../types/mandate';
|
StaticBlock,
|
||||||
import type { Mandate, MandateFeature, FeatureInstance } from '../../types/mandate';
|
DynamicBlock,
|
||||||
import {
|
NavigationItem,
|
||||||
FaHome, FaCog, FaBriefcase, FaRobot, FaPlay, FaBuilding, FaUsers, FaUserTag,
|
NavigationMandate,
|
||||||
FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt,
|
MandateFeature,
|
||||||
FaLightbulb, FaRegFileAlt, FaLink, FaComments, FaChartBar, FaMicrophone,
|
FeatureInstance,
|
||||||
FaListAlt, FaCogs
|
FeatureView
|
||||||
} from 'react-icons/fa';
|
} from '../../hooks/useNavigation';
|
||||||
|
import { getPageIcon } from '../../config/pageRegistry';
|
||||||
|
import { FaSpinner } from 'react-icons/fa';
|
||||||
import { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigation';
|
import { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigation';
|
||||||
import styles from './MandateNavigation.module.css';
|
import styles from './MandateNavigation.module.css';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// ICON MAPPING
|
// HELPER FUNCTIONS - Convert API blocks to TreeItems
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
/**
|
||||||
trustee: <FaBriefcase />,
|
* Convert a NavigationItem (from static block) to TreeNodeItem
|
||||||
chatbot: <FaRobot />,
|
*/
|
||||||
chatworkflow: <FaPlay />,
|
function navigationItemToTreeNode(item: NavigationItem): TreeNodeItem {
|
||||||
};
|
return {
|
||||||
|
id: item.objectKey,
|
||||||
|
label: item.uiLabel,
|
||||||
|
icon: getPageIcon(item.uiComponent),
|
||||||
|
path: item.uiPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
/**
|
||||||
// HELPER FUNCTIONS
|
* Convert a StaticBlock to TreeItem (section)
|
||||||
// =============================================================================
|
*/
|
||||||
|
function staticBlockToTreeItem(block: StaticBlock): TreeItem {
|
||||||
|
return {
|
||||||
|
type: 'section',
|
||||||
|
title: block.title,
|
||||||
|
children: block.items.map(navigationItemToTreeNode),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a FeatureView to TreeNodeItem
|
||||||
|
*/
|
||||||
|
function featureViewToTreeNode(view: FeatureView): TreeNodeItem {
|
||||||
|
return {
|
||||||
|
id: view.objectKey,
|
||||||
|
label: view.uiLabel,
|
||||||
|
path: view.uiPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a FeatureInstance to TreeNodeItem
|
* Convert a FeatureInstance to TreeNodeItem
|
||||||
*/
|
*/
|
||||||
function instanceToTreeNode(
|
function featureInstanceToTreeNode(instance: FeatureInstance): TreeNodeItem {
|
||||||
instance: FeatureInstance,
|
|
||||||
mandateId: string,
|
|
||||||
featureCode: string
|
|
||||||
): TreeNodeItem {
|
|
||||||
const basePath = `/mandates/${mandateId}/${featureCode}/${instance.id}`;
|
|
||||||
|
|
||||||
// Get views from registry
|
|
||||||
const featureConfig = FEATURE_REGISTRY[featureCode];
|
|
||||||
const views = featureConfig?.views || [];
|
|
||||||
|
|
||||||
// Check if user has _all views permission (full access)
|
|
||||||
const hasAllViewsPermission = instance.permissions?.views?._all === true;
|
|
||||||
|
|
||||||
// Filter views based on permissions
|
|
||||||
// A view is visible if:
|
|
||||||
// 1. User has _all views permission, OR
|
|
||||||
// 2. The specific view permission is explicitly true
|
|
||||||
const visibleViews = views.filter(view => {
|
|
||||||
const viewCode = `${featureCode}-${view.code}`;
|
|
||||||
if (hasAllViewsPermission) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return instance.permissions?.views?.[viewCode] === true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Convert views to children
|
|
||||||
const children: TreeNodeItem[] = visibleViews.map(view => ({
|
|
||||||
id: `${instance.id}-${view.code}`,
|
|
||||||
label: getLabel(view.label),
|
|
||||||
path: `${basePath}/${view.path}`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: instance.id,
|
id: instance.id,
|
||||||
label: instance.instanceLabel,
|
label: instance.uiLabel,
|
||||||
// Note: badge für userRole entfernt - ein User kann mehrere Rollen haben
|
children: instance.views.map(featureViewToTreeNode),
|
||||||
children,
|
|
||||||
defaultExpanded: false,
|
defaultExpanded: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -93,38 +89,31 @@ function instanceToTreeNode(
|
||||||
/**
|
/**
|
||||||
* Convert a MandateFeature to TreeNodeItem
|
* Convert a MandateFeature to TreeNodeItem
|
||||||
*/
|
*/
|
||||||
function featureToTreeNode(
|
function mandateFeatureToTreeNode(feature: MandateFeature): TreeNodeItem | null {
|
||||||
feature: MandateFeature,
|
|
||||||
mandateId: string
|
|
||||||
): TreeNodeItem | null {
|
|
||||||
if (feature.instances.length === 0) {
|
if (feature.instances.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const children = feature.instances.map(instance =>
|
|
||||||
instanceToTreeNode(instance, mandateId, feature.code)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${mandateId}-${feature.code}`,
|
id: feature.uiComponent,
|
||||||
label: getLabel(feature.label),
|
label: feature.uiLabel,
|
||||||
icon: FEATURE_ICONS[feature.code] || <FaBriefcase />,
|
icon: getPageIcon(feature.uiComponent),
|
||||||
badge: feature.instances.length,
|
badge: feature.instances.length,
|
||||||
children,
|
children: feature.instances.map(featureInstanceToTreeNode),
|
||||||
defaultExpanded: false,
|
defaultExpanded: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a Mandate to TreeNodeItem
|
* Convert a NavigationMandate to TreeNodeItem
|
||||||
*/
|
*/
|
||||||
function mandateToTreeNode(mandate: Mandate): TreeNodeItem | null {
|
function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem | null {
|
||||||
if (mandate.features.length === 0) {
|
if (mandate.features.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const children = mandate.features
|
const children = mandate.features
|
||||||
.map(feature => featureToTreeNode(feature, mandate.id))
|
.map(mandateFeatureToTreeNode)
|
||||||
.filter((node): node is TreeNodeItem => node !== null);
|
.filter((node): node is TreeNodeItem => node !== null);
|
||||||
|
|
||||||
if (children.length === 0) {
|
if (children.length === 0) {
|
||||||
|
|
@ -133,12 +122,32 @@ function mandateToTreeNode(mandate: Mandate): TreeNodeItem | null {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: mandate.id,
|
id: mandate.id,
|
||||||
label: mandate.name,
|
label: mandate.uiLabel,
|
||||||
children,
|
children,
|
||||||
defaultExpanded: true,
|
defaultExpanded: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a DynamicBlock to array of TreeNodeItems (mandate nodes)
|
||||||
|
*/
|
||||||
|
function dynamicBlockToTreeNodes(block: DynamicBlock): TreeNodeItem[] {
|
||||||
|
return block.mandates
|
||||||
|
.map(navigationMandateToTreeNode)
|
||||||
|
.filter((node): node is TreeNodeItem => node !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// LOADING STATE
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const LoadingState: React.FC = () => (
|
||||||
|
<div className={styles.loadingState}>
|
||||||
|
<FaSpinner className={styles.spinner} />
|
||||||
|
<span>Navigation wird geladen...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// EMPTY STATE
|
// EMPTY STATE
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -157,212 +166,68 @@ const EmptyState: React.FC = () => (
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export const MandateNavigation: React.FC = () => {
|
export const MandateNavigation: React.FC = () => {
|
||||||
const mandates = useMandates();
|
// Fetch navigation from new API (blocks structure, already filtered by permissions)
|
||||||
const { hasAnyInstance } = useFeatureStore();
|
const { blocks, loading } = useNavigation('de');
|
||||||
const { user } = useCurrentUser();
|
|
||||||
|
|
||||||
// Get isSysAdmin from user data
|
// Build navigation items from blocks
|
||||||
const isSysAdmin = user?.isSysAdmin ?? false;
|
|
||||||
|
|
||||||
// Build navigation items using TreeNavigation structure
|
|
||||||
const navigationItems: TreeItem[] = useMemo(() => {
|
const navigationItems: TreeItem[] = useMemo(() => {
|
||||||
const items: TreeItem[] = [];
|
const items: TreeItem[] = [];
|
||||||
|
|
||||||
// System section (always visible)
|
// Process blocks in order (already sorted by backend)
|
||||||
items.push({
|
for (const block of blocks) {
|
||||||
type: 'section',
|
if (block.type === 'static') {
|
||||||
title: 'SYSTEM',
|
// Static block: system, workflows, basedata, migrate, admin
|
||||||
children: [
|
if (block.items.length > 0) {
|
||||||
{
|
// Add separator before admin block
|
||||||
id: 'home',
|
if (block.id === 'admin') {
|
||||||
label: 'Übersicht',
|
items.push({ type: 'separator' });
|
||||||
icon: <FaHome />,
|
}
|
||||||
path: '/',
|
items.push(staticBlockToTreeItem(block));
|
||||||
},
|
}
|
||||||
{
|
} else if (block.type === 'dynamic') {
|
||||||
id: 'settings',
|
// Dynamic block: features/mandates
|
||||||
label: 'Einstellungen',
|
// Add separator before dynamic block
|
||||||
icon: <FaCog />,
|
|
||||||
path: '/settings',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Workflows section (global pages)
|
|
||||||
items.push({
|
|
||||||
type: 'section',
|
|
||||||
title: 'WORKFLOWS',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: 'workflows-playground',
|
|
||||||
label: 'Chat Playground',
|
|
||||||
icon: <FaPlay />,
|
|
||||||
path: '/workflows/playground',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'workflows-list',
|
|
||||||
label: 'Scheduler',
|
|
||||||
icon: <FaListAlt />,
|
|
||||||
path: '/workflows/list',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'workflows-automations',
|
|
||||||
label: 'Automations',
|
|
||||||
icon: <FaCogs />,
|
|
||||||
path: '/workflows/automations',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Basisdaten section (global pages)
|
|
||||||
items.push({
|
|
||||||
type: 'section',
|
|
||||||
title: 'BASISDATEN',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: 'basedata-prompts',
|
|
||||||
label: 'Prompts',
|
|
||||||
icon: <FaLightbulb />,
|
|
||||||
path: '/basedata/prompts',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'basedata-files',
|
|
||||||
label: 'Files',
|
|
||||||
icon: <FaRegFileAlt />,
|
|
||||||
path: '/basedata/files',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'basedata-connections',
|
|
||||||
label: 'Connections',
|
|
||||||
icon: <FaLink />,
|
|
||||||
path: '/basedata/connections',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Migrate to Feature Instances section (temporary)
|
|
||||||
items.push({
|
|
||||||
type: 'section',
|
|
||||||
title: 'MIGRATE TO FEATURES',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: 'migrate-chatbot',
|
|
||||||
label: 'Chatbot',
|
|
||||||
icon: <FaComments />,
|
|
||||||
path: '/chatbot',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'migrate-pek',
|
|
||||||
label: 'PEK',
|
|
||||||
icon: <FaChartBar />,
|
|
||||||
path: '/pek',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'migrate-speech',
|
|
||||||
label: 'Speech',
|
|
||||||
icon: <FaMicrophone />,
|
|
||||||
path: '/speech',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Separator
|
|
||||||
items.push({ type: 'separator' });
|
items.push({ type: 'separator' });
|
||||||
|
|
||||||
// Mandate nodes (if user has instances)
|
const mandateNodes = dynamicBlockToTreeNodes(block);
|
||||||
if (hasAnyInstance()) {
|
|
||||||
const mandateNodes = mandates
|
|
||||||
.map(mandate => mandateToTreeNode(mandate))
|
|
||||||
.filter((node): node is TreeNodeItem => node !== null);
|
|
||||||
|
|
||||||
if (mandateNodes.length > 0) {
|
if (mandateNodes.length > 0) {
|
||||||
items.push(...mandateNodes);
|
items.push(...mandateNodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add separator after dynamic block (before next static blocks)
|
||||||
|
items.push({ type: 'separator' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin section (only for SysAdmin)
|
// Remove trailing separator if present
|
||||||
if (isSysAdmin) {
|
while (items.length > 0 && (items[items.length - 1] as TreeItem & { type?: string }).type === 'separator') {
|
||||||
items.push({ type: 'separator' });
|
items.pop();
|
||||||
items.push({
|
|
||||||
type: 'section',
|
|
||||||
title: 'ADMINISTRATION',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: 'admin-users',
|
|
||||||
label: 'Benutzer',
|
|
||||||
icon: <FaUsers />,
|
|
||||||
path: '/admin/users',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'admin-invitations',
|
|
||||||
label: 'Einladungen',
|
|
||||||
icon: <FaEnvelopeOpenText />,
|
|
||||||
path: '/admin/invitations',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'admin-mandates',
|
|
||||||
label: 'Mandanten',
|
|
||||||
icon: <FaBuilding />,
|
|
||||||
path: '/admin/mandates',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'admin-mandate-roles',
|
|
||||||
label: 'Rollen',
|
|
||||||
icon: <FaKey />,
|
|
||||||
path: '/admin/mandate-roles',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'admin-mandate-role-permissions',
|
|
||||||
label: 'Rollen-Berechtigungen',
|
|
||||||
icon: <FaShieldAlt />,
|
|
||||||
path: '/admin/mandate-role-permissions',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'admin-user-mandates',
|
|
||||||
label: 'Mandanten-Mitglieder',
|
|
||||||
icon: <FaUserTag />,
|
|
||||||
path: '/admin/user-mandates',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'admin-feature-roles',
|
|
||||||
label: 'Feature-Rollen',
|
|
||||||
icon: <FaCube />,
|
|
||||||
path: '/admin/feature-roles',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'admin-feature-instances',
|
|
||||||
label: 'Feature-Instanzen',
|
|
||||||
icon: <FaCubes />,
|
|
||||||
path: '/admin/feature-instances',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'admin-feature-users',
|
|
||||||
label: 'Feature-Benutzer',
|
|
||||||
icon: <FaUsersCog />,
|
|
||||||
path: '/admin/feature-users',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}, [mandates, hasAnyInstance, isSysAdmin]);
|
}, [blocks]);
|
||||||
|
|
||||||
|
// Check if user has any navigation (static or dynamic)
|
||||||
|
const hasNavigation = blocks.length > 0;
|
||||||
|
|
||||||
|
// Show loading state while navigation is being fetched
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={styles.navigation}>
|
||||||
|
<LoadingState />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.navigation}>
|
<div className={styles.navigation}>
|
||||||
{hasAnyInstance() || isSysAdmin ? (
|
{hasNavigation ? (
|
||||||
<TreeNavigation
|
<TreeNavigation
|
||||||
items={navigationItems}
|
items={navigationItems}
|
||||||
autoExpandActive={true}
|
autoExpandActive={true}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
|
||||||
<TreeNavigation
|
|
||||||
items={navigationItems.slice(0, 2)} // System section + separator
|
|
||||||
autoExpandActive={true}
|
|
||||||
/>
|
|
||||||
<EmptyState />
|
<EmptyState />
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
135
src/config/pageRegistry.tsx
Normal file
135
src/config/pageRegistry.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
/**
|
||||||
|
* Page Registry
|
||||||
|
*
|
||||||
|
* Maps uiComponent codes from the Navigation API to React components and icons.
|
||||||
|
* This is the single source of truth for component mapping in the frontend.
|
||||||
|
*
|
||||||
|
* The backend provides uiComponent values like:
|
||||||
|
* - "page.system.home"
|
||||||
|
* - "page.admin.users"
|
||||||
|
* - "page.feature.trustee.dashboard"
|
||||||
|
*
|
||||||
|
* This registry maps them to:
|
||||||
|
* - Icon components for navigation
|
||||||
|
* - Page components for routing (lazy loaded)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
FaHome, FaCog, FaBriefcase, FaRobot, FaPlay, FaBuilding, FaUsers, FaUserTag,
|
||||||
|
FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt,
|
||||||
|
FaLightbulb, FaRegFileAlt, FaLink, FaComments, FaChartBar, FaMicrophone,
|
||||||
|
FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase,
|
||||||
|
FaProjectDiagram, FaMapMarkedAlt
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ICON MAP
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps uiComponent codes to icon components.
|
||||||
|
* Used by navigation to display icons next to menu items.
|
||||||
|
*/
|
||||||
|
export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
// System pages
|
||||||
|
'page.system.home': <FaHome />,
|
||||||
|
'page.system.settings': <FaCog />,
|
||||||
|
'page.system.playground': <FaPlay />,
|
||||||
|
'page.system.chats': <FaListAlt />,
|
||||||
|
'page.system.automations': <FaCogs />,
|
||||||
|
'page.system.prompts': <FaLightbulb />,
|
||||||
|
'page.system.files': <FaRegFileAlt />,
|
||||||
|
'page.system.connections': <FaLink />,
|
||||||
|
'page.system.chatbot': <FaComments />,
|
||||||
|
'page.system.pek': <FaChartBar />,
|
||||||
|
'page.system.speech': <FaMicrophone />,
|
||||||
|
|
||||||
|
// Admin pages
|
||||||
|
'page.admin.users': <FaUsers />,
|
||||||
|
'page.admin.invitations': <FaEnvelopeOpenText />,
|
||||||
|
'page.admin.mandates': <FaBuilding />,
|
||||||
|
'page.admin.roles': <FaKey />,
|
||||||
|
'page.admin.role-permissions': <FaShieldAlt />,
|
||||||
|
'page.admin.user-mandates': <FaUserTag />,
|
||||||
|
'page.admin.feature-roles': <FaCube />,
|
||||||
|
'page.admin.feature-instances': <FaCubes />,
|
||||||
|
'page.admin.feature-users': <FaUsersCog />,
|
||||||
|
|
||||||
|
// Feature pages - Trustee
|
||||||
|
'page.feature.trustee.dashboard': <FaChartLine />,
|
||||||
|
'page.feature.trustee.positions': <FaDatabase />,
|
||||||
|
'page.feature.trustee.documents': <FaFileAlt />,
|
||||||
|
'page.feature.trustee.position-documents': <FaLink />,
|
||||||
|
'page.feature.trustee.instance-roles': <FaUserShield />,
|
||||||
|
|
||||||
|
// Feature pages - Real Estate
|
||||||
|
'page.feature.realestate.projects': <FaProjectDiagram />,
|
||||||
|
'page.feature.realestate.parcels': <FaMapMarkedAlt />,
|
||||||
|
|
||||||
|
// Feature icons (for feature grouping in navigation)
|
||||||
|
'feature.trustee': <FaBriefcase />,
|
||||||
|
'feature.realestate': <FaBuilding />,
|
||||||
|
'feature.chatworkflow': <FaPlay />,
|
||||||
|
'feature.chatbot': <FaRobot />,
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// HELPER FUNCTIONS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get icon for a uiComponent code.
|
||||||
|
* Falls back to FaCog if not found.
|
||||||
|
*/
|
||||||
|
export function getPageIcon(uiComponent: string): React.ReactNode {
|
||||||
|
return PAGE_ICONS[uiComponent] || <FaCog />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a uiComponent is a feature page (requires instance context).
|
||||||
|
*/
|
||||||
|
export function isFeaturePage(uiComponent: string): boolean {
|
||||||
|
return uiComponent.startsWith('page.feature.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a uiComponent is an admin page.
|
||||||
|
*/
|
||||||
|
export function isAdminPage(uiComponent: string): boolean {
|
||||||
|
return uiComponent.startsWith('page.admin.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract feature code from uiComponent.
|
||||||
|
* e.g., "page.feature.trustee.dashboard" -> "trustee"
|
||||||
|
*/
|
||||||
|
export function extractFeatureCode(uiComponent: string): string | null {
|
||||||
|
if (!uiComponent.startsWith('page.feature.')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parts = uiComponent.split('.');
|
||||||
|
return parts.length >= 3 ? parts[2] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract view code from uiComponent.
|
||||||
|
* e.g., "page.feature.trustee.dashboard" -> "dashboard"
|
||||||
|
*/
|
||||||
|
export function extractViewCode(uiComponent: string): string | null {
|
||||||
|
const parts = uiComponent.split('.');
|
||||||
|
return parts.length >= 4 ? parts[3] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build uiComponent from parts.
|
||||||
|
*/
|
||||||
|
export function buildUiComponent(type: 'system' | 'admin' | 'feature', ...parts: string[]): string {
|
||||||
|
return `page.${type}.${parts.join('.')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// EXPORTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export default PAGE_ICONS;
|
||||||
117
src/hooks/useCatalogObjects.ts
Normal file
117
src/hooks/useCatalogObjects.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
/**
|
||||||
|
* useCatalogObjects Hook
|
||||||
|
*
|
||||||
|
* Fetches RBAC catalog objects (DATA, UI, RESOURCE) from the backend.
|
||||||
|
* Used by AccessRulesEditor to populate object selection dropdowns.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import api from '../api';
|
||||||
|
import { type RuleContext } from './useAccessRules';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TYPES
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface CatalogObject {
|
||||||
|
objectKey: string;
|
||||||
|
featureCode: string;
|
||||||
|
label: { [lang: string]: string };
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
type: RuleContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogObjects {
|
||||||
|
DATA: CatalogObject[];
|
||||||
|
UI: CatalogObject[];
|
||||||
|
RESOURCE: CatalogObject[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseCatalogObjectsReturn {
|
||||||
|
objects: CatalogObjects;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
fetchObjects: (context?: RuleContext, featureCode?: string, mandateId?: string) => Promise<CatalogObjects>;
|
||||||
|
getObjectsByContext: (context: RuleContext) => CatalogObject[];
|
||||||
|
getObjectByKey: (objectKey: string) => CatalogObject | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// HOOK
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useCatalogObjects(): UseCatalogObjectsReturn {
|
||||||
|
const [objects, setObjects] = useState<CatalogObjects>({ DATA: [], UI: [], RESOURCE: [] });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch catalog objects from the backend.
|
||||||
|
*
|
||||||
|
* @param context - Optional filter by context type (DATA, UI, RESOURCE)
|
||||||
|
* @param featureCode - Optional filter by feature code
|
||||||
|
* @param mandateId - Optional filter by mandate's active features
|
||||||
|
*/
|
||||||
|
const fetchObjects = useCallback(async (
|
||||||
|
context?: RuleContext,
|
||||||
|
featureCode?: string,
|
||||||
|
mandateId?: string
|
||||||
|
): Promise<CatalogObjects> => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (context) params.append('context', context);
|
||||||
|
if (featureCode) params.append('featureCode', featureCode);
|
||||||
|
if (mandateId) params.append('mandateId', mandateId);
|
||||||
|
|
||||||
|
const url = `/api/rbac/catalog/objects${params.toString() ? `?${params}` : ''}`;
|
||||||
|
const response = await api.get<CatalogObjects>(url);
|
||||||
|
|
||||||
|
// Normalize response structure
|
||||||
|
const data: CatalogObjects = {
|
||||||
|
DATA: response.data.DATA || [],
|
||||||
|
UI: response.data.UI || [],
|
||||||
|
RESOURCE: response.data.RESOURCE || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
setObjects(data);
|
||||||
|
return data;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMsg = err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || 'Fehler beim Laden der Katalog-Objekte';
|
||||||
|
setError(errorMsg);
|
||||||
|
return { DATA: [], UI: [], RESOURCE: [] };
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get objects filtered by context.
|
||||||
|
*/
|
||||||
|
const getObjectsByContext = useCallback((context: RuleContext): CatalogObject[] => {
|
||||||
|
return objects[context] || [];
|
||||||
|
}, [objects]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific object by its key.
|
||||||
|
*/
|
||||||
|
const getObjectByKey = useCallback((objectKey: string): CatalogObject | undefined => {
|
||||||
|
const allObjects = [...objects.DATA, ...objects.UI, ...objects.RESOURCE];
|
||||||
|
return allObjects.find(obj => obj.objectKey === objectKey);
|
||||||
|
}, [objects]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
objects,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
fetchObjects,
|
||||||
|
getObjectsByContext,
|
||||||
|
getObjectByKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useCatalogObjects;
|
||||||
|
|
@ -119,37 +119,92 @@ export function useCanViewTable(tableName: string): boolean {
|
||||||
* );
|
* );
|
||||||
* }
|
* }
|
||||||
* ```
|
* ```
|
||||||
|
*
|
||||||
|
* Supports both legacy format (e.g., "trustee-dashboard") and
|
||||||
|
* fully qualified objectKey format (e.g., "ui.feature.trustee.dashboard")
|
||||||
*/
|
*/
|
||||||
export function useCanViewFeatureView(viewCode: string): boolean {
|
export function useCanViewFeatureView(viewCode: string): boolean {
|
||||||
const { instance } = useCurrentInstance();
|
const { instance, featureCode } = useCurrentInstance();
|
||||||
|
|
||||||
if (!instance?.permissions?.views) {
|
if (!instance?.permissions?.views) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const views = instance.permissions.views;
|
||||||
|
|
||||||
// Check for wildcard "_all" permission first (item=None in backend = all views)
|
// Check for wildcard "_all" permission first (item=None in backend = all views)
|
||||||
if (instance.permissions.views["_all"]) {
|
if (views["_all"]) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return instance.permissions.views[viewCode] ?? false;
|
// Check legacy format directly (e.g., "trustee-dashboard")
|
||||||
|
if (views[viewCode]) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check fully qualified objectKey format (e.g., "ui.feature.trustee.dashboard")
|
||||||
|
// Convert viewCode "trustee-dashboard" to "ui.feature.trustee.dashboard"
|
||||||
|
const parts = viewCode.split('-');
|
||||||
|
if (parts.length >= 2 && featureCode) {
|
||||||
|
const viewName = parts.slice(1).join('-'); // e.g., "dashboard" or "position-documents"
|
||||||
|
const fullObjectKey = `ui.feature.${featureCode}.${viewName}`;
|
||||||
|
if (views[fullObjectKey]) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook für mehrere View-Berechtigungen gleichzeitig
|
* Hook für mehrere View-Berechtigungen gleichzeitig
|
||||||
|
* Supports both legacy format and fully qualified objectKey format
|
||||||
*/
|
*/
|
||||||
export function useViewPermissions(viewCodes: string[]): Record<string, boolean> {
|
export function useViewPermissions(viewCodes: string[]): Record<string, boolean> {
|
||||||
const { instance } = useCurrentInstance();
|
const { instance, featureCode } = useCurrentInstance();
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const result: Record<string, boolean> = {};
|
const result: Record<string, boolean> = {};
|
||||||
|
const views = instance?.permissions?.views;
|
||||||
|
|
||||||
|
if (!views) {
|
||||||
|
viewCodes.forEach(code => {
|
||||||
|
result[code] = false;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for wildcard permission
|
||||||
|
const hasAllViews = views["_all"] ?? false;
|
||||||
|
|
||||||
viewCodes.forEach(code => {
|
viewCodes.forEach(code => {
|
||||||
result[code] = instance?.permissions?.views?.[code] ?? false;
|
if (hasAllViews) {
|
||||||
|
result[code] = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check legacy format
|
||||||
|
if (views[code]) {
|
||||||
|
result[code] = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check fully qualified objectKey format
|
||||||
|
const parts = code.split('-');
|
||||||
|
if (parts.length >= 2 && featureCode) {
|
||||||
|
const viewName = parts.slice(1).join('-');
|
||||||
|
const fullObjectKey = `ui.feature.${featureCode}.${viewName}`;
|
||||||
|
if (views[fullObjectKey]) {
|
||||||
|
result[code] = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result[code] = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [instance, viewCodes]);
|
}, [instance, featureCode, viewCodes]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
176
src/hooks/useNavigation.ts
Normal file
176
src/hooks/useNavigation.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
/**
|
||||||
|
* useNavigation Hook
|
||||||
|
*
|
||||||
|
* Fetches the navigation structure from the new Navigation API.
|
||||||
|
* The backend provides a blocks-based structure with static and dynamic blocks.
|
||||||
|
*
|
||||||
|
* API: GET /api/navigation?language=de
|
||||||
|
*
|
||||||
|
* Response structure (gemäss Navigation-API-Konzept):
|
||||||
|
* {
|
||||||
|
* "language": "de",
|
||||||
|
* "blocks": [
|
||||||
|
* { "type": "static", "id": "system", "title": "SYSTEM", "order": 10, "items": [...] },
|
||||||
|
* { "type": "dynamic", "id": "features", "title": "MEINE FEATURES", "order": 15, "mandates": [...] },
|
||||||
|
* ...
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TYPES - New Navigation API Structure
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/** Static block item (system, admin pages) */
|
||||||
|
export interface NavigationItem {
|
||||||
|
uiComponent: string;
|
||||||
|
uiLabel: string;
|
||||||
|
uiPath: string;
|
||||||
|
order: number;
|
||||||
|
objectKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Static navigation block */
|
||||||
|
export interface StaticBlock {
|
||||||
|
type: 'static';
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
order: number;
|
||||||
|
items: NavigationItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** View within a feature instance */
|
||||||
|
export interface FeatureView {
|
||||||
|
uiComponent: string;
|
||||||
|
uiLabel: string;
|
||||||
|
uiPath: string;
|
||||||
|
order: number;
|
||||||
|
objectKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feature instance within a mandate */
|
||||||
|
export interface FeatureInstance {
|
||||||
|
id: string;
|
||||||
|
uiLabel: string;
|
||||||
|
order: number;
|
||||||
|
views: FeatureView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feature within a mandate */
|
||||||
|
export interface MandateFeature {
|
||||||
|
uiComponent: string;
|
||||||
|
uiLabel: string;
|
||||||
|
order: number;
|
||||||
|
instances: FeatureInstance[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mandate in the dynamic block */
|
||||||
|
export interface NavigationMandate {
|
||||||
|
id: string;
|
||||||
|
uiLabel: string;
|
||||||
|
order: number;
|
||||||
|
features: MandateFeature[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dynamic navigation block (features) */
|
||||||
|
export interface DynamicBlock {
|
||||||
|
type: 'dynamic';
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
order: number;
|
||||||
|
mandates: NavigationMandate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Union type for all block types */
|
||||||
|
export type NavigationBlock = StaticBlock | DynamicBlock;
|
||||||
|
|
||||||
|
/** API Response structure */
|
||||||
|
export interface NavigationResponse {
|
||||||
|
language: string;
|
||||||
|
blocks: NavigationBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hook return type */
|
||||||
|
interface UseNavigationReturn {
|
||||||
|
/** All navigation blocks from API */
|
||||||
|
blocks: NavigationBlock[];
|
||||||
|
/** Static blocks only (for convenience) */
|
||||||
|
staticBlocks: StaticBlock[];
|
||||||
|
/** Dynamic block (features) if present */
|
||||||
|
dynamicBlock: DynamicBlock | null;
|
||||||
|
/** Loading state */
|
||||||
|
loading: boolean;
|
||||||
|
/** Error message if any */
|
||||||
|
error: string | null;
|
||||||
|
/** Refresh navigation data */
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// HELPER FUNCTIONS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function isStaticBlock(block: NavigationBlock): block is StaticBlock {
|
||||||
|
return block.type === 'static';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDynamicBlock(block: NavigationBlock): block is DynamicBlock {
|
||||||
|
return block.type === 'dynamic';
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// HOOK
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useNavigation(language: string = 'de'): UseNavigationReturn {
|
||||||
|
const [blocks, setBlocks] = useState<NavigationBlock[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchNavigation = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// New API endpoint: /api/navigation (without /system prefix)
|
||||||
|
const response = await api.get<NavigationResponse>(
|
||||||
|
`/api/navigation?language=${language}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Blocks are already sorted by order from backend
|
||||||
|
setBlocks(response.data.blocks || []);
|
||||||
|
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMsg = err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
||||||
|
|| 'Fehler beim Laden der Navigation';
|
||||||
|
setError(errorMsg);
|
||||||
|
setBlocks([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNavigation();
|
||||||
|
}, [fetchNavigation]);
|
||||||
|
|
||||||
|
// Derive static and dynamic blocks
|
||||||
|
const staticBlocks = blocks.filter(isStaticBlock);
|
||||||
|
const dynamicBlock = blocks.find(isDynamicBlock) || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
blocks,
|
||||||
|
staticBlocks,
|
||||||
|
dynamicBlock,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refresh: fetchNavigation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useNavigation;
|
||||||
|
|
@ -171,7 +171,16 @@ export const usePermissions = () => {
|
||||||
return cacheRef.current[key];
|
return cacheRef.current[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not in bulk cache, fall back to individual fetch
|
// Check for global permission (_global key) - grants access to all items in this context
|
||||||
|
const globalKey = getPermissionKey(context, '_global');
|
||||||
|
if (cacheRef.current[globalKey]) {
|
||||||
|
console.log(`✅ usePermissions: ${context}:${item} using global permission`);
|
||||||
|
// Cache the global permission for this specific item too
|
||||||
|
cacheRef.current[key] = cacheRef.current[globalKey];
|
||||||
|
return cacheRef.current[globalKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not in bulk cache and no global permission, fall back to individual fetch
|
||||||
// (item may not have explicit rule, but backend will calculate effective permissions)
|
// (item may not have explicit rule, but backend will calculate effective permissions)
|
||||||
console.log(`⚠️ usePermissions: ${context}:${item} not in bulk cache, fetching individually`);
|
console.log(`⚠️ usePermissions: ${context}:${item} not in bulk cache, fetching individually`);
|
||||||
return fetchIndividualPermission(context, item);
|
return fetchIndividualPermission(context, item);
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ import {
|
||||||
// Position-Document API
|
// Position-Document API
|
||||||
fetchPositionDocuments as fetchPositionDocumentsApi,
|
fetchPositionDocuments as fetchPositionDocumentsApi,
|
||||||
createPositionDocument as createPositionDocumentApi,
|
createPositionDocument as createPositionDocumentApi,
|
||||||
|
updatePositionDocument as updatePositionDocumentApi,
|
||||||
deletePositionDocument as deletePositionDocumentApi,
|
deletePositionDocument as deletePositionDocumentApi,
|
||||||
} from '../api/trusteeApi';
|
} from '../api/trusteeApi';
|
||||||
|
|
||||||
|
|
@ -148,7 +149,9 @@ function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntit
|
||||||
|
|
||||||
const fetchPermissions = useCallback(async () => {
|
const fetchPermissions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const perms = await checkPermission('DATA', config.entityName);
|
// Use fully qualified objectKey for RBAC: data.feature.trustee.EntityName
|
||||||
|
const objectKey = `data.feature.trustee.${config.entityName}`;
|
||||||
|
const perms = await checkPermission('DATA', objectKey);
|
||||||
setPermissions(perms);
|
setPermissions(perms);
|
||||||
return perms;
|
return perms;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
@ -592,7 +595,7 @@ const positionDocumentConfig: TrusteeEntityConfig<TrusteePositionDocument> = {
|
||||||
fetchAll: fetchPositionDocumentsApi,
|
fetchAll: fetchPositionDocumentsApi,
|
||||||
fetchById: async () => null,
|
fetchById: async () => null,
|
||||||
create: createPositionDocumentApi,
|
create: createPositionDocumentApi,
|
||||||
update: async () => { throw new Error('Update not supported for position-document links'); },
|
update: updatePositionDocumentApi,
|
||||||
deleteItem: deletePositionDocumentApi
|
deleteItem: deletePositionDocumentApi
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -392,7 +392,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
<div className={styles.adminPage}>
|
<div className={styles.adminPage}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>Feature-Benutzer</h1>
|
<h1 className={styles.pageTitle}>Feature Instanz Benutzer</h1>
|
||||||
<p className={styles.pageSubtitle}>Verwalten Sie Benutzerzugriffe auf Feature-Instanzen</p>
|
<p className={styles.pageSubtitle}>Verwalten Sie Benutzerzugriffe auf Feature-Instanzen</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -261,8 +261,8 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
<div className={styles.adminPage}>
|
<div className={styles.adminPage}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>Feature-Rollen</h1>
|
<h1 className={styles.pageTitle}>Feature Rollen & Rechte</h1>
|
||||||
<p className={styles.pageSubtitle}>Template-Rollen für Feature-Instanzen verwalten</p>
|
<p className={styles.pageSubtitle}>Template-Rollen und deren Berechtigungen für Feature-Instanzen verwalten</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -479,6 +479,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
roleName={permissionsRole.roleLabel}
|
roleName={permissionsRole.roleLabel}
|
||||||
isTemplate={true}
|
isTemplate={true}
|
||||||
onSave={() => setPermissionsRole(null)}
|
onSave={() => setPermissionsRole(null)}
|
||||||
|
featureCode={permissionsRole.featureCode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -188,7 +188,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
||||||
<FaShieldAlt style={{ marginRight: '0.5rem' }} />
|
<FaShieldAlt style={{ marginRight: '0.5rem' }} />
|
||||||
<span>
|
<span>
|
||||||
Klicken Sie auf eine Rolle, um deren Berechtigungen (AccessRules) zu bearbeiten.
|
Klicken Sie auf eine Rolle, um deren Berechtigungen (AccessRules) zu bearbeiten.
|
||||||
System-Rollen sind schreibgeschützt.
|
Alle Rollen-Berechtigungen sind bearbeitbar (System-Rollen-Namen sind geschützt).
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -246,7 +246,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
||||||
roleId={role.id}
|
roleId={role.id}
|
||||||
roleName={role.roleLabel}
|
roleName={role.roleLabel}
|
||||||
isTemplate={false}
|
isTemplate={false}
|
||||||
readOnly={role.isSystemRole}
|
readOnly={false} // All AccessRules are editable (access controlled via RBAC)
|
||||||
apiBasePath="/api/rbac"
|
apiBasePath="/api/rbac"
|
||||||
mandateId={selectedMandateId}
|
mandateId={selectedMandateId}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,7 @@ export const TrusteeInstanceRolesView: React.FC = () => {
|
||||||
isTemplate={false}
|
isTemplate={false}
|
||||||
apiBasePath={`/api/trustee/${instance.id}/instance-roles/${role.id}`}
|
apiBasePath={`/api/trustee/${instance.id}/instance-roles/${role.id}`}
|
||||||
mandateId={instance.mandateId}
|
mandateId={instance.mandateId}
|
||||||
|
featureCode="trustee"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* TrusteePositionDocumentsView
|
* TrusteePositionDocumentsView
|
||||||
*
|
*
|
||||||
* Verknüpfungs-Verwaltung zwischen Positionen und Dokumenten.
|
* Verknüpfungs-Verwaltung zwischen Positionen und Dokumenten.
|
||||||
* Verwendet FormGeneratorTable für konsistentes UI.
|
* Verwendet FormGeneratorTable mit Spalten aus den Pydantic-Attributen.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useMemo, useEffect } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
|
|
@ -32,12 +32,14 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handleCreate,
|
handleCreate,
|
||||||
|
handleUpdate,
|
||||||
deletingItems,
|
deletingItems,
|
||||||
creatingItem,
|
creatingItem,
|
||||||
} = useTrusteePositionDocumentOperations();
|
} = useTrusteePositionDocumentOperations();
|
||||||
|
|
||||||
// Modal state
|
// Modal state
|
||||||
const [isCreateMode, setIsCreateMode] = useState(false);
|
const [isCreateMode, setIsCreateMode] = useState(false);
|
||||||
|
const [editingLink, setEditingLink] = useState<TrusteePositionDocument | null>(null);
|
||||||
|
|
||||||
// Initial fetch
|
// Initial fetch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -46,23 +48,44 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [instanceId]);
|
}, [instanceId]);
|
||||||
|
|
||||||
// Generate columns from attributes
|
// Generate columns from attributes (like TrusteePositionsView)
|
||||||
|
// Map frontend_options to fkSource for FK resolution
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
return (attributes || []).map(attr => ({
|
if (!attributes || attributes.length === 0) return [];
|
||||||
|
|
||||||
|
// Exclude system fields from table columns
|
||||||
|
const excludedFields = ['id', 'mandateId', 'featureInstanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy'];
|
||||||
|
|
||||||
|
return attributes
|
||||||
|
.filter(attr => !excludedFields.includes(attr.name))
|
||||||
|
.map(attr => {
|
||||||
|
// Replace {instanceId} placeholder in options URL
|
||||||
|
let fkSource = attr.options;
|
||||||
|
if (typeof fkSource === 'string' && instanceId) {
|
||||||
|
fkSource = fkSource.replace('{instanceId}', instanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
key: attr.name,
|
key: attr.name,
|
||||||
label: attr.label || attr.name,
|
label: attr.label || attr.name,
|
||||||
type: attr.type as any,
|
type: attr.type as any,
|
||||||
sortable: attr.sortable !== false,
|
sortable: attr.sortable !== false,
|
||||||
filterable: attr.filterable !== false,
|
filterable: attr.filterable !== false,
|
||||||
searchable: attr.searchable !== false,
|
searchable: attr.searchable !== false,
|
||||||
width: attr.width || 150,
|
width: attr.width || 200,
|
||||||
minWidth: attr.minWidth || 100,
|
minWidth: attr.minWidth || 100,
|
||||||
maxWidth: attr.maxWidth || 400,
|
maxWidth: attr.maxWidth || 400,
|
||||||
}));
|
// Use frontend_options as fkSource for FK resolution
|
||||||
}, [attributes]);
|
fkSource: typeof fkSource === 'string' ? fkSource : undefined,
|
||||||
|
fkDisplayField: 'label',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [attributes, instanceId]);
|
||||||
|
|
||||||
// Check permissions
|
// Check permissions (general level)
|
||||||
|
// Row-level permissions are handled automatically by FormGeneratorTable
|
||||||
const canCreate = permissions?.create !== 'n';
|
const canCreate = permissions?.create !== 'n';
|
||||||
|
const canUpdate = permissions?.update !== 'n';
|
||||||
const canDelete = permissions?.delete !== 'n';
|
const canDelete = permissions?.delete !== 'n';
|
||||||
|
|
||||||
// Handle create click
|
// Handle create click
|
||||||
|
|
@ -70,7 +93,12 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
||||||
setIsCreateMode(true);
|
setIsCreateMode(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle form submit
|
// Handle edit click
|
||||||
|
const handleEditClick = (link: TrusteePositionDocument) => {
|
||||||
|
setEditingLink(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle create form submit
|
||||||
const handleFormSubmit = async (data: Partial<TrusteePositionDocument>) => {
|
const handleFormSubmit = async (data: Partial<TrusteePositionDocument>) => {
|
||||||
const result = await handleCreate(data);
|
const result = await handleCreate(data);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|
@ -79,6 +107,16 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle edit form submit
|
||||||
|
const handleEditSubmit = async (data: Partial<TrusteePositionDocument>) => {
|
||||||
|
if (!editingLink) return;
|
||||||
|
const result = await handleUpdate(editingLink.id, data);
|
||||||
|
if (result.success) {
|
||||||
|
setEditingLink(null);
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Handle delete
|
// Handle delete
|
||||||
const handleDeleteLink = async (link: TrusteePositionDocument) => {
|
const handleDeleteLink = async (link: TrusteePositionDocument) => {
|
||||||
if (window.confirm('Verknüpfung wirklich entfernen?')) {
|
if (window.confirm('Verknüpfung wirklich entfernen?')) {
|
||||||
|
|
@ -97,7 +135,7 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
||||||
|
|
||||||
// Form attributes (exclude system fields)
|
// Form attributes (exclude system fields)
|
||||||
const formAttributes = useMemo(() => {
|
const formAttributes = useMemo(() => {
|
||||||
const excludedFields = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy'];
|
const excludedFields = ['id', 'mandateId', 'featureInstanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy'];
|
||||||
return (attributes || []).filter(attr => !excludedFields.includes(attr.name));
|
return (attributes || []).filter(attr => !excludedFields.includes(attr.name));
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
||||||
|
|
@ -174,12 +212,20 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
||||||
sortable={true}
|
sortable={true}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
actionButtons={[
|
actionButtons={[
|
||||||
|
...(canUpdate ? [{
|
||||||
|
type: 'edit' as const,
|
||||||
|
title: 'Verknüpfung bearbeiten',
|
||||||
|
// Row-level permissions handled automatically by FormGeneratorTable
|
||||||
|
}] : []),
|
||||||
...(canDelete ? [{
|
...(canDelete ? [{
|
||||||
type: 'delete' as const,
|
type: 'delete' as const,
|
||||||
title: 'Verknüpfung entfernen',
|
title: 'Verknüpfung entfernen',
|
||||||
loading: (row: TrusteePositionDocument) => deletingItems.has(row.id),
|
loading: (row: TrusteePositionDocument) => deletingItems.has(row.id),
|
||||||
|
// Row-level permissions handled automatically by FormGeneratorTable
|
||||||
}] : []),
|
}] : []),
|
||||||
]}
|
]}
|
||||||
|
attributes={formAttributes}
|
||||||
|
onEdit={handleEditClick}
|
||||||
onDelete={handleDeleteLink}
|
onDelete={handleDeleteLink}
|
||||||
hookData={{
|
hookData={{
|
||||||
refetch,
|
refetch,
|
||||||
|
|
@ -227,6 +273,42 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
{editingLink && (
|
||||||
|
<div className={styles.modalOverlay} onClick={() => setEditingLink(null)}>
|
||||||
|
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||||
|
<div className={styles.modalHeader}>
|
||||||
|
<h2 className={styles.modalTitle}>Verknüpfung bearbeiten</h2>
|
||||||
|
<button
|
||||||
|
className={styles.modalClose}
|
||||||
|
onClick={() => setEditingLink(null)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.modalContent}>
|
||||||
|
{formAttributes.length === 0 ? (
|
||||||
|
<div className={styles.loadingContainer}>
|
||||||
|
<div className={styles.spinner} />
|
||||||
|
<span>Lade Formular...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FormGeneratorForm
|
||||||
|
attributes={formAttributes}
|
||||||
|
data={editingLink}
|
||||||
|
mode="edit"
|
||||||
|
onSubmit={handleEditSubmit}
|
||||||
|
onCancel={() => setEditingLink(null)}
|
||||||
|
submitButtonText="Speichern"
|
||||||
|
cancelButtonText="Abbrechen"
|
||||||
|
instanceId={instanceId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@
|
||||||
|
|
||||||
/* Primary accent color */
|
/* Primary accent color */
|
||||||
--primary-color: #F25843;
|
--primary-color: #F25843;
|
||||||
|
--primary-color-dark: #D94A37;
|
||||||
|
--primary-color-light: rgba(242, 88, 67, 0.2);
|
||||||
--primary-light: rgba(242, 88, 67, 0.12);
|
--primary-light: rgba(242, 88, 67, 0.12);
|
||||||
--primary-dark-bg: rgba(242, 88, 67, 0.08);
|
--primary-dark-bg: rgba(242, 88, 67, 0.08);
|
||||||
|
|
||||||
|
|
@ -127,6 +129,8 @@
|
||||||
|
|
||||||
/* Primary accent color */
|
/* Primary accent color */
|
||||||
--primary-color: #F25843;
|
--primary-color: #F25843;
|
||||||
|
--primary-color-dark: #D94A37;
|
||||||
|
--primary-color-light: rgba(242, 88, 67, 0.3);
|
||||||
--primary-light: #FF9A8A; /* Lighter red for text on dark backgrounds */
|
--primary-light: #FF9A8A; /* Lighter red for text on dark backgrounds */
|
||||||
--primary-dark-bg: rgba(242, 88, 67, 0.15); /* Semi-transparent red for backgrounds */
|
--primary-dark-bg: rgba(242, 88, 67, 0.15); /* Semi-transparent red for backgrounds */
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,7 @@ export interface FeatureView {
|
||||||
label: I18nLabel;
|
label: I18nLabel;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
path: string; // Relativer Pfad innerhalb der Instanz
|
path: string; // Relativer Pfad innerhalb der Instanz
|
||||||
|
adminOnly?: boolean; // Nur für Admin-Rollen sichtbar
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -179,19 +180,27 @@ export interface FeatureConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// FEATURE REGISTRY
|
// FEATURE REGISTRY (DEPRECATED)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registry aller verfügbaren Features mit ihren Views
|
* @deprecated Since Navigation-API-Konzept implementation.
|
||||||
* Wird verwendet um Navigation zu generieren
|
*
|
||||||
|
* Navigation is now provided by the backend via GET /api/navigation.
|
||||||
|
* The backend is the Single Source of Truth for navigation structure.
|
||||||
|
*
|
||||||
|
* Icon mapping is now handled by src/config/pageRegistry.ts using uiComponent codes.
|
||||||
|
*
|
||||||
|
* This registry is kept for backward compatibility with existing code that may
|
||||||
|
* still reference it. It will be removed in a future version.
|
||||||
|
*
|
||||||
|
* TODO: Remove after all references are migrated to use backend navigation.
|
||||||
*/
|
*/
|
||||||
export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
||||||
trustee: {
|
trustee: {
|
||||||
code: 'trustee',
|
code: 'trustee',
|
||||||
label: { de: 'Treuhand', en: 'Trustee' },
|
label: { de: 'Treuhand', en: 'Trustee' },
|
||||||
icon: 'briefcase',
|
icon: 'briefcase',
|
||||||
// Note: Feature-Instanz = Organisation (kein separates Organisations-Objekt)
|
|
||||||
views: [
|
views: [
|
||||||
{ code: 'dashboard', label: { de: 'Übersicht', en: 'Dashboard' }, path: 'dashboard' },
|
{ code: 'dashboard', label: { de: 'Übersicht', en: 'Dashboard' }, path: 'dashboard' },
|
||||||
{ code: 'positions', label: { de: 'Positionen', en: 'Positions' }, path: 'positions' },
|
{ code: 'positions', label: { de: 'Positionen', en: 'Positions' }, path: 'positions' },
|
||||||
|
|
@ -219,6 +228,17 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
||||||
{ code: 'settings', label: { de: 'Einstellungen', en: 'Settings' }, path: 'settings' },
|
{ code: 'settings', label: { de: 'Einstellungen', en: 'Settings' }, path: 'settings' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
realestate: {
|
||||||
|
code: 'realestate',
|
||||||
|
label: { de: 'Immobilien', en: 'Real Estate' },
|
||||||
|
icon: 'home',
|
||||||
|
views: [
|
||||||
|
{ code: 'dashboard', label: { de: 'Übersicht', en: 'Dashboard' }, path: 'dashboard' },
|
||||||
|
{ code: 'projects', label: { de: 'Projekte', en: 'Projects' }, path: 'projects' },
|
||||||
|
{ code: 'parcels', label: { de: 'Parzellen', en: 'Parcels' }, path: 'parcels' },
|
||||||
|
{ code: 'instance-roles', label: { de: 'Rollen & Rechte', en: 'Roles & Permissions' }, path: 'instance-roles', adminOnly: true },
|
||||||
|
]
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue