rbac rules tested and fixed

This commit is contained in:
ValueOn AG 2026-01-25 03:01:07 +01:00
parent 6a406d885d
commit bf4ddc6fd5
22 changed files with 1526 additions and 344 deletions

View file

@ -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();

View file

@ -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,

View file

@ -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);
}

View file

@ -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}

View 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;

View file

@ -6,3 +6,4 @@
export { AccessRulesEditor } from './AccessRulesEditor'; export { AccessRulesEditor } from './AccessRulesEditor';
export { AccessLevelSelect } from './AccessLevelSelect'; export { AccessLevelSelect } from './AccessLevelSelect';
export { AccessRulesTable } from './AccessRulesTable';

View file

@ -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;

View file

@ -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;

View file

@ -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
View 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;

View 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;

View file

@ -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
View 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;

View file

@ -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);

View file

@ -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
}; };

View file

@ -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>

View file

@ -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>

View file

@ -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}
/> />

View file

@ -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>
)} )}

View file

@ -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>
); );
}; };

View file

@ -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 */

View file

@ -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 },
]
},
}; };
// ============================================================================= // =============================================================================