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';
|
||||
|
||||
const api = axios.create({
|
||||
|
|
@ -27,7 +45,7 @@ const api = axios.create({
|
|||
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(
|
||||
async (config) => {
|
||||
// Log backend information
|
||||
|
|
@ -63,6 +81,18 @@ api.interceptors.request.use(
|
|||
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)
|
||||
// Some endpoints like /api/realestate/* require CSRF tokens even for GET requests
|
||||
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(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
|
|
|
|||
|
|
@ -533,3 +533,260 @@
|
|||
font-size: 0.75rem;
|
||||
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.
|
||||
* 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 {
|
||||
FaTable,
|
||||
FaDesktop,
|
||||
|
|
@ -16,6 +21,8 @@ import {
|
|||
FaSave,
|
||||
FaUndo,
|
||||
FaSpinner,
|
||||
FaThList,
|
||||
FaTh,
|
||||
} from 'react-icons/fa';
|
||||
import {
|
||||
useAccessRules,
|
||||
|
|
@ -24,7 +31,9 @@ import {
|
|||
type AccessLevel,
|
||||
type AccessRuleCreate,
|
||||
} from '../../hooks/useAccessRules';
|
||||
import { useCatalogObjects, type CatalogObject } from '../../hooks/useCatalogObjects';
|
||||
import { AccessLevelSelect } from './AccessLevelSelect';
|
||||
import { AccessRulesTable } from './AccessRulesTable';
|
||||
import styles from './AccessRules.module.css';
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -39,6 +48,7 @@ interface AccessRulesEditorProps {
|
|||
onSave?: () => void;
|
||||
apiBasePath?: string;
|
||||
mandateId?: string;
|
||||
featureCode?: string; // Filter catalog objects to this feature only
|
||||
}
|
||||
|
||||
type TabType = 'DATA' | 'UI' | 'RESOURCE' | 'JSON';
|
||||
|
|
@ -150,18 +160,32 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
|
|||
|
||||
interface AddRuleFormProps {
|
||||
context: RuleContext;
|
||||
availableObjects: CatalogObject[];
|
||||
onAdd: (rule: AccessRuleCreate) => 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 [useCustom, setUseCustom] = useState(false);
|
||||
const [view, setView] = useState(true);
|
||||
const [read, setRead] = useState<AccessLevel>('n');
|
||||
const [create, setCreate] = useState<AccessLevel>('n');
|
||||
const [update, setUpdate] = 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) => {
|
||||
e.preventDefault();
|
||||
const newRule: AccessRuleCreate = {
|
||||
|
|
@ -176,28 +200,62 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, onAdd, onCancel }) =
|
|||
const getPlaceholder = () => {
|
||||
switch (context) {
|
||||
case 'DATA':
|
||||
return 'z.B. TrusteeContract oder TrusteeContract.salary';
|
||||
return 'z.B. data.feature.trustee.TrusteePosition';
|
||||
case 'UI':
|
||||
return 'z.B. nav.trustee oder button.export';
|
||||
return 'z.B. ui.feature.trustee.dashboard';
|
||||
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 (
|
||||
<form className={styles.addRuleForm} onSubmit={handleSubmit}>
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.formLabel}>Item (Dot-Notation)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={item}
|
||||
onChange={(e) => setItem(e.target.value)}
|
||||
placeholder={getPlaceholder()}
|
||||
className={styles.formInput}
|
||||
autoFocus
|
||||
/>
|
||||
<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
|
||||
type="text"
|
||||
value={item}
|
||||
onChange={(e) => setItem(e.target.value)}
|
||||
placeholder={getPlaceholder()}
|
||||
className={styles.formInput}
|
||||
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}>
|
||||
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>
|
||||
</div>
|
||||
|
||||
|
|
@ -214,23 +272,46 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, onAdd, onCancel }) =
|
|||
</div>
|
||||
|
||||
{context === 'DATA' && (
|
||||
<div className={styles.permissionsGrid} style={{ marginTop: '0.5rem' }}>
|
||||
<div className={styles.permissionItem}>
|
||||
<span className={styles.permissionLabel}>Read</span>
|
||||
<AccessLevelSelect value={read} onChange={setRead} compact />
|
||||
</div>
|
||||
<div className={styles.permissionItem}>
|
||||
<span className={styles.permissionLabel}>Create</span>
|
||||
<AccessLevelSelect value={create} onChange={setCreate} compact />
|
||||
</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 className={styles.addRuleMatrix}>
|
||||
{/* Header Row */}
|
||||
<div className={styles.matrixHeader}>
|
||||
<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>
|
||||
|
||||
{/* CRUD Rows */}
|
||||
{(['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>
|
||||
)}
|
||||
|
||||
|
|
@ -253,6 +334,7 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, onAdd, onCancel }) =
|
|||
interface RulesSectionProps {
|
||||
context: RuleContext;
|
||||
rules: AccessRule[];
|
||||
availableObjects: CatalogObject[];
|
||||
readOnly?: boolean;
|
||||
onUpdate: (ruleId: string, updates: Partial<AccessRule>) => void;
|
||||
onDelete: (ruleId: string) => void;
|
||||
|
|
@ -262,12 +344,14 @@ interface RulesSectionProps {
|
|||
const RulesSection: React.FC<RulesSectionProps> = ({
|
||||
context,
|
||||
rules,
|
||||
availableObjects,
|
||||
readOnly,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onAdd,
|
||||
}) => {
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [useTableView, setUseTableView] = useState(context === 'DATA'); // Default to table for DATA
|
||||
|
||||
const handleAdd = (rule: AccessRuleCreate) => {
|
||||
onAdd(rule);
|
||||
|
|
@ -297,18 +381,40 @@ const RulesSection: React.FC<RulesSectionProps> = ({
|
|||
<span className={styles.sectionTitle}>
|
||||
{rules.length} {rules.length === 1 ? 'Regel' : 'Regeln'}
|
||||
</span>
|
||||
<button
|
||||
className={styles.addButton}
|
||||
onClick={() => setShowAddForm(true)}
|
||||
>
|
||||
<FaPlus /> Neue Regel
|
||||
</button>
|
||||
<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
|
||||
className={styles.addButton}
|
||||
onClick={() => setShowAddForm(true)}
|
||||
>
|
||||
<FaPlus /> Neue Regel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddForm && (
|
||||
<AddRuleForm
|
||||
context={context}
|
||||
availableObjects={availableObjects}
|
||||
onAdd={handleAdd}
|
||||
onCancel={() => setShowAddForm(false)}
|
||||
/>
|
||||
|
|
@ -324,6 +430,14 @@ const RulesSection: React.FC<RulesSectionProps> = ({
|
|||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : useTableView && context === 'DATA' ? (
|
||||
<AccessRulesTable
|
||||
rules={rules}
|
||||
context={context}
|
||||
readOnly={readOnly}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
) : (
|
||||
rules.map(rule => (
|
||||
<RuleCard
|
||||
|
|
@ -413,6 +527,7 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
|
|||
onSave,
|
||||
apiBasePath = '/api/rbac',
|
||||
mandateId,
|
||||
featureCode,
|
||||
}) => {
|
||||
const {
|
||||
rules,
|
||||
|
|
@ -427,6 +542,9 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
|
|||
removeRuleLocally,
|
||||
} = useAccessRules(roleId, apiBasePath, mandateId);
|
||||
|
||||
// Catalog objects for dropdown selection
|
||||
const { objects: catalogObjects, fetchObjects } = useCatalogObjects();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>('DATA');
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [originalRules, setOriginalRules] = useState<AccessRule[]>([]);
|
||||
|
|
@ -438,6 +556,17 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
|
|||
});
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
setHasChanges(JSON.stringify(rules) !== JSON.stringify(originalRules));
|
||||
|
|
@ -568,6 +697,7 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
|
|||
<RulesSection
|
||||
context="DATA"
|
||||
rules={groupedRules.DATA}
|
||||
availableObjects={catalogObjects.DATA || []}
|
||||
readOnly={readOnly}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
|
|
@ -578,6 +708,7 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
|
|||
<RulesSection
|
||||
context="UI"
|
||||
rules={groupedRules.UI}
|
||||
availableObjects={catalogObjects.UI || []}
|
||||
readOnly={readOnly}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
|
|
@ -588,6 +719,7 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
|
|||
<RulesSection
|
||||
context="RESOURCE"
|
||||
rules={groupedRules.RESOURCE}
|
||||
availableObjects={catalogObjects.RESOURCE || []}
|
||||
readOnly={readOnly}
|
||||
onUpdate={handleUpdate}
|
||||
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 { 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'
|
||||
? actionButton.title(row)
|
||||
: 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 isProcessing = actionButton.isProcessing ? actionButton.isProcessing(row) : false;
|
||||
|
||||
|
|
|
|||
|
|
@ -248,6 +248,26 @@
|
|||
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 */
|
||||
.emptyState {
|
||||
padding: 1.5rem 1rem;
|
||||
|
|
|
|||
|
|
@ -4,88 +4,84 @@
|
|||
* Hierarchische Navigation für das Multi-Tenant-System.
|
||||
* Verwendet TreeNavigation für flexible Baumstruktur.
|
||||
*
|
||||
* Struktur:
|
||||
* - SYSTEM (immer verfügbar)
|
||||
* - Mandant 1
|
||||
* - Feature A
|
||||
* - Instanz 1 (mit Views)
|
||||
* - Instanz 2 (mit Views)
|
||||
* - Feature B
|
||||
* - Instanz 3 (mit Views)
|
||||
* - Mandant 2
|
||||
* - ...
|
||||
* - ADMINISTRATION (nur für SysAdmin)
|
||||
* Navigation wird vollständig vom Backend geladen (/api/navigation).
|
||||
* 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
|
||||
* - Feature A
|
||||
* - Instanz 1 (mit Views)
|
||||
* - WORKFLOWS (static block, order: 20)
|
||||
* - BASISDATEN (static block, order: 30)
|
||||
* - MIGRATE TO FEATURES (static block, order: 40)
|
||||
* - ADMINISTRATION (static block, order: 200)
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { useMandates, useFeatureStore } from '../../stores/featureStore';
|
||||
import { useCurrentUser } from '../../hooks/useUsers';
|
||||
import { FEATURE_REGISTRY, getLabel } from '../../types/mandate';
|
||||
import type { Mandate, MandateFeature, FeatureInstance } from '../../types/mandate';
|
||||
import {
|
||||
FaHome, FaCog, FaBriefcase, FaRobot, FaPlay, FaBuilding, FaUsers, FaUserTag,
|
||||
FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt,
|
||||
FaLightbulb, FaRegFileAlt, FaLink, FaComments, FaChartBar, FaMicrophone,
|
||||
FaListAlt, FaCogs
|
||||
} from 'react-icons/fa';
|
||||
import { useNavigation } from '../../hooks/useNavigation';
|
||||
import type {
|
||||
StaticBlock,
|
||||
DynamicBlock,
|
||||
NavigationItem,
|
||||
NavigationMandate,
|
||||
MandateFeature,
|
||||
FeatureInstance,
|
||||
FeatureView
|
||||
} from '../../hooks/useNavigation';
|
||||
import { getPageIcon } from '../../config/pageRegistry';
|
||||
import { FaSpinner } from 'react-icons/fa';
|
||||
import { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigation';
|
||||
import styles from './MandateNavigation.module.css';
|
||||
|
||||
// =============================================================================
|
||||
// ICON MAPPING
|
||||
// HELPER FUNCTIONS - Convert API blocks to TreeItems
|
||||
// =============================================================================
|
||||
|
||||
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
||||
trustee: <FaBriefcase />,
|
||||
chatbot: <FaRobot />,
|
||||
chatworkflow: <FaPlay />,
|
||||
};
|
||||
/**
|
||||
* Convert a NavigationItem (from static block) to TreeNodeItem
|
||||
*/
|
||||
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
|
||||
*/
|
||||
function instanceToTreeNode(
|
||||
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}`,
|
||||
}));
|
||||
|
||||
function featureInstanceToTreeNode(instance: FeatureInstance): TreeNodeItem {
|
||||
return {
|
||||
id: instance.id,
|
||||
label: instance.instanceLabel,
|
||||
// Note: badge für userRole entfernt - ein User kann mehrere Rollen haben
|
||||
children,
|
||||
label: instance.uiLabel,
|
||||
children: instance.views.map(featureViewToTreeNode),
|
||||
defaultExpanded: false,
|
||||
};
|
||||
}
|
||||
|
|
@ -93,38 +89,31 @@ function instanceToTreeNode(
|
|||
/**
|
||||
* Convert a MandateFeature to TreeNodeItem
|
||||
*/
|
||||
function featureToTreeNode(
|
||||
feature: MandateFeature,
|
||||
mandateId: string
|
||||
): TreeNodeItem | null {
|
||||
function mandateFeatureToTreeNode(feature: MandateFeature): TreeNodeItem | null {
|
||||
if (feature.instances.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const children = feature.instances.map(instance =>
|
||||
instanceToTreeNode(instance, mandateId, feature.code)
|
||||
);
|
||||
|
||||
return {
|
||||
id: `${mandateId}-${feature.code}`,
|
||||
label: getLabel(feature.label),
|
||||
icon: FEATURE_ICONS[feature.code] || <FaBriefcase />,
|
||||
id: feature.uiComponent,
|
||||
label: feature.uiLabel,
|
||||
icon: getPageIcon(feature.uiComponent),
|
||||
badge: feature.instances.length,
|
||||
children,
|
||||
children: feature.instances.map(featureInstanceToTreeNode),
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const children = mandate.features
|
||||
.map(feature => featureToTreeNode(feature, mandate.id))
|
||||
.map(mandateFeatureToTreeNode)
|
||||
.filter((node): node is TreeNodeItem => node !== null);
|
||||
|
||||
if (children.length === 0) {
|
||||
|
|
@ -133,12 +122,32 @@ function mandateToTreeNode(mandate: Mandate): TreeNodeItem | null {
|
|||
|
||||
return {
|
||||
id: mandate.id,
|
||||
label: mandate.name,
|
||||
label: mandate.uiLabel,
|
||||
children,
|
||||
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
|
||||
// =============================================================================
|
||||
|
|
@ -157,212 +166,68 @@ const EmptyState: React.FC = () => (
|
|||
// =============================================================================
|
||||
|
||||
export const MandateNavigation: React.FC = () => {
|
||||
const mandates = useMandates();
|
||||
const { hasAnyInstance } = useFeatureStore();
|
||||
const { user } = useCurrentUser();
|
||||
// Fetch navigation from new API (blocks structure, already filtered by permissions)
|
||||
const { blocks, loading } = useNavigation('de');
|
||||
|
||||
// Get isSysAdmin from user data
|
||||
const isSysAdmin = user?.isSysAdmin ?? false;
|
||||
|
||||
// Build navigation items using TreeNavigation structure
|
||||
// Build navigation items from blocks
|
||||
const navigationItems: TreeItem[] = useMemo(() => {
|
||||
const items: TreeItem[] = [];
|
||||
|
||||
// System section (always visible)
|
||||
items.push({
|
||||
type: 'section',
|
||||
title: 'SYSTEM',
|
||||
children: [
|
||||
{
|
||||
id: 'home',
|
||||
label: 'Übersicht',
|
||||
icon: <FaHome />,
|
||||
path: '/',
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Einstellungen',
|
||||
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' });
|
||||
|
||||
// Mandate nodes (if user has instances)
|
||||
if (hasAnyInstance()) {
|
||||
const mandateNodes = mandates
|
||||
.map(mandate => mandateToTreeNode(mandate))
|
||||
.filter((node): node is TreeNodeItem => node !== null);
|
||||
|
||||
if (mandateNodes.length > 0) {
|
||||
items.push(...mandateNodes);
|
||||
// Process blocks in order (already sorted by backend)
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'static') {
|
||||
// Static block: system, workflows, basedata, migrate, admin
|
||||
if (block.items.length > 0) {
|
||||
// Add separator before admin block
|
||||
if (block.id === 'admin') {
|
||||
items.push({ type: 'separator' });
|
||||
}
|
||||
items.push(staticBlockToTreeItem(block));
|
||||
}
|
||||
} else if (block.type === 'dynamic') {
|
||||
// Dynamic block: features/mandates
|
||||
// Add separator before dynamic block
|
||||
items.push({ type: 'separator' });
|
||||
|
||||
const mandateNodes = dynamicBlockToTreeNodes(block);
|
||||
if (mandateNodes.length > 0) {
|
||||
items.push(...mandateNodes);
|
||||
}
|
||||
|
||||
// Add separator after dynamic block (before next static blocks)
|
||||
items.push({ type: 'separator' });
|
||||
}
|
||||
}
|
||||
|
||||
// Admin section (only for SysAdmin)
|
||||
if (isSysAdmin) {
|
||||
items.push({ type: 'separator' });
|
||||
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',
|
||||
},
|
||||
],
|
||||
});
|
||||
// Remove trailing separator if present
|
||||
while (items.length > 0 && (items[items.length - 1] as TreeItem & { type?: string }).type === 'separator') {
|
||||
items.pop();
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={styles.navigation}>
|
||||
{hasAnyInstance() || isSysAdmin ? (
|
||||
{hasNavigation ? (
|
||||
<TreeNavigation
|
||||
items={navigationItems}
|
||||
autoExpandActive={true}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<TreeNavigation
|
||||
items={navigationItems.slice(0, 2)} // System section + separator
|
||||
autoExpandActive={true}
|
||||
/>
|
||||
<EmptyState />
|
||||
</>
|
||||
<EmptyState />
|
||||
)}
|
||||
</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 {
|
||||
const { instance } = useCurrentInstance();
|
||||
const { instance, featureCode } = useCurrentInstance();
|
||||
|
||||
if (!instance?.permissions?.views) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const views = instance.permissions.views;
|
||||
|
||||
// Check for wildcard "_all" permission first (item=None in backend = all views)
|
||||
if (instance.permissions.views["_all"]) {
|
||||
if (views["_all"]) {
|
||||
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
|
||||
* Supports both legacy format and fully qualified objectKey format
|
||||
*/
|
||||
export function useViewPermissions(viewCodes: string[]): Record<string, boolean> {
|
||||
const { instance } = useCurrentInstance();
|
||||
const { instance, featureCode } = useCurrentInstance();
|
||||
|
||||
return useMemo(() => {
|
||||
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 => {
|
||||
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;
|
||||
}, [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];
|
||||
}
|
||||
|
||||
// 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)
|
||||
console.log(`⚠️ usePermissions: ${context}:${item} not in bulk cache, fetching individually`);
|
||||
return fetchIndividualPermission(context, item);
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import {
|
|||
// Position-Document API
|
||||
fetchPositionDocuments as fetchPositionDocumentsApi,
|
||||
createPositionDocument as createPositionDocumentApi,
|
||||
updatePositionDocument as updatePositionDocumentApi,
|
||||
deletePositionDocument as deletePositionDocumentApi,
|
||||
} from '../api/trusteeApi';
|
||||
|
||||
|
|
@ -148,7 +149,9 @@ function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntit
|
|||
|
||||
const fetchPermissions = useCallback(async () => {
|
||||
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);
|
||||
return perms;
|
||||
} catch (error: any) {
|
||||
|
|
@ -592,7 +595,7 @@ const positionDocumentConfig: TrusteeEntityConfig<TrusteePositionDocument> = {
|
|||
fetchAll: fetchPositionDocumentsApi,
|
||||
fetchById: async () => null,
|
||||
create: createPositionDocumentApi,
|
||||
update: async () => { throw new Error('Update not supported for position-document links'); },
|
||||
update: updatePositionDocumentApi,
|
||||
deleteItem: deletePositionDocumentApi
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -392,7 +392,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
|||
<div className={styles.adminPage}>
|
||||
<div className={styles.pageHeader}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -261,8 +261,8 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
|||
<div className={styles.adminPage}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>Feature-Rollen</h1>
|
||||
<p className={styles.pageSubtitle}>Template-Rollen für Feature-Instanzen verwalten</p>
|
||||
<h1 className={styles.pageTitle}>Feature Rollen & Rechte</h1>
|
||||
<p className={styles.pageSubtitle}>Template-Rollen und deren Berechtigungen für Feature-Instanzen verwalten</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -479,6 +479,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
|||
roleName={permissionsRole.roleLabel}
|
||||
isTemplate={true}
|
||||
onSave={() => setPermissionsRole(null)}
|
||||
featureCode={permissionsRole.featureCode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
|||
<FaShieldAlt style={{ marginRight: '0.5rem' }} />
|
||||
<span>
|
||||
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>
|
||||
</div>
|
||||
|
||||
|
|
@ -246,7 +246,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
|||
roleId={role.id}
|
||||
roleName={role.roleLabel}
|
||||
isTemplate={false}
|
||||
readOnly={role.isSystemRole}
|
||||
readOnly={false} // All AccessRules are editable (access controlled via RBAC)
|
||||
apiBasePath="/api/rbac"
|
||||
mandateId={selectedMandateId}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@ export const TrusteeInstanceRolesView: React.FC = () => {
|
|||
isTemplate={false}
|
||||
apiBasePath={`/api/trustee/${instance.id}/instance-roles/${role.id}`}
|
||||
mandateId={instance.mandateId}
|
||||
featureCode="trustee"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* TrusteePositionDocumentsView
|
||||
*
|
||||
* 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';
|
||||
|
|
@ -32,12 +32,14 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
|||
const {
|
||||
handleDelete,
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
deletingItems,
|
||||
creatingItem,
|
||||
} = useTrusteePositionDocumentOperations();
|
||||
|
||||
// Modal state
|
||||
const [isCreateMode, setIsCreateMode] = useState(false);
|
||||
const [editingLink, setEditingLink] = useState<TrusteePositionDocument | null>(null);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
|
|
@ -46,23 +48,44 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
|||
}
|
||||
}, [instanceId]);
|
||||
|
||||
// Generate columns from attributes
|
||||
// Generate columns from attributes (like TrusteePositionsView)
|
||||
// Map frontend_options to fkSource for FK resolution
|
||||
const columns = useMemo(() => {
|
||||
return (attributes || []).map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as any,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
width: attr.width || 150,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
}));
|
||||
}, [attributes]);
|
||||
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,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as any,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
width: attr.width || 200,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
// Use frontend_options as fkSource for FK resolution
|
||||
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 canUpdate = permissions?.update !== 'n';
|
||||
const canDelete = permissions?.delete !== 'n';
|
||||
|
||||
// Handle create click
|
||||
|
|
@ -70,7 +93,12 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
|||
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 result = await handleCreate(data);
|
||||
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
|
||||
const handleDeleteLink = async (link: TrusteePositionDocument) => {
|
||||
if (window.confirm('Verknüpfung wirklich entfernen?')) {
|
||||
|
|
@ -97,7 +135,7 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
|||
|
||||
// Form attributes (exclude system fields)
|
||||
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));
|
||||
}, [attributes]);
|
||||
|
||||
|
|
@ -174,12 +212,20 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
|||
sortable={true}
|
||||
selectable={false}
|
||||
actionButtons={[
|
||||
...(canUpdate ? [{
|
||||
type: 'edit' as const,
|
||||
title: 'Verknüpfung bearbeiten',
|
||||
// Row-level permissions handled automatically by FormGeneratorTable
|
||||
}] : []),
|
||||
...(canDelete ? [{
|
||||
type: 'delete' as const,
|
||||
title: 'Verknüpfung entfernen',
|
||||
loading: (row: TrusteePositionDocument) => deletingItems.has(row.id),
|
||||
// Row-level permissions handled automatically by FormGeneratorTable
|
||||
}] : []),
|
||||
]}
|
||||
attributes={formAttributes}
|
||||
onEdit={handleEditClick}
|
||||
onDelete={handleDeleteLink}
|
||||
hookData={{
|
||||
refetch,
|
||||
|
|
@ -227,6 +273,42 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
|||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@
|
|||
|
||||
/* Primary accent color */
|
||||
--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-dark-bg: rgba(242, 88, 67, 0.08);
|
||||
|
||||
|
|
@ -127,6 +129,8 @@
|
|||
|
||||
/* Primary accent color */
|
||||
--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-dark-bg: rgba(242, 88, 67, 0.15); /* Semi-transparent red for backgrounds */
|
||||
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ export interface FeatureView {
|
|||
label: I18nLabel;
|
||||
icon?: string;
|
||||
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
|
||||
* Wird verwendet um Navigation zu generieren
|
||||
* @deprecated Since Navigation-API-Konzept implementation.
|
||||
*
|
||||
* 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> = {
|
||||
trustee: {
|
||||
code: 'trustee',
|
||||
label: { de: 'Treuhand', en: 'Trustee' },
|
||||
icon: 'briefcase',
|
||||
// Note: Feature-Instanz = Organisation (kein separates Organisations-Objekt)
|
||||
views: [
|
||||
{ code: 'dashboard', label: { de: 'Übersicht', en: 'Dashboard' }, path: 'dashboard' },
|
||||
{ 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' },
|
||||
]
|
||||
},
|
||||
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