From bf4ddc6fd5baa9d4d73e6f87dc7244477956b26a Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 25 Jan 2026 03:01:07 +0100 Subject: [PATCH] rbac rules tested and fixed --- src/api.ts | 32 +- src/api/trusteeApi.ts | 13 + .../AccessRules/AccessRules.module.css | 257 +++++++++++ .../AccessRules/AccessRulesEditor.tsx | 206 +++++++-- .../AccessRules/AccessRulesTable.tsx | 246 +++++++++++ src/components/AccessRules/index.ts | 1 + .../FormGeneratorTable/FormGeneratorTable.tsx | 17 +- .../Navigation/MandateNavigation.module.css | 20 + .../Navigation/MandateNavigation.tsx | 405 ++++++------------ src/config/pageRegistry.tsx | 135 ++++++ src/hooks/useCatalogObjects.ts | 117 +++++ src/hooks/useInstancePermissions.tsx | 67 ++- src/hooks/useNavigation.ts | 176 ++++++++ src/hooks/usePermissions.ts | 11 +- src/hooks/useTrustee.ts | 7 +- .../admin/AdminFeatureInstanceUsersPage.tsx | 2 +- src/pages/admin/AdminFeatureRolesPage.tsx | 5 +- .../admin/AdminMandateRolePermissionsPage.tsx | 4 +- .../trustee/TrusteeInstanceRolesView.tsx | 1 + .../trustee/TrusteePositionDocumentsView.tsx | 116 ++++- src/styles/themes/light.css | 4 + src/types/mandate.ts | 28 +- 22 files changed, 1526 insertions(+), 344 deletions(-) create mode 100644 src/components/AccessRules/AccessRulesTable.tsx create mode 100644 src/config/pageRegistry.tsx create mode 100644 src/hooks/useCatalogObjects.ts create mode 100644 src/hooks/useNavigation.ts diff --git a/src/api.ts b/src/api.ts index 0745a84..fa1ddb9 100644 --- a/src/api.ts +++ b/src/api.ts @@ -20,6 +20,24 @@ const resolveHostnameToIP = async (hostname: string): Promise => } }; +/** + * 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(); diff --git a/src/api/trusteeApi.ts b/src/api/trusteeApi.ts index 9ea5a53..a95c2ad 100644 --- a/src/api/trusteeApi.ts +++ b/src/api/trusteeApi.ts @@ -725,6 +725,19 @@ export async function createPositionDocument( }); } +export async function updatePositionDocument( + request: ApiRequestFunction, + instanceId: string, + linkId: string, + data: Partial +): Promise { + return await request({ + url: `${_getTrusteeBaseUrl(instanceId)}/position-documents/${linkId}`, + method: 'put', + data + }); +} + export async function deletePositionDocument( request: ApiRequestFunction, instanceId: string, diff --git a/src/components/AccessRules/AccessRules.module.css b/src/components/AccessRules/AccessRules.module.css index 2b36518..7730262 100644 --- a/src/components/AccessRules/AccessRules.module.css +++ b/src/components/AccessRules/AccessRules.module.css @@ -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); +} diff --git a/src/components/AccessRules/AccessRulesEditor.tsx b/src/components/AccessRules/AccessRulesEditor.tsx index f4f1f82..2f0c13b 100644 --- a/src/components/AccessRules/AccessRulesEditor.tsx +++ b/src/components/AccessRules/AccessRulesEditor.tsx @@ -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 = ({ rule, readOnly, onUpdate, onDelete interface AddRuleFormProps { context: RuleContext; + availableObjects: CatalogObject[]; onAdd: (rule: AccessRuleCreate) => void; onCancel: () => void; } -const AddRuleForm: React.FC = ({ context, onAdd, onCancel }) => { +const AddRuleForm: React.FC = ({ context, availableObjects, onAdd, onCancel }) => { const [item, setItem] = useState(''); + const [useCustom, setUseCustom] = useState(false); const [view, setView] = useState(true); const [read, setRead] = useState('n'); const [create, setCreate] = useState('n'); const [update, setUpdate] = useState('n'); const [del, setDel] = useState('n'); + // Group objects by feature + const groupedObjects = useMemo(() => { + const grouped: Record = {}; + 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 = ({ 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 (
- - setItem(e.target.value)} - placeholder={getPlaceholder()} - className={styles.formInput} - autoFocus - /> +
+ + +
+ + {useCustom ? ( + setItem(e.target.value)} + placeholder={getPlaceholder()} + className={styles.formInput} + autoFocus + /> + ) : ( + + )} + - 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.*).
@@ -214,23 +272,46 @@ const AddRuleForm: React.FC = ({ context, onAdd, onCancel }) = {context === 'DATA' && ( -
-
- Read - -
-
- Create - -
-
- Update - -
-
- Delete - +
+ {/* Header Row */} +
+
+
Eigene (m)
+
Gruppe (g)
+
Alle (a)
+ + {/* 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 ( +
+
{labels[op]}
+ {(['m', 'g', 'a'] as const).map(level => ( +
+ { + 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'}`} + /> +
+ ))} +
+ ); + })}
)} @@ -253,6 +334,7 @@ const AddRuleForm: React.FC = ({ context, onAdd, onCancel }) = interface RulesSectionProps { context: RuleContext; rules: AccessRule[]; + availableObjects: CatalogObject[]; readOnly?: boolean; onUpdate: (ruleId: string, updates: Partial) => void; onDelete: (ruleId: string) => void; @@ -262,12 +344,14 @@ interface RulesSectionProps { const RulesSection: React.FC = ({ 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 = ({ {rules.length} {rules.length === 1 ? 'Regel' : 'Regeln'} - +
+ {/* View Toggle */} + {context === 'DATA' && rules.length > 0 && ( + <> + + + + )} + +
)} {showAddForm && ( setShowAddForm(false)} /> @@ -324,6 +430,14 @@ const RulesSection: React.FC = ({

)}
+ ) : useTableView && context === 'DATA' ? ( + ) : ( rules.map(rule => ( = ({ onSave, apiBasePath = '/api/rbac', mandateId, + featureCode, }) => { const { rules, @@ -427,6 +542,9 @@ export const AccessRulesEditor: React.FC = ({ removeRuleLocally, } = useAccessRules(roleId, apiBasePath, mandateId); + // Catalog objects for dropdown selection + const { objects: catalogObjects, fetchObjects } = useCatalogObjects(); + const [activeTab, setActiveTab] = useState('DATA'); const [hasChanges, setHasChanges] = useState(false); const [originalRules, setOriginalRules] = useState([]); @@ -438,6 +556,17 @@ export const AccessRulesEditor: React.FC = ({ }); }, [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 = ({ = ({ = ({ ) => 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) => void; + onDelete: (ruleId: string) => void; +} + +const AccessRuleRow: React.FC = ({ + 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 ; + case 'UI': return ; + case 'RESOURCE': return ; + default: return ; + } + }; + + return ( + + {/* Object Name */} + + {getContextIcon()} + {rule.item || '(global)'} + + + {/* View Checkbox */} + + onUpdate(rule.id, { view: e.target.checked })} + disabled={readOnly} + title="Sichtbar" + /> + + + {/* CRUD Checkboxes for DATA context */} + {isDataContext && ( + <> + {/* EIGENE (m) */} + {(['create', 'read', 'update', 'delete'] as const).map(op => ( + + handleLevelToggle(op, 'm', e.target.checked)} + disabled={readOnly} + title={`${op.charAt(0).toUpperCase() + op.slice(1)} - Eigene`} + /> + + ))} + + {/* GRUPPE (g) */} + {(['create', 'read', 'update', 'delete'] as const).map(op => ( + + handleLevelToggle(op, 'g', e.target.checked)} + disabled={readOnly} + title={`${op.charAt(0).toUpperCase() + op.slice(1)} - Gruppe`} + /> + + ))} + + {/* ALLE (a) */} + {(['create', 'read', 'update', 'delete'] as const).map(op => ( + + handleLevelToggle(op, 'a', e.target.checked)} + disabled={readOnly} + title={`${op.charAt(0).toUpperCase() + op.slice(1)} - Alle`} + /> + + ))} + + )} + + {/* Delete Button */} + + {!readOnly && ( + + )} + + + ); +}; + +// ============================================================================= +// MAIN TABLE COMPONENT +// ============================================================================= + +export const AccessRulesTable: React.FC = ({ + rules, + context, + readOnly, + onUpdate, + onDelete, +}) => { + const isDataContext = context === 'DATA'; + + if (rules.length === 0) { + return null; + } + + return ( +
+ + + + + + {isDataContext && ( + <> + + + + + )} + + + {isDataContext && ( + + + + + + + + + + + + + + + + + + )} + + + {rules.map(rule => ( + + ))} + +
Objekt (Dot-Notation)ViewEigene (m)Gruppe (g)Alle (a)
CRUDCRUDCRUD
+
+ ); +}; + +export default AccessRulesTable; diff --git a/src/components/AccessRules/index.ts b/src/components/AccessRules/index.ts index 16d0f83..3fe301e 100644 --- a/src/components/AccessRules/index.ts +++ b/src/components/AccessRules/index.ts @@ -6,3 +6,4 @@ export { AccessRulesEditor } from './AccessRulesEditor'; export { AccessLevelSelect } from './AccessLevelSelect'; +export { AccessRulesTable } from './AccessRulesTable'; diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index ee7fc6b..936231f 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -1590,7 +1590,22 @@ export function FormGeneratorTable>({ 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; diff --git a/src/components/Navigation/MandateNavigation.module.css b/src/components/Navigation/MandateNavigation.module.css index 150f65c..dcb8358 100644 --- a/src/components/Navigation/MandateNavigation.module.css +++ b/src/components/Navigation/MandateNavigation.module.css @@ -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; diff --git a/src/components/Navigation/MandateNavigation.tsx b/src/components/Navigation/MandateNavigation.tsx index d7d2adf..2c36e7a 100644 --- a/src/components/Navigation/MandateNavigation.tsx +++ b/src/components/Navigation/MandateNavigation.tsx @@ -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 = { - trustee: , - chatbot: , - chatworkflow: , -}; +/** + * 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] || , + 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 = () => ( +
+ + Navigation wird geladen... +
+); + // ============================================================================= // 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: , - path: '/', - }, - { - id: 'settings', - label: 'Einstellungen', - icon: , - path: '/settings', - }, - ], - }); - - // Workflows section (global pages) - items.push({ - type: 'section', - title: 'WORKFLOWS', - children: [ - { - id: 'workflows-playground', - label: 'Chat Playground', - icon: , - path: '/workflows/playground', - }, - { - id: 'workflows-list', - label: 'Scheduler', - icon: , - path: '/workflows/list', - }, - { - id: 'workflows-automations', - label: 'Automations', - icon: , - path: '/workflows/automations', - }, - ], - }); - - // Basisdaten section (global pages) - items.push({ - type: 'section', - title: 'BASISDATEN', - children: [ - { - id: 'basedata-prompts', - label: 'Prompts', - icon: , - path: '/basedata/prompts', - }, - { - id: 'basedata-files', - label: 'Files', - icon: , - path: '/basedata/files', - }, - { - id: 'basedata-connections', - label: 'Connections', - icon: , - path: '/basedata/connections', - }, - ], - }); - - // Migrate to Feature Instances section (temporary) - items.push({ - type: 'section', - title: 'MIGRATE TO FEATURES', - children: [ - { - id: 'migrate-chatbot', - label: 'Chatbot', - icon: , - path: '/chatbot', - }, - { - id: 'migrate-pek', - label: 'PEK', - icon: , - path: '/pek', - }, - { - id: 'migrate-speech', - label: 'Speech', - icon: , - 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: , - path: '/admin/users', - }, - { - id: 'admin-invitations', - label: 'Einladungen', - icon: , - path: '/admin/invitations', - }, - { - id: 'admin-mandates', - label: 'Mandanten', - icon: , - path: '/admin/mandates', - }, - { - id: 'admin-mandate-roles', - label: 'Rollen', - icon: , - path: '/admin/mandate-roles', - }, - { - id: 'admin-mandate-role-permissions', - label: 'Rollen-Berechtigungen', - icon: , - path: '/admin/mandate-role-permissions', - }, - { - id: 'admin-user-mandates', - label: 'Mandanten-Mitglieder', - icon: , - path: '/admin/user-mandates', - }, - { - id: 'admin-feature-roles', - label: 'Feature-Rollen', - icon: , - path: '/admin/feature-roles', - }, - { - id: 'admin-feature-instances', - label: 'Feature-Instanzen', - icon: , - path: '/admin/feature-instances', - }, - { - id: 'admin-feature-users', - label: 'Feature-Benutzer', - icon: , - 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 ( +
+ +
+ ); + } return (
- {hasAnyInstance() || isSysAdmin ? ( + {hasNavigation ? ( ) : ( - <> - - - + )}
); diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx new file mode 100644 index 0000000..e8127b8 --- /dev/null +++ b/src/config/pageRegistry.tsx @@ -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 = { + // System pages + 'page.system.home': , + 'page.system.settings': , + 'page.system.playground': , + 'page.system.chats': , + 'page.system.automations': , + 'page.system.prompts': , + 'page.system.files': , + 'page.system.connections': , + 'page.system.chatbot': , + 'page.system.pek': , + 'page.system.speech': , + + // Admin pages + 'page.admin.users': , + 'page.admin.invitations': , + 'page.admin.mandates': , + 'page.admin.roles': , + 'page.admin.role-permissions': , + 'page.admin.user-mandates': , + 'page.admin.feature-roles': , + 'page.admin.feature-instances': , + 'page.admin.feature-users': , + + // Feature pages - Trustee + 'page.feature.trustee.dashboard': , + 'page.feature.trustee.positions': , + 'page.feature.trustee.documents': , + 'page.feature.trustee.position-documents': , + 'page.feature.trustee.instance-roles': , + + // Feature pages - Real Estate + 'page.feature.realestate.projects': , + 'page.feature.realestate.parcels': , + + // Feature icons (for feature grouping in navigation) + 'feature.trustee': , + 'feature.realestate': , + 'feature.chatworkflow': , + 'feature.chatbot': , +}; + +// ============================================================================= +// 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] || ; +} + +/** + * 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; diff --git a/src/hooks/useCatalogObjects.ts b/src/hooks/useCatalogObjects.ts new file mode 100644 index 0000000..8069453 --- /dev/null +++ b/src/hooks/useCatalogObjects.ts @@ -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; + 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; + getObjectsByContext: (context: RuleContext) => CatalogObject[]; + getObjectByKey: (objectKey: string) => CatalogObject | undefined; +} + +// ============================================================================= +// HOOK +// ============================================================================= + +export function useCatalogObjects(): UseCatalogObjectsReturn { + const [objects, setObjects] = useState({ DATA: [], UI: [], RESOURCE: [] }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 => { + 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(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; diff --git a/src/hooks/useInstancePermissions.tsx b/src/hooks/useInstancePermissions.tsx index 86ef345..e39556d 100644 --- a/src/hooks/useInstancePermissions.tsx +++ b/src/hooks/useInstancePermissions.tsx @@ -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 { - const { instance } = useCurrentInstance(); + const { instance, featureCode } = useCurrentInstance(); return useMemo(() => { const result: Record = {}; + 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]); } // ============================================================================= diff --git a/src/hooks/useNavigation.ts b/src/hooks/useNavigation.ts new file mode 100644 index 0000000..2b6c4a0 --- /dev/null +++ b/src/hooks/useNavigation.ts @@ -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; +} + +// ============================================================================= +// 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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchNavigation = useCallback(async () => { + setLoading(true); + setError(null); + + try { + // New API endpoint: /api/navigation (without /system prefix) + const response = await api.get( + `/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; diff --git a/src/hooks/usePermissions.ts b/src/hooks/usePermissions.ts index 256577d..cd4f584 100644 --- a/src/hooks/usePermissions.ts +++ b/src/hooks/usePermissions.ts @@ -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); diff --git a/src/hooks/useTrustee.ts b/src/hooks/useTrustee.ts index 55f1aea..edf2422 100644 --- a/src/hooks/useTrustee.ts +++ b/src/hooks/useTrustee.ts @@ -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(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 = { fetchAll: fetchPositionDocumentsApi, fetchById: async () => null, create: createPositionDocumentApi, - update: async () => { throw new Error('Update not supported for position-document links'); }, + update: updatePositionDocumentApi, deleteItem: deletePositionDocumentApi }; diff --git a/src/pages/admin/AdminFeatureInstanceUsersPage.tsx b/src/pages/admin/AdminFeatureInstanceUsersPage.tsx index e026b91..f7b3441 100644 --- a/src/pages/admin/AdminFeatureInstanceUsersPage.tsx +++ b/src/pages/admin/AdminFeatureInstanceUsersPage.tsx @@ -392,7 +392,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
-

Feature-Benutzer

+

Feature Instanz Benutzer

Verwalten Sie Benutzerzugriffe auf Feature-Instanzen

diff --git a/src/pages/admin/AdminFeatureRolesPage.tsx b/src/pages/admin/AdminFeatureRolesPage.tsx index 5021dfa..d8ca3d6 100644 --- a/src/pages/admin/AdminFeatureRolesPage.tsx +++ b/src/pages/admin/AdminFeatureRolesPage.tsx @@ -261,8 +261,8 @@ export const AdminFeatureRolesPage: React.FC = () => {
-

Feature-Rollen

-

Template-Rollen für Feature-Instanzen verwalten

+

Feature Rollen & Rechte

+

Template-Rollen und deren Berechtigungen für Feature-Instanzen verwalten

@@ -479,6 +479,7 @@ export const AdminFeatureRolesPage: React.FC = () => { roleName={permissionsRole.roleLabel} isTemplate={true} onSave={() => setPermissionsRole(null)} + featureCode={permissionsRole.featureCode} />
diff --git a/src/pages/admin/AdminMandateRolePermissionsPage.tsx b/src/pages/admin/AdminMandateRolePermissionsPage.tsx index eba7f09..ec94651 100644 --- a/src/pages/admin/AdminMandateRolePermissionsPage.tsx +++ b/src/pages/admin/AdminMandateRolePermissionsPage.tsx @@ -188,7 +188,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => { 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). @@ -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} /> diff --git a/src/pages/views/trustee/TrusteeInstanceRolesView.tsx b/src/pages/views/trustee/TrusteeInstanceRolesView.tsx index 8d63f2b..710deac 100644 --- a/src/pages/views/trustee/TrusteeInstanceRolesView.tsx +++ b/src/pages/views/trustee/TrusteeInstanceRolesView.tsx @@ -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" /> )} diff --git a/src/pages/views/trustee/TrusteePositionDocumentsView.tsx b/src/pages/views/trustee/TrusteePositionDocumentsView.tsx index e7960c2..c9db848 100644 --- a/src/pages/views/trustee/TrusteePositionDocumentsView.tsx +++ b/src/pages/views/trustee/TrusteePositionDocumentsView.tsx @@ -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(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) => { 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) => { + 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 = () => { )} + + {/* Edit Modal */} + {editingLink && ( +
setEditingLink(null)}> +
e.stopPropagation()}> +
+

Verknüpfung bearbeiten

+ +
+
+ {formAttributes.length === 0 ? ( +
+
+ Lade Formular... +
+ ) : ( + setEditingLink(null)} + submitButtonText="Speichern" + cancelButtonText="Abbrechen" + instanceId={instanceId} + /> + )} +
+
+
+ )}
); }; diff --git a/src/styles/themes/light.css b/src/styles/themes/light.css index c392110..527de54 100644 --- a/src/styles/themes/light.css +++ b/src/styles/themes/light.css @@ -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 */ diff --git a/src/types/mandate.ts b/src/types/mandate.ts index a3f8bf5..1fd1c2e 100644 --- a/src/types/mandate.ts +++ b/src/types/mandate.ts @@ -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 = { 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 = { { 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 }, + ] + }, }; // =============================================================================