fixed merge conflicts

This commit is contained in:
Ida Dittrich 2026-01-27 09:25:59 +01:00
commit 04590b78c9
168 changed files with 31191 additions and 632 deletions

View file

@ -0,0 +1,246 @@
# PR-Bericht: frontend_nyla (feat/saas-multi-tenant-mandates)
**Ziel-Branch:** `int`
**Feature-Branch:** `feat/saas-multi-tenant-mandates`
**Stand:** Januar 2026
---
## Übersicht
| Metrik | Wert |
|--------|------|
| **Anzahl Commits** | 73 |
| **Geänderte Dateien** | 455 |
| **Zeilen hinzugefügt** | ~78'604 |
| **Zeilen gelöscht** | ~15'070 |
---
## Wichtigste inhaltliche Änderungen
### 1. SaaS Multi-Mandate & Mandatsverwaltung
- Mandaten-Navigation und Mandatenwechsel im UI
- Mandats-Einladungen und Benachrichtigungssystem
- Mandats-Rollen und -Berechtigungen (RBAC)
- Zugriff auf Seiten und Features mandatenbasiert
- UID-/ID-Mapping und Referenzen für Multi-Mandate angepasst
### 2. Admin-Bereich
- Admin-Seiten: Mandate, RBAC-Rollen, RBAC-Regeln, Team-Mitglieder
- Feature-Zugriff, Benutzer-Zugriff, Mandats-Rollen-Berechtigungen
- Einladungsverwaltung, Benutzer-Mandate, Benutzerübersicht
- RBAC-Export/Import
### 3. Trustee (Treuhand)-Feature
- Trustee-Views: Dashboard, Positionen, Dokumente, Verträge, Rollen, Organisationen, Access
- Expense-Import-View, Positions-Dokumente
- Trustee-API und Hooks (`useTrustee`, `useTrusteeOptions`)
### 4. Workflows & Automation
- Workflow-Playground mit zentralem State, Log-Polling, Lifecycle-Hooks
- Automations-Seite und Workflows-Seite
- Workflow-Statistiken, verbesserte Log-Darstellung
- Chatbot-Integration (Messages, Dateien, Lifecycle)
### 5. PEK (Projekte & Stammdaten)
- Isoliert in Folder
### 6. Authentifizierung & Nutzerverwaltung
- Magic-Link-Login
- Passwort-Reset (Request & Reset-Seiten)
- Registrierung und Login überarbeitet
- Erweiterter Auth-Hook und CSRF-Utilities
### 7. GDPR & Rechtliches
- GDPR-konforme Seite/Flows
- PowerOn Home, Datenschutz, AGB als statische HTML-Seiten
### 8. UI/UX & Architektur
- Neues Page-Management (`core/PageManager`) mit datengetriebenen Seiten
- FormGenerator: Aufteilung in Form, List, Table, Controls, Action-Buttons
- Server-seitige Filter/Sort für generische Tabellen, Scroll-Lock für Header
- Sidebar überarbeitet, Mandaten-Navigation, Tree-Navigation, UserSection
- Notification-Bell, Access-Rules-Editor
- Layouts: `MainLayout`, `FeatureLayout`; Seiten: `FeatureView`, `Dashboard`, `Settings`
- Diverse UI-Komponenten (Log, Messages, MapView, Tabs, Toast, ViewForm, WorkflowStatus, etc.)
### 9. Konfiguration & Deployment
- Konfiguration in `config/` (env, serverConfig, universalConfig)
- Scripts nach `scripts/` (server, deploy-server)
- GitHub-Workflows angepasst, Node-Version 20
### 10. Weitere Features & Fixes
- Speech-Integration (Prototyp), Speech-Seiten & -Transkripte
- Real-Estate/Privilege-Checker isoliert in Folder
- Althaus-Seite, PEK-Tabs umbenannt (Projects, Data Management)
- Einstellungen-Seite, Basedata-Seiten (Connections, Files, Prompts)
- Privilege-Caching und -Checker konsolidiert
- Diverse Build- und TypeScript-Fixes, Hotfixes
---
## Geänderte Dateien (nach Bereichen)
### Konfiguration & Projekt
- `.cursorignore`, `.gitignore`, `.github/workflows/*.yml`
- `README.md`, `index.html`, `package.json`, `package-lock.json`
- `vite.config.ts`
- `config/*` (neu), `.env.*``config/.env.*`
- `scripts/server.js`, `scripts/deploy-server.js`
- `public/`: Favicon, Logos, `poweron-home.html`, `poweron-privacy.html`, `poweron-terms.html`
### API-Schicht
- `src/api.ts` (erweitert)
- Neu: `src/api/attributesApi.ts`, `authApi.ts`, `automationApi.ts`, `chatbotApi.ts`, `connectionApi.ts`, `featuresApi.ts`, `fileApi.ts`, `mandateApi.ts`, `permissionApi.ts`, `promptApi.ts`, `rbacRulesApi.ts`, `roleApi.ts`, `trusteeApi.ts`, `userApi.ts`, `workflowApi.ts`
### Auth & Provider
- `src/providers/auth/AuthProvider.tsx`, `ProtectedRoute.tsx`, `authConfig.ts`
- `src/providers/language/LanguageContext.tsx`
- `src/auth/ProtectedRoute.tsx` (entfernt/ersetzt)
### Core: PageManager
- `src/core/PageManager/PageManager.tsx`, `PageRenderer.tsx`, `SidebarProvider.tsx`, `pageInterface.ts`
- `src/core/PageManager/data/pages/*`: admin (mandates, rbac-role, rbac-rules, team-members), automations, chatbot, connections, dashboard, files, pek, pek-tables, prompts, settings, speech, speech-transcripts, trustee (access, contracts, documents, organisations, positions, roles), workflows
- `src/config/pageRegistry.tsx`
### Komponenten (Auswahl)
- **AccessRules:** AccessLevelSelect, AccessRulesEditor, AccessRulesTable + Styles
- **FormGenerator:** FormGeneratorControls, FormGeneratorForm, FormGeneratorList, FormGeneratorTable, ActionButtons (Copy, Custom, Delete, Download, Edit, Remove, View)
- **Navigation:** MandateNavigation, TreeNavigation, UserSection + Styles
- **NotificationBell**, **ContentPreview** (inkl. Renderer)
- **Sidebar:** Sidebar, SidebarItem, SidebarSubmenu, SidebarUser, Styles, Logic, Types
- **Dashboard/DashboardChat:** teils entfernt/umgebaut (Playground-basiert)
- **Connections, Dateien, Mitglieder, PageManager, Popup, Prompts:** entfernt oder nach core/Pages migriert
- **Speech:** SpeechConfirmation, SpeechInfo, SpeechSettings, SpeechSignUp
- **TestSharepoint:** Tabelle, Logic, Interfaces
- **Workflows:** WorkflowsTable, workflowsLogic, workflowsTypes
- **UiComponents:** AutoScroll, Button, ConnectedFilesList, CopyableTruncatedValue, DragDropOverlay, DropdownSelect, InfoMessageOverlay, LocationInput, Log/LogMessage, MapView, Messages/ChatMessages, ParcelInfoPanel, Popup, Tabs, TextField, Toast, ViewForm, VoiceLanguageSelect, WorkflowStatus
- **settings:** settingsUser
### Hooks
- Neu/erweitert: `useAccessRules`, `useAdminMandates`, `useAdminRbacRoles`, `useAdminRbacRules`, `useAuthentication`, `useAutomations`, `useChatbot`, `useConnections`, `useCurrentInstance`, `useFeatureAccess`, `useFiles`, `useInstancePermissions`, `useInvitations`, `useMandateRoles`, `useMandates`, `useNavigation`, `useNotifications`, `usePek`, `usePekTables`, `usePermissions`, `usePlayground`, `usePrompts`, `useRbacExportImport`, `useResizablePanels`, `useRoles`, `useSettings`, `useTrustee`, `useTrusteeOptions`, `useUserMandates`, `useUsers`, `useWorkflows`
- Playground: `useDashboardInputForm`, `useDashboardLogTree`, `useWorkflowLifecycle`, `useWorkflowPolling`, `useWorkflows`, `playgroundUtils`
- Entfernt: `useSharePointTest`
### Seiten
- **Neu:** `Dashboard.tsx`, `FeatureView.tsx`, `GDPR.tsx`, `InvitePage.tsx`, `PasswordResetRequest.tsx`, `Reset.tsx`, `Settings.tsx`
- **Login, Register:** überarbeitet
- **Home:** Connections, Dashboard, Dateien, Einstellungen, Prompts, TeamBereich, TestSharepoint, Workflows entfernt/ausgelagert; `Home.tsx` angepasst
- **admin/:** AdminFeatureAccessPage, AdminFeatureInstanceUsersPage, AdminFeatureRolesPage, AdminInvitationsPage, AdminMandateRolePermissionsPage, AdminMandateRolesPage, AdminMandatesPage, AdminUserAccessOverviewPage, AdminUserMandatesPage, AdminUsersPage
- **basedata/:** ConnectionsPage, FilesPage, PromptsPage
- **migrate/:** ChatbotPage, PekPage, SpeechPage
- **views/trustee/:** TrusteeDashboardView, TrusteeDocumentsView, TrusteeExpenseImportView, TrusteeInstanceRolesView, TrusteePositionDocumentsView, TrusteePositionsView
- **workflows/:** AutomationsPage, PlaygroundPage, WorkflowsPage
### Contexts, Stores, Utils, Locales
- **Contexts:** FileContext, PekContext, PekTablesContext, ToastContext, WorkflowSelectionContext
- **Stores:** featureStore
- **Utils:** attributeTypeMapper, csrfUtils, privilegeCheckers, time, userCache
- **Types:** mandate.ts
- **Locales:** de.ts, en.ts, fr.ts (erweitert)
### Styles & Assets
- `src/styles/`: buttons.css, pages.module.css, themes (dark, light), assets/bg.jpg
- `src/assets/styles/light.css` entfernt
- `src/index.css`, `src/main.tsx`
### Dokumentation
- `docs/DASHBOARD_LOG_POLLING_DOCUMENTATION.md` (neu)
- `documentation/sidebar.md` (entfernt)
- `.cursor/plans/implement_rbac_roles_page_*.plan.md` (Cursor-Plan)
---
## Commit-Historie (chronologisch)
| Datum | Commit | Nachricht |
|-------|--------|-----------|
| 2025-09-10 | 0bd6091 | updated cofig logic |
| 2025-09-15 | 9fc33c7 | feat: added speech integration prototype |
| 2025-09-16 | 9e7c3b2 | implemented feedback |
| 2025-09-18 | 41aa0fd | minor bugfixing |
| 2025-10-01 | 05f51c4 | working on action button |
| 2025-10-08 | b238ab8 | fixed action buttons |
| 2025-10-12 | 6988984 | finished files page |
| 2025-10-12 | 9519fed | pushing to int |
| 2025-10-12 | 8a0e5f8 | fix: ready for build |
| 2025-12-01 | 101b306 | added PEK pages |
| 2025-12-01 | aa34508 | updated the table view |
| 2025-12-15 | aaf64b8 | resumed backend integration, RBAC focus |
| 2025-12-15 | f8d5c0a | fix: geolinien outline |
| 2025-12-15 | 78889bf | fix: collapsed sidebar |
| 2025-12-22 | bfbe3f8 | PEK updates |
| 2025-12-30 | cf76e89 | pek update |
| 2025-12-30 | 94e8681 | fix: centralized workflow state management on dashboard page |
| 2025-12-30 | 14273c2 | Merge PR #2 feat/real-estate |
| 2025-12-30 | d3c950d | fix: consolidated privilegechecker and usepermissions hook |
| 2026-01-02 | 641930b | updated log rendering |
| 2026-01-02 | 401c088 | fix: fixed styling of log messages |
| 2026-01-02 | ae6a634 | feature: show workflow stats |
| 2026-01-05 | c76e7ef | feat: completely build up althaus page |
| 2026-01-05 | 079d398 | fix: build errors removed |
| 2026-01-05 | 6315c9a | fix: constant reload |
| 2026-01-05 | 6c90a00 | fix: another build error |
| 2026-01-05 | 05508cc | fix: privilege caching led to no pages showing |
| 2026-01-05 | 826eead | fix: added more rolelabel logging |
| 2026-01-05 | 48754d6 | fix: typescript build errors |
| 2026-01-05 | fc55a25 | fix: added more rolelabel logging |
| 2026-01-05 | 5f22c7b | fix: added more rolelabel logging |
| 2026-01-05 | eb280db | feat: completely build up althaus page |
| 2026-01-05 | c80ad96 | Rename PEK tabs: Projects and Data Management |
| 2026-01-05 | dd79895 | Merge int |
| 2026-01-05 | b0826a3 | feat: weiter chatbot implementiert |
| 2026-01-05 | 23508ea | fix: merge conflicts |
| 2026-01-05 | 350cc7b | fix: geolinien outline |
| 2026-01-05 | 407a3c4 | PEK updates |
| 2026-01-05 | c5a82dd | feat: multiselect parcels and create projects |
| 2026-01-09 | 836b803 | fix: fixed and finished chatbot integration |
| 2026-01-09 | 3df83f0 | fix: button fix |
| 2026-01-12 | 7d794ef | Merge feat/chatbot into int |
| 2026-01-12 | 5808bd4 | fixed merge conflicts |
| 2026-01-12 | 239fd32 | fix: readded deleted code, fixed build |
| 2026-01-12 | be3844f | feat: privilege checker into real estate pages |
| 2026-01-12 | eaf69f4 | feat/fix: added admin pages, fixed sidebar width |
| 2026-01-12 | 64d14af | feat: finished admin pages |
| 2026-01-12 | acdcf2c | fix: moved team-members file to correct place |
| 2026-01-12 | 06ffce8 | fix: fixed build |
| 2026-01-13 | 9fc0c9b | user magic link implemented |
| 2026-01-13 | 7d2808d | hotfix |
| 2026-01-13 | b2c38e7 | Fixed UI issues |
| 2026-01-13 | 71666d2 | hotfixes |
| 2026-01-13 | de98b86 | hotfixes |
| 2026-01-13 | c5d60c4 | node-version 20 |
| 2026-01-13 | 2b96ab7 | fixed trustee access |
| 2026-01-14 | 54ba020 | fix: fixed formgenerator layout and design |
| 2026-01-17 | 8033ca9 | prepared multimandate |
| 2026-01-20 | 70c84dd | revised ui components |
| 2026-01-21 | 7f07a55 | saas mandates core done |
| 2026-01-21 | 537b624 | fixed uid mapping to id |
| 2026-01-21 | d387322 | serverside filter and sort for form generic |
| 2026-01-21 | 34d4646 | generic form table scroll lock for headers |
| 2026-01-21 | f99b6b7 | saas multi mandate tested |
| 2026-01-22 | b207c0c | dyn options in api |
| 2026-01-23 | dc4b475 | refactored pages ui access with saas mandates |
| 2026-01-24 | cc8770d | reference fixes |
| 2026-01-24 | 41e02b5 | fixed automation and trustee |
| 2026-01-24 | 5952074 | access rules editor enhanced |
| 2026-01-24 | 6a406d8 | fixes |
| 2026-01-25 | bf4ddc6 | rbac rules tested and fixed |
| 2026-01-25 | 2b220fe | gpdr compliancy implemented |
| 2026-01-26 | 28af4cb | mandate invitation and notification system |
| 2026-01-26 | f41e6d0 | fixed ai call end to end with saas multimandate |
---
## Hinweise für das PR-Review
1. **Breite Änderung:** Viele Dateien und neue Strukturen; Fokus auf PageManager, Admin, Trustee und Workflow/Playground empfohlen.
2. **Auth & Mandate:** Magic Link, Reset-Flow und Mandatenwechsel sollten manuell geprüft werden.
3. **RBAC:** Admin RBAC-Seiten und Access Rules Editor sind kritisch für Berechtigungen.
4. **Build & Lint:** `npm run build` und Linter im CI prüfen.
5. **Doku:** `docs/DASHBOARD_LOG_POLLING_DOCUMENTATION.md` für Playground/Logging konsultieren.
---
*Bericht erstellt aus der Git-Historie `main..HEAD` im Repository `frontend_nyla` (Branch `feat/saas-multi-tenant-mandates`).*

View file

@ -2,9 +2,13 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%- VITE_APP_NAME %></title>
<!-- Google Fonts - DM Sans -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="60" fill="none" xmlns:v="https://vecta.io/nano"><g clip-path="url(#A)" fill="#fffefd"><path d="M9.001 28.103c-3.277-.84-4.094-1.246-4.094-2.492v-.055c0-.923.84-1.657 2.442-1.657s3.249.706 4.929 1.869l2.169-3.143c-1.92-1.546-4.278-2.414-7.043-2.414-3.877 0-6.637 2.275-6.637 5.718v.055c0 3.766 2.465 4.823 6.286 5.802 3.171.812 3.822 1.357 3.822 2.414v.055c0 1.112-1.029 1.791-2.737 1.791-2.169 0-3.955-.895-5.663-2.303L.01 36.697c2.275 2.031 5.174 3.032 8.049 3.032 4.094 0 6.965-2.114 6.965-5.88v-.055c0-3.305-2.169-4.689-6.018-5.691h-.005zm22.582-.923c0 1.625-1.218 2.871-3.305 2.871h-3.309v-5.797h3.226c2.086 0 3.388 1.002 3.388 2.871v.055zm-3.037-6.692h-7.749v18.969h4.172v-5.691h3.171c4.251 0 7.671-2.275 7.671-6.665v-.055c0-3.877-2.737-6.558-7.265-6.558zm16.939 0h-4.172v18.969h4.172V20.488zm4.384 3.664v.18h5.769v15.125h4.172V24.332h5.774v-3.845H49.869v3.665zm27.794 11.783c-3.254 0-5.501-2.709-5.501-5.963v-.055c0-3.249 2.303-5.908 5.501-5.908 1.897 0 3.388.812 4.846 2.142l2.654-3.06c-1.758-1.735-3.905-2.926-7.477-2.926-5.829 0-9.891 4.417-9.891 9.808v.055c0 5.446 4.145 9.757 9.729 9.757 3.66 0 5.825-1.301 7.777-3.388l-2.654-2.681c-1.491 1.352-2.82 2.22-4.985 2.22zm23.746-15.447v7.509h-7.694v-7.509h-4.172v18.969h4.172v-7.615h7.694v7.615h4.172V20.488h-4.172zM56.995 0h-8.571l4.288 7.615L56.995 0zm-8.571 60h8.571l-4.283-7.615L48.424 60z"/></g><defs><clipPath id="A"><path fill="#fff" d="M0 0h105.582v60H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

192
public/poweron-home.html Normal file
View file

@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="google-site-verification" content="HF3EVKLJvD7jp5xiS-r7in7Jo01_okijtWzDSnu_YhQ" />
<title>PowerOn AI Platform - Home</title>
<link rel="icon" type="image/x-icon" href="./favicon.ico">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f8fafc;
color: #1e293b;
line-height: 1.6;
font-size: 16px;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
background-color: #ffffff;
min-height: 100vh;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 2px solid #e2e8f0;
}
.header h1 {
color: #3b82f6;
font-size: 2.5rem;
margin-bottom: 1rem;
}
.header p {
color: #64748b;
font-size: 1.1rem;
}
.content-section {
margin-bottom: 2.5rem;
}
.content-section h2 {
color: #1e293b;
font-size: 1.5rem;
margin-bottom: 1rem;
border-left: 4px solid #3b82f6;
padding-left: 1rem;
}
.content-section p {
margin-bottom: 1rem;
color: #475569;
}
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin: 2rem 0;
}
.feature-card {
background-color: #f1f5f9;
padding: 1.5rem;
border-radius: 0.5rem;
border-left: 4px solid #3b82f6;
}
.feature-card h3 {
color: #1e293b;
margin-bottom: 0.5rem;
}
.feature-card p {
color: #64748b;
font-size: 0.95rem;
}
.navigation {
text-align: center;
margin-top: 3rem;
padding-top: 2rem;
border-top: 2px solid #e2e8f0;
}
.nav-link {
display: inline-block;
margin: 0 1rem;
padding: 0.75rem 1.5rem;
background-color: #3b82f6;
color: white;
text-decoration: none;
border-radius: 0.25rem;
transition: background-color 0.2s ease;
}
.nav-link:hover {
background-color: #2563eb;
}
.footer {
text-align: center;
margin-top: 2rem;
color: #64748b;
font-size: 0.9rem;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>PowerOn AI Platform</h1>
<p>Intelligent Workflow Automation & Multi-Agent Collaboration</p>
</div>
<div class="content-section">
<h2>What is PowerOn?</h2>
<p>
PowerOn is an advanced AI-powered platform that revolutionizes how businesses manage workflows,
collaborate with AI agents, and automate complex processes. Our platform combines cutting-edge
artificial intelligence with intuitive workflow design tools to help organizations work smarter,
not harder.
</p>
</div>
<div class="content-section">
<h2>Core Capabilities</h2>
<div class="features">
<div class="feature-card">
<h3>AI Agent Management</h3>
<p>Create, configure, and manage multiple AI agents for different business tasks and workflows.</p>
</div>
<div class="feature-card">
<h3>Workflow Automation</h3>
<p>Design and execute complex business processes with drag-and-drop workflow builder.</p>
</div>
<div class="feature-card">
<h3>Document Processing</h3>
<p>Intelligent document extraction, analysis, and generation powered by AI.</p>
</div>
<div class="feature-card">
<h3>Multi-Platform Integration</h3>
<p>Seamlessly connect with Microsoft 365, SharePoint, Outlook, and web services.</p>
</div>
</div>
</div>
<div class="content-section">
<h2>Who Benefits from PowerOn?</h2>
<p>
PowerOn is designed for businesses of all sizes that want to leverage AI to streamline operations,
improve productivity, and reduce manual workload. Whether you're managing customer relationships,
processing documents, or coordinating team workflows, PowerOn provides the tools you need to succeed
in the AI-powered future.
</p>
</div>
<div class="content-section">
<h2>Key Benefits</h2>
<ul style="color: #475569; margin-left: 2rem;">
<li>Reduce manual work by up to 80% through intelligent automation</li>
<li>Improve accuracy and consistency in business processes</li>
<li>Enable 24/7 operation with AI agents that never sleep</li>
<li>Scale operations without proportional increase in human resources</li>
<li>Gain insights from AI-powered analytics and reporting</li>
</ul>
</div>
<div class="navigation">
<a href="poweron-privacy.html" class="nav-link">Privacy Policy</a>
<a href="poweron-terms.html" class="nav-link">Terms of Service</a>
</div>
<div class="footer">
<p>&copy; 2025 PowerOn AI Platform. All rights reserved.</p>
</div>
</div>
</body>
</html>

290
public/poweron-privacy.html Normal file
View file

@ -0,0 +1,290 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PowerOn AI Platform - Privacy Policy</title>
<link rel="icon" type="image/x-icon" href="./favicon.ico">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f8fafc;
color: #1e293b;
line-height: 1.6;
font-size: 16px;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
background-color: #ffffff;
min-height: 100vh;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 2px solid #e2e8f0;
}
.header h1 {
color: #3b82f6;
font-size: 2.5rem;
margin-bottom: 1rem;
}
.header p {
color: #64748b;
font-size: 1.1rem;
}
.content-section {
margin-bottom: 2.5rem;
}
.content-section h2 {
color: #1e293b;
font-size: 1.5rem;
margin-bottom: 1rem;
border-left: 4px solid #3b82f6;
padding-left: 1rem;
}
.content-section h3 {
color: #1e293b;
font-size: 1.2rem;
margin: 1.5rem 0 0.75rem 0;
}
.content-section p {
margin-bottom: 1rem;
color: #475569;
}
.content-section ul {
margin-left: 2rem;
margin-bottom: 1rem;
}
.content-section li {
color: #475569;
margin-bottom: 0.5rem;
}
.highlight-box {
background-color: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
padding: 1.5rem;
margin: 1.5rem 0;
}
.highlight-box h4 {
color: #1e293b;
margin-bottom: 0.75rem;
}
.navigation {
text-align: center;
margin-top: 3rem;
padding-top: 2rem;
border-top: 2px solid #e2e8f0;
}
.nav-link {
display: inline-block;
margin: 0 1rem;
padding: 0.75rem 1.5rem;
background-color: #3b82f6;
color: white;
text-decoration: none;
border-radius: 0.25rem;
transition: background-color 0.2s ease;
}
.nav-link:hover {
background-color: #2563eb;
}
.footer {
text-align: center;
margin-top: 2rem;
color: #64748b;
font-size: 0.9rem;
}
.last-updated {
background-color: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 2rem;
text-align: center;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Privacy Policy</h1>
<p>PowerOn AI Platform - Data Protection & Privacy</p>
</div>
<div class="last-updated">
<strong>Last Updated:</strong> August 2025
</div>
<div class="content-section">
<h2>Introduction</h2>
<p>
PowerOn AI Platform ("we," "our," or "us") is committed to protecting your privacy and ensuring
the security of your personal information. This Privacy Policy explains how we collect, use,
disclose, and safeguard your information when you use our AI-powered workflow automation platform.
</p>
</div>
<div class="content-section">
<h2>Information We Collect</h2>
<h3>Personal Information</h3>
<p>We may collect the following types of personal information:</p>
<ul>
<li>Name and contact information (email address, phone number)</li>
<li>Company and job title information</li>
<li>Authentication credentials and account settings</li>
<li>Payment and billing information</li>
</ul>
<h3>Usage Information</h3>
<p>We automatically collect information about how you use our platform:</p>
<ul>
<li>Workflow creation and execution data</li>
<li>AI agent interactions and configurations</li>
<li>Document processing activities</li>
<li>Platform access logs and performance metrics</li>
</ul>
<h3>Technical Information</h3>
<p>We collect technical information to ensure platform functionality:</p>
<ul>
<li>Device and browser information</li>
<li>IP address and location data</li>
<li>Cookies and similar tracking technologies</li>
<li>System performance and error logs</li>
</ul>
</div>
<div class="content-section">
<h2>How We Use Your Information</h2>
<p>We use the collected information for the following purposes:</p>
<ul>
<li>Provide and maintain our AI platform services</li>
<li>Process and execute your workflow automations</li>
<li>Improve platform performance and user experience</li>
<li>Send important service updates and notifications</li>
<li>Provide customer support and technical assistance</li>
<li>Ensure platform security and prevent fraud</li>
<li>Comply with legal obligations and regulations</li>
</ul>
</div>
<div class="content-section">
<h2>Data Sharing and Disclosure</h2>
<p>We do not sell, trade, or rent your personal information to third parties. We may share your information only in the following circumstances:</p>
<div class="highlight-box">
<h4>Service Providers</h4>
<p>We work with trusted third-party service providers who assist us in operating our platform, such as cloud hosting services, payment processors, and AI model providers. These providers are contractually obligated to protect your information.</p>
</div>
<div class="highlight-box">
<h4>Legal Requirements</h4>
<p>We may disclose your information if required by law, court order, or government regulation, or to protect our rights, property, or safety.</p>
</div>
<div class="highlight-box">
<h4>Business Transfers</h4>
<p>In the event of a merger, acquisition, or sale of assets, your information may be transferred as part of the business transaction.</p>
</div>
</div>
<div class="content-section">
<h2>Data Security</h2>
<p>We implement comprehensive security measures to protect your information:</p>
<ul>
<li>Encryption of data in transit and at rest</li>
<li>Regular security audits and vulnerability assessments</li>
<li>Access controls and authentication mechanisms</li>
<li>Secure data centers and infrastructure</li>
<li>Employee training on data protection practices</li>
</ul>
</div>
<div class="content-section">
<h2>Your Rights and Choices</h2>
<p>You have the following rights regarding your personal information:</p>
<ul>
<li><strong>Access:</strong> Request a copy of your personal information</li>
<li><strong>Correction:</strong> Update or correct inaccurate information</li>
<li><strong>Deletion:</strong> Request deletion of your personal information</li>
<li><strong>Portability:</strong> Receive your data in a portable format</li>
<li><strong>Opt-out:</strong> Unsubscribe from marketing communications</li>
</ul>
</div>
<div class="content-section">
<h2>Data Retention</h2>
<p>We retain your personal information only as long as necessary to:</p>
<ul>
<li>Provide our services to you</li>
<li>Comply with legal obligations</li>
<li>Resolve disputes and enforce agreements</li>
<li>Improve our platform and services</li>
</ul>
<p>When we no longer need your information, we securely delete or anonymize it.</p>
</div>
<div class="content-section">
<h2>International Data Transfers</h2>
<p>Your information may be transferred to and processed in countries other than your own. We ensure that such transfers comply with applicable data protection laws and implement appropriate safeguards to protect your information.</p>
</div>
<div class="content-section">
<h2>Children's Privacy</h2>
<p>Our platform is not intended for use by children under the age of 13. We do not knowingly collect personal information from children under 13. If you believe we have collected such information, please contact us immediately.</p>
</div>
<div class="content-section">
<h2>Changes to This Policy</h2>
<p>We may update this Privacy Policy from time to time. We will notify you of any material changes by posting the new policy on our platform and updating the "Last Updated" date. Your continued use of our platform after such changes constitutes acceptance of the updated policy.</p>
</div>
<div class="content-section">
<h2>Contact Us</h2>
<p>If you have any questions about this Privacy Policy or our data practices, please contact us:</p>
<div class="highlight-box">
<p><strong>Email:</strong> privacy@poweron-ai.com</p>
<p><strong>Address:</strong> PowerOn AI Platform, Privacy Team</p>
</div>
</div>
<div class="navigation">
<a href="poweron-home.html" class="nav-link">Home</a>
<a href="poweron-terms.html" class="nav-link">Terms of Service</a>
</div>
<div class="footer">
<p>&copy; 2025 PowerOn AI Platform. All rights reserved.</p>
</div>
</div>
</body>
</html>

333
public/poweron-terms.html Normal file
View file

@ -0,0 +1,333 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PowerOn AI Platform - Terms of Service</title>
<link rel="icon" type="image/x-icon" href="./favicon.ico">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f8fafc;
color: #1e293b;
line-height: 1.6;
font-size: 16px;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
background-color: #ffffff;
min-height: 100vh;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 2px solid #e2e8f0;
}
.header h1 {
color: #3b82f6;
font-size: 2.5rem;
margin-bottom: 1rem;
}
.header p {
color: #64748b;
font-size: 1.1rem;
}
.content-section {
margin-bottom: 2.5rem;
}
.content-section h2 {
color: #1e293b;
font-size: 1.5rem;
margin-bottom: 1rem;
border-left: 4px solid #3b82f6;
padding-left: 1rem;
}
.content-section h3 {
color: #1e293b;
font-size: 1.2rem;
margin: 1.5rem 0 0.75rem 0;
}
.content-section p {
margin-bottom: 1rem;
color: #475569;
}
.content-section ul {
margin-left: 2rem;
margin-bottom: 1rem;
}
.content-section li {
color: #475569;
margin-bottom: 0.5rem;
}
.highlight-box {
background-color: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
padding: 1.5rem;
margin: 1.5rem 0;
}
.highlight-box h4 {
color: #1e293b;
margin-bottom: 0.75rem;
}
.warning-box {
background-color: #fef2f2;
border: 1px solid #fecaca;
border-radius: 0.5rem;
padding: 1.5rem;
margin: 1.5rem 0;
}
.warning-box h4 {
color: #dc2626;
margin-bottom: 0.75rem;
}
.navigation {
text-align: center;
margin-top: 3rem;
padding-top: 2rem;
border-top: 2px solid #e2e8f0;
}
.nav-link {
display: inline-block;
margin: 0 1rem;
padding: 0.75rem 1.5rem;
background-color: #3b82f6;
color: white;
text-decoration: none;
border-radius: 0.25rem;
transition: background-color 0.2s ease;
}
.nav-link:hover {
background-color: #2563eb;
}
.footer {
text-align: center;
margin-top: 2rem;
color: #64748b;
font-size: 0.9rem;
}
.last-updated {
background-color: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 2rem;
text-align: center;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Terms of Service</h1>
<p>PowerOn AI Platform - Service Agreement & User Terms</p>
</div>
<div class="last-updated">
<strong>Last Updated:</strong> August 2025
</div>
<div class="content-section">
<h2>Acceptance of Terms</h2>
<p>
By accessing or using the PowerOn AI Platform ("Platform"), you agree to be bound by these Terms of Service
("Terms"). If you do not agree to these Terms, you must not use our Platform. These Terms constitute a
legally binding agreement between you and PowerOn AI Platform ("we," "our," or "us").
</p>
</div>
<div class="content-section">
<h2>Description of Service</h2>
<p>
PowerOn AI Platform is an AI-powered workflow automation and multi-agent collaboration platform that enables
users to create, manage, and execute automated business processes using artificial intelligence agents.
</p>
<p>Our Platform includes the following services:</p>
<ul>
<li>AI agent creation and management</li>
<li>Workflow design and automation tools</li>
<li>Document processing and analysis capabilities</li>
<li>Integration with third-party services and platforms</li>
<li>Analytics and reporting features</li>
</ul>
</div>
<div class="content-section">
<h2>User Accounts and Registration</h2>
<h3>Account Creation</h3>
<p>To use our Platform, you must create an account by providing accurate, current, and complete information. You are responsible for maintaining the confidentiality of your account credentials.</p>
<h3>Account Security</h3>
<p>You are responsible for all activities that occur under your account. You must immediately notify us of any unauthorized use of your account or any other security breach.</p>
<h3>Account Termination</h3>
<p>We reserve the right to terminate or suspend your account at any time for violation of these Terms or for any other reason at our sole discretion.</p>
</div>
<div class="content-section">
<h2>Acceptable Use Policy</h2>
<p>You agree to use our Platform only for lawful purposes and in accordance with these Terms. You agree not to:</p>
<div class="warning-box">
<h4>Prohibited Activities</h4>
<ul>
<li>Use the Platform for any illegal or unauthorized purpose</li>
<li>Violate any applicable laws or regulations</li>
<li>Infringe upon the intellectual property rights of others</li>
<li>Attempt to gain unauthorized access to our systems</li>
<li>Interfere with or disrupt the Platform's operation</li>
<li>Use the Platform to transmit harmful or malicious code</li>
<li>Harass, abuse, or harm other users</li>
</ul>
</div>
</div>
<div class="content-section">
<h2>User Content and Data</h2>
<h3>Content Ownership</h3>
<p>You retain ownership of any content, data, or information you upload, create, or process through our Platform ("User Content").</p>
<h3>Content License</h3>
<p>By using our Platform, you grant us a limited, non-exclusive license to use your User Content solely for the purpose of providing our services to you.</p>
<h3>Content Responsibility</h3>
<p>You are solely responsible for the accuracy, legality, and appropriateness of your User Content. We do not review or monitor User Content and are not responsible for its content.</p>
</div>
<div class="content-section">
<h2>Service Availability and Limitations</h2>
<div class="highlight-box">
<h4>Service Availability</h4>
<p>We strive to maintain high service availability but do not guarantee uninterrupted access to our Platform. We may perform maintenance, updates, or modifications that may temporarily affect service availability.</p>
</div>
<div class="highlight-box">
<h4>Service Limitations</h4>
<p>Our Platform is subject to reasonable usage limits and technical constraints. We reserve the right to implement usage limits, rate limiting, or other restrictions to ensure fair usage and system stability.</p>
</div>
</div>
<div class="content-section">
<h2>Intellectual Property Rights</h2>
<p>
The Platform, including its software, design, content, and functionality, is owned by PowerOn AI Platform
and is protected by intellectual property laws. You may not copy, modify, distribute, or create derivative
works based on our Platform without our express written consent.
</p>
</div>
<div class="content-section">
<h2>Third-Party Services and Integrations</h2>
<p>
Our Platform may integrate with third-party services, applications, or platforms. We are not responsible
for the availability, accuracy, or content of these third-party services. Your use of third-party services
is subject to their respective terms of service and privacy policies.
</p>
</div>
<div class="content-section">
<h2>Payment Terms</h2>
<h3>Pricing and Billing</h3>
<p>Service pricing is available on our Platform and may be subject to change. We will provide reasonable notice of any price changes.</p>
<h3>Payment Obligations</h3>
<p>You agree to pay all fees associated with your use of our Platform. Failure to pay may result in service suspension or termination.</p>
<h3>Refunds</h3>
<p>Refund policies are determined by your subscription plan and are subject to our discretion and applicable laws.</p>
</div>
<div class="content-section">
<h2>Disclaimers and Limitations of Liability</h2>
<div class="warning-box">
<h4>Service Disclaimers</h4>
<p>Our Platform is provided "as is" and "as available" without warranties of any kind. We disclaim all warranties, express or implied, including but not limited to warranties of merchantability, fitness for a particular purpose, and non-infringement.</p>
</div>
<div class="warning-box">
<h4>Limitation of Liability</h4>
<p>In no event shall PowerOn AI Platform be liable for any indirect, incidental, special, consequential, or punitive damages, including but not limited to loss of profits, data, or use, arising out of or relating to your use of our Platform.</p>
</div>
</div>
<div class="content-section">
<h2>Indemnification</h2>
<p>
You agree to indemnify, defend, and hold harmless PowerOn AI Platform and its officers, directors,
employees, and agents from and against any claims, damages, losses, liabilities, costs, and expenses
arising out of or relating to your use of our Platform or violation of these Terms.
</p>
</div>
<div class="content-section">
<h2>Governing Law and Dispute Resolution</h2>
<p>
These Terms are governed by and construed in accordance with the laws of the jurisdiction where
PowerOn AI Platform is incorporated. Any disputes arising from these Terms or your use of our Platform
shall be resolved through binding arbitration in accordance with applicable arbitration rules.
</p>
</div>
<div class="content-section">
<h2>Changes to Terms</h2>
<p>
We reserve the right to modify these Terms at any time. We will notify you of any material changes
by posting the updated Terms on our Platform. Your continued use of our Platform after such changes
constitutes acceptance of the updated Terms.
</p>
</div>
<div class="content-section">
<h2>Contact Information</h2>
<p>If you have any questions about these Terms of Service, please contact us:</p>
<div class="highlight-box">
<p><strong>Email:</strong> legal@poweron-ai.com</p>
<p><strong>Address:</strong> PowerOn AI Platform, Legal Department</p>
</div>
</div>
<div class="navigation">
<a href="poweron-home.html" class="nav-link">Home</a>
<a href="poweron-privacy.html" class="nav-link">Privacy Policy</a>
</div>
<div class="footer">
<p>&copy; 2025 PowerOn AI Platform. All rights reserved.</p>
</div>
</div>
</body>
</html>

View file

@ -1,20 +1,56 @@
/**
* App.tsx
*
* Haupt-App-Komponente mit Multi-Tenant Router-Setup.
*
* URL-Struktur:
* - / Dashboard/Übersicht
* - /settings Benutzer-Einstellungen
* - /gdpr → GDPR / Datenschutz
* - /mandates/:mandateId/:featureCode/:instanceId/* Feature-Instanz-Routen
* - /admin/* System-Administration (nur SysAdmin)
*/
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { useEffect } from 'react';
// Import global CSS reset first
import './index.css';
// Auth Pages (Public)
import Login from './pages/Login';
import Register from './pages/Register';
import PasswordResetRequest from './pages/PasswordResetRequest';
import Reset from './pages/Reset';
import { InvitePage } from './pages/InvitePage';
// Providers
import { AuthProvider } from './providers/auth/AuthProvider';
import { ProtectedRoute } from './providers/auth/ProtectedRoute';
import { LanguageProvider } from './providers/language/LanguageContext';
import { ToastProvider } from './contexts/ToastContext';
import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext';
import { FileProvider } from './contexts/FileContext';
import Home from './pages/Home/Home';
// Layouts
import { MainLayout } from './layouts/MainLayout';
import { FeatureLayout } from './layouts/FeatureLayout';
// Pages
import { DashboardPage } from './pages/Dashboard';
import { SettingsPage } from './pages/Settings';
import { GDPRPage } from './pages/GDPR';
import { FeatureViewPage } from './pages/FeatureView';
import { AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage } from './pages/admin';
// Workflow Pages (global)
import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflows';
// Basedata Pages (global)
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
// Migrate Pages (temporary - to be migrated to feature instances)
import { ChatbotPage, PekPage, SpeechPage } from './pages/migrate';
function App() {
// Load saved theme preference and set app name on app mount
@ -38,43 +74,125 @@ function App() {
}
document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
}, []);
return (
<LanguageProvider>
<AuthProvider>
<Router>
<Routes>
{/* PUBLIC AUTH ROUTES - NO AUTHENTICATION REQUIRED */}
<ToastProvider>
<WorkflowSelectionProvider>
<FileProvider>
<Router>
<Routes>
{/* ================================================== */}
{/* PUBLIC AUTH ROUTES - NO AUTHENTICATION REQUIRED */}
{/* ================================================== */}
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/password-reset-request" element={<PasswordResetRequest />} />
<Route path="/reset" element={<Reset />} />
<Route path="/invite/:token" element={<InvitePage />} />
{/* PROTECTED ROUTE - requires authentication */}
{/* ================================================== */}
{/* PROTECTED ROUTES - REQUIRE AUTHENTICATION */}
{/* ================================================== */}
<Route path="/" element={
<ProtectedRoute>
<FileProvider>
<WorkflowSelectionProvider>
<Home />
</WorkflowSelectionProvider>
</FileProvider>
<MainLayout />
</ProtectedRoute>
} />
}>
{/* Dashboard (Root) */}
<Route index element={<DashboardPage />} />
{/* System-Seiten (ohne Instanz-Kontext) */}
<Route path="settings" element={<SettingsPage />} />
<Route path="gdpr" element={<GDPRPage />} />
{/* ============================================== */}
{/* WORKFLOWS ROUTES (global) */}
{/* ============================================== */}
<Route path="workflows">
<Route path="playground" element={<PlaygroundPage />} />
<Route path="list" element={<WorkflowsPage />} />
<Route path="automations" element={<AutomationsPage />} />
</Route>
{/* ============================================== */}
{/* BASISDATEN ROUTES (global) */}
{/* ============================================== */}
<Route path="basedata">
<Route path="prompts" element={<PromptsPage />} />
<Route path="files" element={<FilesPage />} />
<Route path="connections" element={<ConnectionsPage />} />
</Route>
{/* ============================================== */}
{/* MIGRATE TO FEATURES (temporary) */}
{/* ============================================== */}
<Route path="chatbot" element={<ChatbotPage />} />
<Route path="pek" element={<PekPage />} />
<Route path="speech" element={<SpeechPage />} />
{/* ============================================== */}
{/* FEATURE-INSTANZ ROUTES */}
{/* /mandates/:mandateId/:featureCode/:instanceId */}
{/* ============================================== */}
<Route
path="mandates/:mandateId/:featureCode/:instanceId"
element={<FeatureLayout />}
>
{/* Feature Views - dynamisch basierend auf featureCode */}
<Route index element={<FeatureViewPage view="dashboard" />} />
<Route path="dashboard" element={<FeatureViewPage view="dashboard" />} />
<Route path="organisations" element={<FeatureViewPage view="organisations" />} />
<Route path="contracts" element={<FeatureViewPage view="contracts" />} />
<Route path="documents" element={<FeatureViewPage view="documents" />} />
<Route path="positions" element={<FeatureViewPage view="positions" />} />
<Route path="roles" element={<FeatureViewPage view="roles" />} />
<Route path="access" element={<FeatureViewPage view="access" />} />
<Route path="runs" element={<FeatureViewPage view="runs" />} />
<Route path="files" element={<FeatureViewPage view="files" />} />
<Route path="conversations" element={<FeatureViewPage view="conversations" />} />
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
{/* Catch-all für unbekannte Sub-Pfade */}
<Route path="*" element={<FeatureViewPage view="not-found" />} />
</Route>
{/* ============================================== */}
{/* ADMIN ROUTES (nur SysAdmin) */}
{/* ============================================== */}
<Route path="admin">
<Route path="mandates" element={<AdminMandatesPage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="user-mandates" element={<AdminUserMandatesPage />} />
<Route path="feature-instances" element={<AdminFeatureAccessPage />} />
<Route path="feature-roles" element={<AdminFeatureRolesPage />} />
<Route path="feature-users" element={<AdminFeatureInstanceUsersPage />} />
<Route path="invitations" element={<AdminInvitationsPage />} />
<Route path="mandate-roles" element={<AdminMandateRolesPage />} />
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
<Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} />
</Route>
</Route>
{/* Catch-all redirect to home */}
{/* ================================================== */}
{/* CATCH-ALL - Redirect to Dashboard */}
{/* ================================================== */}
<Route path="*" element={
<ProtectedRoute>
<FileProvider>
<WorkflowSelectionProvider>
<Home />
</WorkflowSelectionProvider>
</FileProvider>
<MainLayout />
</ProtectedRoute>
} />
</Routes>
</Router>
</Routes>
</Router>
</FileProvider>
</WorkflowSelectionProvider>
</ToastProvider>
</AuthProvider>
</LanguageProvider>
);
}
export default App;
export default App;

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';
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();
@ -96,11 +126,19 @@ api.interceptors.response.use(
error.config?.url?.includes('/api/local/login') ||
error.config?.url?.includes('/api/msft/login');
// Don't redirect if we're already on the login page (prevents redirect loops)
const isOnLoginPage = window.location.pathname === '/login' ||
window.location.pathname.startsWith('/login');
// Don't redirect if we're on a public auth page (prevents redirect loops and allows public pages to work)
const pathname = window.location.pathname;
const isOnPublicAuthPage = pathname === '/login' ||
pathname.startsWith('/login') ||
pathname === '/register' ||
pathname.startsWith('/register') ||
pathname === '/reset' ||
pathname.startsWith('/reset') ||
pathname === '/password-reset-request' ||
pathname.startsWith('/password-reset-request') ||
pathname.startsWith('/invite');
if (!isLoginEndpoint && !isOnLoginPage) {
if (!isLoginEndpoint && !isOnPublicAuthPage) {
// Clear local auth data (httpOnly cookies are cleared by backend)
sessionStorage.removeItem('auth_authority');
clearUserDataCache();

View file

@ -85,15 +85,18 @@ export interface UsernameAvailabilityResponse {
message: string;
}
export interface User {
// User-Typ wird aus userApi.ts importiert
// Hier nur für Rückwärtskompatibilität
export interface AuthUser {
id: string;
username: string;
email: string;
fullName: string;
language: string;
enabled: boolean;
privilege: string;
mandateId: string;
roleLabels?: string[];
authenticationAuthority: string;
isSysAdmin?: boolean;
[key: string]: any;
}
@ -138,7 +141,7 @@ export async function loginApi(loginData: LoginRequest): Promise<LoginResponse>
* Fetch current user data
* Endpoint: GET /api/local/me | /api/msft/me | /api/google/me
*/
export async function fetchCurrentUserApi(authAuthority?: string): Promise<User> {
export async function fetchCurrentUserApi(authAuthority?: string): Promise<AuthUser> {
let endpoint = '/api/local/me';
if (authAuthority === 'msft') {
@ -147,7 +150,7 @@ export async function fetchCurrentUserApi(authAuthority?: string): Promise<User>
endpoint = '/api/google/me';
}
const response = await api.get<User>(endpoint);
const response = await api.get<AuthUser>(endpoint);
return response.data;
}

240
src/api/automationApi.ts Normal file
View file

@ -0,0 +1,240 @@
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================
// TYPES & INTERFACES
// ============================================================================
export interface Automation {
id: string;
mandateId: string;
featureInstanceId: string;
label: string;
template: string | object;
placeholders: Record<string, string>;
schedule: string;
active: boolean;
status?: string;
lastExecution?: number;
nextExecution?: number;
executionLogs?: AutomationLog[];
_createdAt?: number;
_updatedAt?: number;
_createdByUserName?: string;
mandateName?: string;
[key: string]: any;
}
export interface AutomationLog {
id: string;
timestamp: number;
status: string;
workflowId?: string;
messages?: string[];
}
export interface AutomationTemplate {
template: {
overview?: string;
tasks?: Array<{
description?: string;
objective?: string;
[key: string]: any;
}>;
[key: string]: any;
};
parameters?: Record<string, any>;
}
export interface CreateAutomationRequest {
label: string;
template: string;
placeholders?: Record<string, string>;
schedule?: string;
active?: boolean;
mandateId?: string;
featureInstanceId?: string;
}
export interface UpdateAutomationRequest {
label?: string;
template?: string;
placeholders?: Record<string, string>;
schedule?: string;
active?: boolean;
}
export interface ExecuteAutomationResponse {
id: string;
status: string;
workflowId?: string;
[key: string]: any;
}
// Type for the request function passed to API functions
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
// ============================================================================
// API REQUEST FUNCTIONS
// ============================================================================
/**
* Fetch all automations for the current mandate
* Endpoint: GET /api/automations
*/
export async function fetchAutomations(request: ApiRequestFunction): Promise<Automation[]> {
console.log('📤 fetchAutomations: Making API request to /api/automations');
try {
const data = await request({
url: '/api/automations',
method: 'get'
});
console.log('📥 fetchAutomations: API response:', data);
// Handle different response formats
let automations: Automation[] = [];
if (Array.isArray(data)) {
automations = data;
} else if (data && typeof data === 'object') {
if (Array.isArray(data.automations)) {
automations = data.automations;
} else if (Array.isArray(data.items)) {
automations = data.items;
} else if (Array.isArray(data.data)) {
automations = data.data;
}
}
console.log(`✅ fetchAutomations: Returning ${automations.length} automations`);
return automations;
} catch (error) {
console.error('❌ fetchAutomations: Error fetching automations:', error);
throw error;
}
}
/**
* Fetch a single automation by ID
* Endpoint: GET /api/automations/{automationId}
*/
export async function fetchAutomation(
request: ApiRequestFunction,
automationId: string
): Promise<Automation> {
return await request({
url: `/api/automations/${automationId}`,
method: 'get'
});
}
/**
* Create a new automation
* Endpoint: POST /api/automations
*/
export async function createAutomationApi(
request: ApiRequestFunction,
automationData: CreateAutomationRequest
): Promise<Automation> {
return await request({
url: '/api/automations',
method: 'post',
data: automationData
});
}
/**
* Update an existing automation
* Endpoint: PUT /api/automations/{automationId}
*/
export async function updateAutomationApi(
request: ApiRequestFunction,
automationId: string,
updateData: UpdateAutomationRequest
): Promise<Automation> {
return await request({
url: `/api/automations/${automationId}`,
method: 'put',
data: updateData
});
}
/**
* Delete an automation
* Endpoint: DELETE /api/automations/{automationId}
*/
export async function deleteAutomationApi(
request: ApiRequestFunction,
automationId: string
): Promise<void> {
await request({
url: `/api/automations/${automationId}`,
method: 'delete'
});
}
/**
* Execute an automation (test mode)
* Endpoint: POST /api/automations/{automationId}/execute
*/
export async function executeAutomationApi(
request: ApiRequestFunction,
automationId: string
): Promise<ExecuteAutomationResponse> {
return await request({
url: `/api/automations/${automationId}/execute`,
method: 'post'
});
}
/**
* Fetch automation templates
* Endpoint: GET /api/automations/templates
*/
export async function fetchAutomationTemplates(
request: ApiRequestFunction
): Promise<AutomationTemplate[]> {
const data = await request({
url: '/api/automations/templates',
method: 'get'
});
if (Array.isArray(data)) {
return data;
}
if (data && typeof data === 'object') {
if (Array.isArray(data.sets)) {
return data.sets;
}
if (Array.isArray(data.templates)) {
return data.templates;
}
}
return [];
}
/**
* Fetch automation attributes for dynamic form generation
* Endpoint: GET /api/attributes/AutomationDefinition
*/
export async function fetchAutomationAttributes(
request: ApiRequestFunction
): Promise<any[]> {
const data = await request({
url: '/api/attributes/AutomationDefinition',
method: 'get'
});
if (data?.attributes && Array.isArray(data.attributes)) {
return data.attributes;
}
if (Array.isArray(data)) {
return data;
}
return [];
}

233
src/api/featuresApi.ts Normal file
View file

@ -0,0 +1,233 @@
/**
* Features API
*
* API-Schicht für das Multi-Tenant Feature-System.
* Hauptendpoint: GET /features/my - Lädt alle Mandate + Features + Instanzen + Permissions
*/
import api from '../api';
import type {
FeaturesMyResponse,
Mandate,
MandateFeature,
FeatureInstance,
InstancePermissions,
AccessLevel,
} from '../types/mandate';
// =============================================================================
// MOCK DATA (Temporär bis Backend bereit)
// =============================================================================
const MOCK_PERMISSIONS: InstancePermissions = {
tables: {
TrusteeOrganisation: { view: true, read: 'g', create: 'g', update: 'g', delete: 'n' },
TrusteeContract: { view: true, read: 'g', create: 'g', update: 'm', delete: 'n' },
TrusteeDocument: { view: true, read: 'g', create: 'g', update: 'm', delete: 'm' },
TrusteePosition: { view: true, read: 'g', create: 'g', update: 'm', delete: 'n' },
},
views: {
'trustee-dashboard': true,
'trustee-organisations': true,
'trustee-contracts': true,
'trustee-documents': true,
'trustee-positions': true,
'trustee-roles': true,
'trustee-access': true,
},
};
const MOCK_CUSTOMER_PERMISSIONS: InstancePermissions = {
tables: {
TrusteeOrganisation: { view: true, read: 'm', create: 'n', update: 'n', delete: 'n' },
TrusteeContract: { view: true, read: 'm', create: 'n', update: 'n', delete: 'n' },
TrusteeDocument: { view: true, read: 'm', create: 'm', update: 'm', delete: 'n' },
TrusteePosition: { view: true, read: 'm', create: 'n', update: 'n', delete: 'n' },
},
views: {
'trustee-dashboard': true,
'trustee-contracts': true,
'trustee-documents': true,
'trustee-positions': true,
'trustee-organisations': false,
'trustee-roles': false,
'trustee-access': false,
},
};
const MOCK_WORKFLOW_PERMISSIONS: InstancePermissions = {
tables: {
WorkflowRun: { view: true, read: 'g', create: 'g', update: 'm', delete: 'n' },
WorkflowFile: { view: true, read: 'g', create: 'g', update: 'm', delete: 'm' },
},
views: {
'chatworkflow-dashboard': true,
'chatworkflow-runs': true,
'chatworkflow-files': true,
},
};
const MOCK_RESPONSE: FeaturesMyResponse = {
mandates: [
{
id: 'mand-soha',
name: 'Soha Treuhand',
code: 'soha',
features: [
{
code: 'trustee',
label: { de: 'Treuhand', en: 'Trustee' },
icon: 'briefcase',
instances: [
{
id: 'inst-soha-pamo',
featureCode: 'trustee',
mandateId: 'mand-soha',
mandateName: 'Soha Treuhand',
instanceLabel: 'PamoCreate AG',
userRole: 'admin',
permissions: MOCK_PERMISSIONS,
},
{
id: 'inst-soha-valueon',
featureCode: 'trustee',
mandateId: 'mand-soha',
mandateName: 'Soha Treuhand',
instanceLabel: 'ValueOn AG',
userRole: 'customer',
permissions: MOCK_CUSTOMER_PERMISSIONS,
},
],
},
{
code: 'chatworkflow',
label: { de: 'Workflow', en: 'Workflow' },
icon: 'play_circle',
instances: [
{
id: 'inst-soha-workflow',
featureCode: 'chatworkflow',
mandateId: 'mand-soha',
mandateName: 'Soha Treuhand',
instanceLabel: 'Beratung Dynamic',
userRole: 'user',
permissions: MOCK_WORKFLOW_PERMISSIONS,
},
],
},
],
},
{
id: 'mand-swiss',
name: 'SwissTreu',
code: 'swisstreu',
features: [
{
code: 'trustee',
label: { de: 'Treuhand', en: 'Trustee' },
icon: 'briefcase',
instances: [
{
id: 'inst-swiss-firma-x',
featureCode: 'trustee',
mandateId: 'mand-swiss',
mandateName: 'SwissTreu',
instanceLabel: 'Firma X',
userRole: 'customer',
permissions: MOCK_CUSTOMER_PERMISSIONS,
},
],
},
],
},
],
};
// Flag für Mock-Modus (auf false setzen wenn Backend bereit)
const USE_MOCK = false;
// =============================================================================
// API FUNCTIONS
// =============================================================================
/**
* Lädt alle Mandate + Features + Instanzen + Permissions für den aktuellen User
*
* Endpoint: GET /api/features/my
*
* Response enthält:
* - Alle Mandanten zu denen der User Zugriff hat
* - Pro Mandant: Alle Features mit deren Instanzen
* - Pro Instanz: Summarische Berechtigungen (tables, views)
*/
export async function fetchMyFeatures(): Promise<FeaturesMyResponse> {
if (USE_MOCK) {
console.log('📦 featuresApi: Using MOCK data');
// Simuliere Netzwerk-Latenz
await new Promise(resolve => setTimeout(resolve, 300));
return MOCK_RESPONSE;
}
try {
console.log('📡 featuresApi: Fetching /api/features/my');
const response = await api.get<FeaturesMyResponse>('/api/features/my');
console.log('✅ featuresApi: Loaded features:', {
mandateCount: response.data.mandates.length,
totalInstances: response.data.mandates
.flatMap(m => m.features)
.flatMap(f => f.instances)
.length,
});
return response.data;
} catch (error) {
console.error('❌ featuresApi: Error fetching features:', error);
throw error;
}
}
/**
* Lädt die verfügbaren Features (für Admin - Feature-Instanz erstellen)
*
* Endpoint: GET /api/features/available
*/
export async function fetchAvailableFeatures(): Promise<MandateFeature[]> {
if (USE_MOCK) {
return [
{ code: 'trustee', label: { de: 'Treuhand', en: 'Trustee' }, icon: 'briefcase', instances: [] },
{ code: 'chatworkflow', label: { de: 'Workflow', en: 'Workflow' }, icon: 'play_circle', instances: [] },
{ code: 'chatbot', label: { de: 'Chatbot', en: 'Chatbot' }, icon: 'chat', instances: [] },
];
}
const response = await api.get<MandateFeature[]>('/api/features/available');
return response.data;
}
// =============================================================================
// TYPE GUARDS
// =============================================================================
export function isValidAccessLevel(value: string): value is AccessLevel {
return ['n', 'm', 'g', 'a'].includes(value);
}
export function isValidMandate(obj: unknown): obj is Mandate {
if (!obj || typeof obj !== 'object') return false;
const mandate = obj as Record<string, unknown>;
return (
typeof mandate.id === 'string' &&
typeof mandate.name === 'string' &&
Array.isArray(mandate.features)
);
}
export function isValidFeatureInstance(obj: unknown): obj is FeatureInstance {
if (!obj || typeof obj !== 'object') return false;
const instance = obj as Record<string, unknown>;
return (
typeof instance.id === 'string' &&
typeof instance.featureCode === 'string' &&
typeof instance.mandateId === 'string' &&
typeof instance.instanceLabel === 'string'
);
}

View file

@ -62,15 +62,16 @@ export interface PaginatedResponse<T> {
}
export interface CreatePromptData {
mandateId: string;
name: string;
content: string;
// mandateId wird nicht mehr vom Client gesendet
// Das Backend bestimmt den Kontext über die instanceId
}
export interface UpdatePromptData {
mandateId: string;
name: string;
content: string;
// mandateId wird nicht mehr vom Client gesendet
}
// Type for the request function passed to API functions

View file

@ -1,3 +1,12 @@
/**
* Trustee API
*
* API-Funktionen für das Trustee-Feature.
* Alle Endpunkte erfordern eine instanceId für den Feature-Instanz-Kontext.
*
* URL-Struktur: /api/trustee/{instanceId}/{entity}
*/
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================
@ -60,7 +69,7 @@ export interface TrusteeDocument {
contractId: string;
documentName: string;
documentMimeType: string;
documentData?: any; // Binary data, typically not included in list responses
documentData?: any;
mandateId?: string;
_createdAt?: number;
_modifiedAt?: number;
@ -150,16 +159,24 @@ function _buildPaginationParams(params?: PaginationParams): Record<string, any>
return requestParams;
}
/**
* Erstellt die Basis-URL für Trustee-Endpunkte
*/
function _getTrusteeBaseUrl(instanceId: string): string {
return `/api/trustee/${instanceId}`;
}
// ============================================================================
// ORGANISATION API
// ============================================================================
export async function fetchOrganisations(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeOrganisation> | TrusteeOrganisation[]> {
return await request({
url: '/api/trustee/organisations',
url: `${_getTrusteeBaseUrl(instanceId)}/organisations`,
method: 'get',
params: _buildPaginationParams(params)
});
@ -167,11 +184,12 @@ export async function fetchOrganisations(
export async function fetchOrganisationById(
request: ApiRequestFunction,
instanceId: string,
orgId: string
): Promise<TrusteeOrganisation | null> {
try {
return await request({
url: `/api/trustee/organisations/${orgId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/organisations/${orgId}`,
method: 'get'
});
} catch (error: any) {
@ -182,10 +200,11 @@ export async function fetchOrganisationById(
export async function createOrganisation(
request: ApiRequestFunction,
instanceId: string,
data: Partial<TrusteeOrganisation>
): Promise<TrusteeOrganisation> {
return await request({
url: '/api/trustee/organisations',
url: `${_getTrusteeBaseUrl(instanceId)}/organisations`,
method: 'post',
data
});
@ -193,11 +212,12 @@ export async function createOrganisation(
export async function updateOrganisation(
request: ApiRequestFunction,
instanceId: string,
orgId: string,
data: Partial<TrusteeOrganisation>
): Promise<TrusteeOrganisation> {
return await request({
url: `/api/trustee/organisations/${orgId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/organisations/${orgId}`,
method: 'put',
data
});
@ -205,10 +225,11 @@ export async function updateOrganisation(
export async function deleteOrganisation(
request: ApiRequestFunction,
instanceId: string,
orgId: string
): Promise<void> {
await request({
url: `/api/trustee/organisations/${orgId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/organisations/${orgId}`,
method: 'delete'
});
}
@ -219,10 +240,11 @@ export async function deleteOrganisation(
export async function fetchRoles(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeRole> | TrusteeRole[]> {
return await request({
url: '/api/trustee/roles',
url: `${_getTrusteeBaseUrl(instanceId)}/roles`,
method: 'get',
params: _buildPaginationParams(params)
});
@ -230,11 +252,12 @@ export async function fetchRoles(
export async function fetchRoleById(
request: ApiRequestFunction,
instanceId: string,
roleId: string
): Promise<TrusteeRole | null> {
try {
return await request({
url: `/api/trustee/roles/${roleId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/roles/${roleId}`,
method: 'get'
});
} catch (error: any) {
@ -245,10 +268,11 @@ export async function fetchRoleById(
export async function createRole(
request: ApiRequestFunction,
instanceId: string,
data: Partial<TrusteeRole>
): Promise<TrusteeRole> {
return await request({
url: '/api/trustee/roles',
url: `${_getTrusteeBaseUrl(instanceId)}/roles`,
method: 'post',
data
});
@ -256,11 +280,12 @@ export async function createRole(
export async function updateRole(
request: ApiRequestFunction,
instanceId: string,
roleId: string,
data: Partial<TrusteeRole>
): Promise<TrusteeRole> {
return await request({
url: `/api/trustee/roles/${roleId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/roles/${roleId}`,
method: 'put',
data
});
@ -268,10 +293,11 @@ export async function updateRole(
export async function deleteRole(
request: ApiRequestFunction,
instanceId: string,
roleId: string
): Promise<void> {
await request({
url: `/api/trustee/roles/${roleId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/roles/${roleId}`,
method: 'delete'
});
}
@ -282,10 +308,11 @@ export async function deleteRole(
export async function fetchAccess(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeAccess> | TrusteeAccess[]> {
return await request({
url: '/api/trustee/access',
url: `${_getTrusteeBaseUrl(instanceId)}/access`,
method: 'get',
params: _buildPaginationParams(params)
});
@ -293,11 +320,12 @@ export async function fetchAccess(
export async function fetchAccessById(
request: ApiRequestFunction,
instanceId: string,
accessId: string
): Promise<TrusteeAccess | null> {
try {
return await request({
url: `/api/trustee/access/${accessId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/access/${accessId}`,
method: 'get'
});
} catch (error: any) {
@ -308,30 +336,33 @@ export async function fetchAccessById(
export async function fetchAccessByOrganisation(
request: ApiRequestFunction,
instanceId: string,
orgId: string
): Promise<TrusteeAccess[]> {
return await request({
url: `/api/trustee/access/organisation/${orgId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/access/organisation/${orgId}`,
method: 'get'
});
}
export async function fetchAccessByUser(
request: ApiRequestFunction,
instanceId: string,
userId: string
): Promise<TrusteeAccess[]> {
return await request({
url: `/api/trustee/access/user/${userId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/access/user/${userId}`,
method: 'get'
});
}
export async function createAccess(
request: ApiRequestFunction,
instanceId: string,
data: Partial<TrusteeAccess>
): Promise<TrusteeAccess> {
return await request({
url: '/api/trustee/access',
url: `${_getTrusteeBaseUrl(instanceId)}/access`,
method: 'post',
data
});
@ -339,11 +370,12 @@ export async function createAccess(
export async function updateAccess(
request: ApiRequestFunction,
instanceId: string,
accessId: string,
data: Partial<TrusteeAccess>
): Promise<TrusteeAccess> {
return await request({
url: `/api/trustee/access/${accessId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/access/${accessId}`,
method: 'put',
data
});
@ -351,10 +383,11 @@ export async function updateAccess(
export async function deleteAccess(
request: ApiRequestFunction,
instanceId: string,
accessId: string
): Promise<void> {
await request({
url: `/api/trustee/access/${accessId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/access/${accessId}`,
method: 'delete'
});
}
@ -365,10 +398,11 @@ export async function deleteAccess(
export async function fetchContracts(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeContract> | TrusteeContract[]> {
return await request({
url: '/api/trustee/contracts',
url: `${_getTrusteeBaseUrl(instanceId)}/contracts`,
method: 'get',
params: _buildPaginationParams(params)
});
@ -376,11 +410,12 @@ export async function fetchContracts(
export async function fetchContractById(
request: ApiRequestFunction,
instanceId: string,
contractId: string
): Promise<TrusteeContract | null> {
try {
return await request({
url: `/api/trustee/contracts/${contractId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/contracts/${contractId}`,
method: 'get'
});
} catch (error: any) {
@ -391,20 +426,22 @@ export async function fetchContractById(
export async function fetchContractsByOrganisation(
request: ApiRequestFunction,
instanceId: string,
orgId: string
): Promise<TrusteeContract[]> {
return await request({
url: `/api/trustee/contracts/organisation/${orgId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/contracts/organisation/${orgId}`,
method: 'get'
});
}
export async function createContract(
request: ApiRequestFunction,
instanceId: string,
data: Partial<TrusteeContract>
): Promise<TrusteeContract> {
return await request({
url: '/api/trustee/contracts',
url: `${_getTrusteeBaseUrl(instanceId)}/contracts`,
method: 'post',
data
});
@ -412,11 +449,12 @@ export async function createContract(
export async function updateContract(
request: ApiRequestFunction,
instanceId: string,
contractId: string,
data: Partial<TrusteeContract>
): Promise<TrusteeContract> {
return await request({
url: `/api/trustee/contracts/${contractId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/contracts/${contractId}`,
method: 'put',
data
});
@ -424,10 +462,11 @@ export async function updateContract(
export async function deleteContract(
request: ApiRequestFunction,
instanceId: string,
contractId: string
): Promise<void> {
await request({
url: `/api/trustee/contracts/${contractId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/contracts/${contractId}`,
method: 'delete'
});
}
@ -438,10 +477,11 @@ export async function deleteContract(
export async function fetchDocuments(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeDocument> | TrusteeDocument[]> {
return await request({
url: '/api/trustee/documents',
url: `${_getTrusteeBaseUrl(instanceId)}/documents`,
method: 'get',
params: _buildPaginationParams(params)
});
@ -449,11 +489,12 @@ export async function fetchDocuments(
export async function fetchDocumentById(
request: ApiRequestFunction,
instanceId: string,
documentId: string
): Promise<TrusteeDocument | null> {
try {
return await request({
url: `/api/trustee/documents/${documentId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/documents/${documentId}`,
method: 'get'
});
} catch (error: any) {
@ -464,32 +505,54 @@ export async function fetchDocumentById(
export async function fetchDocumentsByContract(
request: ApiRequestFunction,
instanceId: string,
contractId: string
): Promise<TrusteeDocument[]> {
return await request({
url: `/api/trustee/documents/contract/${contractId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/documents/contract/${contractId}`,
method: 'get'
});
}
export async function createDocument(
request: ApiRequestFunction,
instanceId: string,
data: Partial<TrusteeDocument>
): Promise<TrusteeDocument> {
// If documentData is a File, convert to base64
let processedData = { ...data };
if (data.documentData instanceof File) {
const file = data.documentData as File;
const arrayBuffer = await file.arrayBuffer();
const base64 = btoa(
new Uint8Array(arrayBuffer).reduce((data, byte) => data + String.fromCharCode(byte), '')
);
processedData.documentData = base64 as any;
// Auto-set MIME type from file if not provided
if (!processedData.documentMimeType && file.type) {
processedData.documentMimeType = file.type;
}
// Auto-set name from file if not provided
if (!processedData.documentName && file.name) {
processedData.documentName = file.name;
}
}
return await request({
url: '/api/trustee/documents',
url: `${_getTrusteeBaseUrl(instanceId)}/documents`,
method: 'post',
data
data: processedData
});
}
export async function updateDocument(
request: ApiRequestFunction,
instanceId: string,
documentId: string,
data: Partial<TrusteeDocument>
): Promise<TrusteeDocument> {
return await request({
url: `/api/trustee/documents/${documentId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/documents/${documentId}`,
method: 'put',
data
});
@ -497,10 +560,11 @@ export async function updateDocument(
export async function deleteDocument(
request: ApiRequestFunction,
instanceId: string,
documentId: string
): Promise<void> {
await request({
url: `/api/trustee/documents/${documentId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/documents/${documentId}`,
method: 'delete'
});
}
@ -511,10 +575,11 @@ export async function deleteDocument(
export async function fetchPositions(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteePosition> | TrusteePosition[]> {
return await request({
url: '/api/trustee/positions',
url: `${_getTrusteeBaseUrl(instanceId)}/positions`,
method: 'get',
params: _buildPaginationParams(params)
});
@ -522,11 +587,12 @@ export async function fetchPositions(
export async function fetchPositionById(
request: ApiRequestFunction,
instanceId: string,
positionId: string
): Promise<TrusteePosition | null> {
try {
return await request({
url: `/api/trustee/positions/${positionId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/positions/${positionId}`,
method: 'get'
});
} catch (error: any) {
@ -537,30 +603,33 @@ export async function fetchPositionById(
export async function fetchPositionsByContract(
request: ApiRequestFunction,
instanceId: string,
contractId: string
): Promise<TrusteePosition[]> {
return await request({
url: `/api/trustee/positions/contract/${contractId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/positions/contract/${contractId}`,
method: 'get'
});
}
export async function fetchPositionsByOrganisation(
request: ApiRequestFunction,
instanceId: string,
orgId: string
): Promise<TrusteePosition[]> {
return await request({
url: `/api/trustee/positions/organisation/${orgId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/positions/organisation/${orgId}`,
method: 'get'
});
}
export async function createPosition(
request: ApiRequestFunction,
instanceId: string,
data: Partial<TrusteePosition>
): Promise<TrusteePosition> {
return await request({
url: '/api/trustee/positions',
url: `${_getTrusteeBaseUrl(instanceId)}/positions`,
method: 'post',
data
});
@ -568,11 +637,12 @@ export async function createPosition(
export async function updatePosition(
request: ApiRequestFunction,
instanceId: string,
positionId: string,
data: Partial<TrusteePosition>
): Promise<TrusteePosition> {
return await request({
url: `/api/trustee/positions/${positionId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/positions/${positionId}`,
method: 'put',
data
});
@ -580,10 +650,11 @@ export async function updatePosition(
export async function deletePosition(
request: ApiRequestFunction,
instanceId: string,
positionId: string
): Promise<void> {
await request({
url: `/api/trustee/positions/${positionId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/positions/${positionId}`,
method: 'delete'
});
}
@ -594,10 +665,11 @@ export async function deletePosition(
export async function fetchPositionDocuments(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteePositionDocument> | TrusteePositionDocument[]> {
return await request({
url: '/api/trustee/position-documents',
url: `${_getTrusteeBaseUrl(instanceId)}/position-documents`,
method: 'get',
params: _buildPaginationParams(params)
});
@ -605,11 +677,12 @@ export async function fetchPositionDocuments(
export async function fetchPositionDocumentById(
request: ApiRequestFunction,
instanceId: string,
linkId: string
): Promise<TrusteePositionDocument | null> {
try {
return await request({
url: `/api/trustee/position-documents/${linkId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/position-documents/${linkId}`,
method: 'get'
});
} catch (error: any) {
@ -620,41 +693,58 @@ export async function fetchPositionDocumentById(
export async function fetchDocumentsForPosition(
request: ApiRequestFunction,
instanceId: string,
positionId: string
): Promise<TrusteePositionDocument[]> {
return await request({
url: `/api/trustee/position-documents/position/${positionId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/position-documents/position/${positionId}`,
method: 'get'
});
}
export async function fetchPositionsForDocument(
request: ApiRequestFunction,
instanceId: string,
documentId: string
): Promise<TrusteePositionDocument[]> {
return await request({
url: `/api/trustee/position-documents/document/${documentId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/position-documents/document/${documentId}`,
method: 'get'
});
}
export async function createPositionDocument(
request: ApiRequestFunction,
instanceId: string,
data: Partial<TrusteePositionDocument>
): Promise<TrusteePositionDocument> {
return await request({
url: '/api/trustee/position-documents',
url: `${_getTrusteeBaseUrl(instanceId)}/position-documents`,
method: 'post',
data
});
}
export async function updatePositionDocument(
request: ApiRequestFunction,
instanceId: string,
linkId: string,
data: Partial<TrusteePositionDocument>
): Promise<TrusteePositionDocument> {
return await request({
url: `${_getTrusteeBaseUrl(instanceId)}/position-documents/${linkId}`,
method: 'put',
data
});
}
export async function deletePositionDocument(
request: ApiRequestFunction,
instanceId: string,
linkId: string
): Promise<void> {
await request({
url: `/api/trustee/position-documents/${linkId}`,
url: `${_getTrusteeBaseUrl(instanceId)}/position-documents/${linkId}`,
method: 'delete'
});
}

View file

@ -13,8 +13,10 @@ export interface User {
enabled: boolean;
roleLabels?: string[]; // Array of role labels from backend (e.g., ["user"])
authenticationAuthority: string;
mandateId: string;
[key: string]: any; // Allow additional properties (may include deprecated 'privilege' from backend)
isSysAdmin?: boolean; // System-Administrator Flag
// mandateId ist nicht mehr Teil des User-Objekts (Multi-Tenant-Konzept)
// Der Mandant-Kontext wird über Feature-Instanzen bestimmt
[key: string]: any; // Allow additional properties
}
export type UserUpdateData = Partial<Omit<User, 'id' | 'mandateId'>>;

View file

@ -0,0 +1,59 @@
/**
* AccessLevelSelect
*
* Dropdown component for selecting RBAC access levels (n/m/g/a).
*/
import React from 'react';
import { ACCESS_LEVEL_OPTIONS, type AccessLevel, getAccessLevelColor } from '../../hooks/useAccessRules';
import styles from './AccessRules.module.css';
interface AccessLevelSelectProps {
value: AccessLevel | null;
onChange: (value: AccessLevel) => void;
disabled?: boolean;
label?: string;
showLabel?: boolean;
compact?: boolean;
}
export const AccessLevelSelect: React.FC<AccessLevelSelectProps> = ({
value,
onChange,
disabled = false,
label,
showLabel = false,
compact = false,
}) => {
const currentColor = getAccessLevelColor(value);
return (
<div className={`${styles.accessLevelSelect} ${compact ? styles.compact : ''}`}>
{showLabel && label && (
<label className={styles.accessLevelLabel}>{label}</label>
)}
<select
value={value || 'n'}
onChange={(e) => onChange(e.target.value as AccessLevel)}
disabled={disabled}
className={styles.accessLevelDropdown}
style={{
borderColor: currentColor,
color: currentColor,
}}
>
{ACCESS_LEVEL_OPTIONS.map(option => (
<option
key={option.value}
value={option.value}
style={{ color: option.color }}
>
{option.label}
</option>
))}
</select>
</div>
);
};
export default AccessLevelSelect;

View file

@ -0,0 +1,792 @@
/* =============================================================================
* AccessRules Components Styles
* ============================================================================= */
/* =============================================================================
* Access Level Select
* ============================================================================= */
.accessLevelSelect {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.accessLevelSelect.compact {
gap: 0;
}
.accessLevelLabel {
font-size: 0.75rem;
color: var(--text-secondary);
font-weight: 500;
}
.accessLevelDropdown {
padding: 0.375rem 0.5rem;
border: 2px solid var(--border-color);
border-radius: 4px;
background: var(--bg-primary);
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
min-width: 80px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.accessLevelDropdown:hover:not(:disabled) {
box-shadow: 0 0 0 2px var(--primary-color-light);
}
.accessLevelDropdown:focus {
outline: none;
box-shadow: 0 0 0 2px var(--primary-color);
}
.accessLevelDropdown:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* =============================================================================
* Access Rules Editor
* ============================================================================= */
.accessRulesEditor {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background: var(--bg-primary);
border-radius: 8px;
border: 1px solid var(--border-color);
}
.editorHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border-color);
}
.editorTitle {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.templateBadge {
background: var(--info-color);
color: white;
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
border-radius: 4px;
text-transform: uppercase;
font-weight: 700;
}
.headerActions {
display: flex;
gap: 0.5rem;
}
/* =============================================================================
* Tabs
* ============================================================================= */
.tabsContainer {
display: flex;
flex-direction: column;
gap: 1rem;
}
.tabList {
display: flex;
gap: 0.25rem;
border-bottom: 2px solid var(--border-color);
padding-bottom: -2px;
}
.tab {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
transition: all 0.2s;
}
.tab:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
.tab.active {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
}
.tabIcon {
font-size: 1rem;
}
.tabBadge {
background: var(--bg-tertiary);
color: var(--text-secondary);
font-size: 0.75rem;
padding: 0.125rem 0.375rem;
border-radius: 10px;
min-width: 20px;
text-align: center;
}
.tab.active .tabBadge {
background: var(--primary-color);
color: white;
}
.tabContent {
min-height: 200px;
}
/* =============================================================================
* Rules Section
* ============================================================================= */
.rulesSection {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
}
.sectionTitle {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.addButton {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
background: var(--primary-color);
color: white;
border: none;
border-radius: 4px;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.addButton:hover {
background: var(--primary-color-dark);
}
.addButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* =============================================================================
* Rule Card
* ============================================================================= */
.ruleCard {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.875rem;
background: var(--bg-secondary);
border-radius: 6px;
border: 1px solid var(--border-color);
}
.ruleHeader {
display: flex;
justify-content: space-between;
align-items: center;
}
.ruleItem {
display: flex;
align-items: center;
gap: 0.5rem;
}
.ruleItemIcon {
color: var(--text-tertiary);
font-size: 0.875rem;
}
.ruleItemName {
font-weight: 500;
color: var(--text-primary);
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.875rem;
}
.ruleActions {
display: flex;
gap: 0.25rem;
}
.iconButton {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: none;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
color: var(--text-tertiary);
transition: all 0.2s;
}
.iconButton:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
border-color: var(--border-color);
}
.iconButton.danger:hover {
background: #fed7d7;
color: #c53030;
border-color: #fc8181;
}
/* =============================================================================
* Permissions Grid
* ============================================================================= */
.permissionsGrid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.5rem;
}
.permissionItem {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.permissionLabel {
font-size: 0.6875rem;
color: var(--text-tertiary);
text-transform: uppercase;
font-weight: 500;
}
/* View Toggle */
.viewToggle {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.viewCheckbox {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--primary-color);
}
/* =============================================================================
* Empty State
* ============================================================================= */
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
color: var(--text-tertiary);
text-align: center;
}
.emptyIcon {
font-size: 2rem;
margin-bottom: 0.75rem;
opacity: 0.5;
}
.emptyText {
font-size: 0.875rem;
margin: 0;
}
.emptyHint {
font-size: 0.75rem;
margin-top: 0.25rem;
}
/* =============================================================================
* Add Rule Modal
* ============================================================================= */
.addRuleForm {
display: flex;
flex-direction: column;
gap: 1rem;
}
.formGroup {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.formLabel {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary);
}
.formInput {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 0.875rem;
background: var(--bg-primary);
color: var(--text-primary);
}
.formInput:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-color-light);
}
.formSelect {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 0.875rem;
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
}
.formHint {
font-size: 0.75rem;
color: var(--text-tertiary);
}
.formActions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
/* =============================================================================
* Action Bar
* ============================================================================= */
.actionBar {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.secondaryButton {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.secondaryButton:hover {
background: var(--bg-tertiary);
}
.secondaryButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.primaryButton {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
background: var(--primary-color);
color: white;
border: none;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.primaryButton:hover {
background: var(--primary-color-dark);
}
.primaryButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* =============================================================================
* Loading State
* ============================================================================= */
.loadingContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
gap: 1rem;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-color);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* =============================================================================
* JSON Editor Tab
* ============================================================================= */
.jsonEditor {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.jsonTextarea {
width: 100%;
min-height: 300px;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 4px;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.8125rem;
line-height: 1.5;
background: var(--bg-secondary);
color: var(--text-primary);
resize: vertical;
}
.jsonTextarea:focus {
outline: none;
border-color: var(--primary-color);
}
.jsonError {
color: #c53030;
font-size: 0.8125rem;
padding: 0.5rem;
background: #fed7d7;
border-radius: 4px;
}
.jsonHint {
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);
}

View file

@ -0,0 +1,762 @@
/**
* AccessRulesEditor
*
* 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, useMemo } from 'react';
import {
FaTable,
FaDesktop,
FaServer,
FaCode,
FaPlus,
FaTrash,
FaSave,
FaUndo,
FaSpinner,
FaThList,
FaTh,
} from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import {
useAccessRules,
type AccessRule,
type RuleContext,
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';
// =============================================================================
// TYPES
// =============================================================================
interface AccessRulesEditorProps {
roleId: string;
roleName?: string;
isTemplate?: boolean;
readOnly?: boolean;
onSave?: () => void;
apiBasePath?: string;
mandateId?: string;
featureCode?: string; // Filter catalog objects to this feature only
}
type TabType = 'DATA' | 'UI' | 'RESOURCE' | 'JSON';
// =============================================================================
// RULE CARD COMPONENT
// =============================================================================
interface RuleCardProps {
rule: AccessRule;
readOnly?: boolean;
onUpdate: (ruleId: string, updates: Partial<AccessRule>) => void;
onDelete: (ruleId: string) => void;
}
const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete }) => {
const isDataRule = rule.context === 'DATA';
return (
<div className={styles.ruleCard}>
<div className={styles.ruleHeader}>
<div className={styles.ruleItem}>
<span className={styles.ruleItemIcon}>
{rule.context === 'DATA' ? <FaTable /> :
rule.context === 'UI' ? <FaDesktop /> : <FaServer />}
</span>
<span className={styles.ruleItemName}>{rule.item || '(global)'}</span>
</div>
{!readOnly && (
<div className={styles.ruleActions}>
<button
className={`${styles.iconButton} ${styles.danger}`}
onClick={() => onDelete(rule.id)}
title="Regel löschen"
>
<FaTrash />
</button>
</div>
)}
</div>
<div className={styles.permissionsGrid}>
{/* View Toggle */}
<div className={styles.permissionItem}>
<span className={styles.permissionLabel}>View</span>
<div className={styles.viewToggle}>
<input
type="checkbox"
checked={rule.view}
onChange={(e) => onUpdate(rule.id, { view: e.target.checked })}
disabled={readOnly}
className={styles.viewCheckbox}
/>
</div>
</div>
{/* CRUD Levels (only for DATA context) */}
{isDataRule ? (
<>
<div className={styles.permissionItem}>
<span className={styles.permissionLabel}>Read</span>
<AccessLevelSelect
value={rule.read}
onChange={(value) => onUpdate(rule.id, { read: value })}
disabled={readOnly}
compact
/>
</div>
<div className={styles.permissionItem}>
<span className={styles.permissionLabel}>Create</span>
<AccessLevelSelect
value={rule.create}
onChange={(value) => onUpdate(rule.id, { create: value })}
disabled={readOnly}
compact
/>
</div>
<div className={styles.permissionItem}>
<span className={styles.permissionLabel}>Update</span>
<AccessLevelSelect
value={rule.update}
onChange={(value) => onUpdate(rule.id, { update: value })}
disabled={readOnly}
compact
/>
</div>
<div className={styles.permissionItem}>
<span className={styles.permissionLabel}>Delete</span>
<AccessLevelSelect
value={rule.delete}
onChange={(value) => onUpdate(rule.id, { delete: value })}
disabled={readOnly}
compact
/>
</div>
</>
) : (
// For UI and RESOURCE, show empty placeholders to maintain grid
<div style={{ gridColumn: 'span 4' }} />
)}
</div>
</div>
);
};
// =============================================================================
// ADD RULE FORM
// =============================================================================
interface AddRuleFormProps {
context: RuleContext;
availableObjects: CatalogObject[];
onAdd: (rule: AccessRuleCreate) => void;
onCancel: () => void;
}
const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, onAdd, onCancel }) => {
const [item, setItem] = useState('');
const [useCustom, setUseCustom] = useState(false);
const [view, setView] = useState(true);
const [read, setRead] = useState<AccessLevel>('n');
const [create, setCreate] = useState<AccessLevel>('n');
const [update, setUpdate] = useState<AccessLevel>('n');
const [del, setDel] = useState<AccessLevel>('n');
// Group objects by feature
const groupedObjects = useMemo(() => {
const grouped: Record<string, CatalogObject[]> = {};
availableObjects.forEach(obj => {
if (!grouped[obj.featureCode]) {
grouped[obj.featureCode] = [];
}
grouped[obj.featureCode].push(obj);
});
return grouped;
}, [availableObjects]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newRule: AccessRuleCreate = {
context,
item: item.trim() || null,
view,
...(context === 'DATA' ? { read, create, update, delete: del } : {}),
};
onAdd(newRule);
};
const getPlaceholder = () => {
switch (context) {
case 'DATA':
return 'z.B. data.feature.trustee.TrusteePosition';
case 'UI':
return 'z.B. ui.feature.trustee.dashboard';
case 'RESOURCE':
return 'z.B. resource.feature.trustee.documents.create';
}
};
const getLabel = (obj: CatalogObject): string => {
return obj.label.de || obj.label.en || obj.objectKey;
};
return (
<form className={styles.addRuleForm} onSubmit={handleSubmit}>
<div className={styles.formGroup}>
<div className={styles.objectSelectorLabel}>
<label className={styles.formLabel}>Objekt auswählen</label>
<button
type="button"
className={styles.toggleCustomButton}
onClick={() => setUseCustom(!useCustom)}
>
{useCustom ? '← Aus Katalog wählen' : 'Freie Eingabe →'}
</button>
</div>
{useCustom ? (
<input
type="text"
value={item}
onChange={(e) => setItem(e.target.value)}
placeholder={getPlaceholder()}
className={styles.formInput}
autoFocus
/>
) : (
<select
value={item}
onChange={(e) => setItem(e.target.value)}
className={styles.formSelect}
>
<option value="">-- Global (alle Objekte) --</option>
{Object.entries(groupedObjects).map(([feature, objs]) => (
<optgroup key={feature} label={feature.toUpperCase()}>
{objs.map(obj => (
<option key={obj.objectKey} value={obj.objectKey}>
{obj.objectKey} - {getLabel(obj)}
</option>
))}
</optgroup>
))}
</select>
)}
<span className={styles.formHint}>
Leer lassen für globale Regel. Längster Match gewinnt bei Wildcards (z.B. data.feature.trustee.*).
</span>
</div>
<div className={styles.formGroup}>
<label className={styles.formLabel}>
<input
type="checkbox"
checked={view}
onChange={(e) => setView(e.target.checked)}
style={{ marginRight: '0.5rem' }}
/>
Sichtbar (View)
</label>
</div>
{context === 'DATA' && (
<div className={styles.addRuleMatrix}>
{/* Header Row */}
<div className={styles.matrixHeader}>
<div className={styles.matrixLabel}></div>
<div className={styles.matrixGroup}>Eigene (m)</div>
<div className={styles.matrixGroup}>Gruppe (g)</div>
<div className={styles.matrixGroup}>Alle (a)</div>
</div>
{/* CRUD Rows */}
{(['create', 'read', 'update', 'delete'] as const).map(op => {
const value = op === 'delete' ? del : op === 'create' ? create : op === 'update' ? update : read;
const setValue = op === 'delete' ? setDel : op === 'create' ? setCreate : op === 'update' ? setUpdate : setRead;
const labels = { create: 'Create', read: 'Read', update: 'Update', delete: 'Delete' };
return (
<div key={op} className={styles.matrixRow}>
<div className={styles.matrixLabel}>{labels[op]}</div>
{(['m', 'g', 'a'] as const).map(level => (
<div key={level} className={styles.matrixCell}>
<input
type="checkbox"
checked={value === level || (level === 'm' && (value === 'g' || value === 'a')) || (level === 'g' && value === 'a')}
onChange={(e) => {
if (e.target.checked) {
setValue(level);
} else {
// Deactivate: set to level below
const hierarchy: AccessLevel[] = ['n', 'm', 'g', 'a'];
const idx = hierarchy.indexOf(level);
setValue(hierarchy[idx - 1] || 'n');
}
}}
title={`${labels[op]} - ${level === 'm' ? 'Eigene' : level === 'g' ? 'Gruppe' : 'Alle'}`}
/>
</div>
))}
</div>
);
})}
</div>
)}
<div className={styles.formActions}>
<button type="button" className={styles.secondaryButton} onClick={onCancel}>
Abbrechen
</button>
<button type="submit" className={styles.primaryButton}>
<FaPlus /> Hinzufügen
</button>
</div>
</form>
);
};
// =============================================================================
// RULES SECTION
// =============================================================================
interface RulesSectionProps {
context: RuleContext;
rules: AccessRule[];
availableObjects: CatalogObject[];
readOnly?: boolean;
onUpdate: (ruleId: string, updates: Partial<AccessRule>) => void;
onDelete: (ruleId: string) => void;
onAdd: (rule: AccessRuleCreate) => void;
}
const RulesSection: React.FC<RulesSectionProps> = ({
context,
rules,
availableObjects,
readOnly,
onUpdate,
onDelete,
onAdd,
}) => {
const [showAddForm, setShowAddForm] = useState(false);
const [useTableView, setUseTableView] = useState(context === 'DATA'); // Default to table for DATA
const handleAdd = (rule: AccessRuleCreate) => {
onAdd(rule);
setShowAddForm(false);
};
const getEmptyIcon = () => {
switch (context) {
case 'DATA': return <FaTable />;
case 'UI': return <FaDesktop />;
case 'RESOURCE': return <FaServer />;
}
};
const getEmptyText = () => {
switch (context) {
case 'DATA': return 'Keine Daten-Regeln definiert';
case 'UI': return 'Keine UI-Regeln definiert';
case 'RESOURCE': return 'Keine Ressourcen-Regeln definiert';
}
};
return (
<div className={styles.rulesSection}>
{!readOnly && !showAddForm && (
<div className={styles.sectionHeader}>
<span className={styles.sectionTitle}>
{rules.length} {rules.length === 1 ? 'Regel' : 'Regeln'}
</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
className={styles.addButton}
onClick={() => setShowAddForm(true)}
>
<FaPlus /> Neue Regel
</button>
</div>
</div>
)}
{showAddForm && (
<AddRuleForm
context={context}
availableObjects={availableObjects}
onAdd={handleAdd}
onCancel={() => setShowAddForm(false)}
/>
)}
{rules.length === 0 && !showAddForm ? (
<div className={styles.emptyState}>
<div className={styles.emptyIcon}>{getEmptyIcon()}</div>
<p className={styles.emptyText}>{getEmptyText()}</p>
{!readOnly && (
<p className={styles.emptyHint}>
Klicken Sie auf "Neue Regel" um eine Berechtigung hinzuzufügen.
</p>
)}
</div>
) : useTableView && context === 'DATA' ? (
<AccessRulesTable
rules={rules}
context={context}
readOnly={readOnly}
onUpdate={onUpdate}
onDelete={onDelete}
/>
) : (
rules.map(rule => (
<RuleCard
key={rule.id}
rule={rule}
readOnly={readOnly}
onUpdate={onUpdate}
onDelete={onDelete}
/>
))
)}
</div>
);
};
// =============================================================================
// JSON EDITOR
// =============================================================================
interface JsonEditorProps {
rules: AccessRule[];
readOnly?: boolean;
onApply: (rules: AccessRule[]) => void;
}
const JsonEditor: React.FC<JsonEditorProps> = ({ rules, readOnly, onApply }) => {
const [jsonText, setJsonText] = useState('');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setJsonText(JSON.stringify(rules, null, 2));
setError(null);
}, [rules]);
const handleApply = () => {
try {
const parsed = JSON.parse(jsonText);
if (!Array.isArray(parsed)) {
throw new Error('JSON muss ein Array sein');
}
setError(null);
onApply(parsed);
} catch (err: any) {
setError(err.message);
}
};
return (
<div className={styles.jsonEditor}>
<textarea
value={jsonText}
onChange={(e) => setJsonText(e.target.value)}
className={styles.jsonTextarea}
readOnly={readOnly}
spellCheck={false}
/>
{error && <div className={styles.jsonError}>{error}</div>}
<p className={styles.jsonHint}>
Experten-Modus: Bearbeiten Sie die Regeln direkt als JSON.
Änderungen werden erst nach Klick auf "Anwenden" übernommen.
</p>
{!readOnly && (
<div className={styles.formActions}>
<button
type="button"
className={styles.primaryButton}
onClick={handleApply}
disabled={!!error}
>
JSON anwenden
</button>
</div>
)}
</div>
);
};
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
roleId,
roleName,
isTemplate = false,
readOnly = false,
onSave,
apiBasePath = '/api/rbac',
mandateId,
featureCode,
}) => {
const { showError } = useToast();
const {
rules,
loading,
saving,
error,
fetchRules,
saveRules,
getGroupedRules,
updateRuleLocally,
addRuleLocally,
removeRuleLocally,
} = useAccessRules(roleId, apiBasePath, mandateId);
// Catalog objects for dropdown selection
const { objects: catalogObjects, fetchObjects } = useCatalogObjects();
const [activeTab, setActiveTab] = useState<TabType>('DATA');
const [hasChanges, setHasChanges] = useState(false);
const [originalRules, setOriginalRules] = useState<AccessRule[]>([]);
// Load rules on mount
useEffect(() => {
fetchRules().then(fetchedRules => {
setOriginalRules(fetchedRules);
});
}, [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));
}, [rules, originalRules]);
const groupedRules = getGroupedRules();
// Handlers
const handleUpdate = useCallback((ruleId: string, updates: Partial<AccessRule>) => {
updateRuleLocally(ruleId, updates);
}, [updateRuleLocally]);
const handleDelete = useCallback((ruleId: string) => {
// Direct delete - rules are local until saved
removeRuleLocally(ruleId);
}, [removeRuleLocally]);
const handleAdd = useCallback((ruleData: AccessRuleCreate) => {
const newRule: AccessRule = {
id: `temp-${Date.now()}`, // Temporary ID
roleId,
context: ruleData.context,
item: ruleData.item || null,
view: ruleData.view ?? true,
read: ruleData.read ?? null,
create: ruleData.create ?? null,
update: ruleData.update ?? null,
delete: ruleData.delete ?? null,
};
addRuleLocally(newRule);
}, [roleId, addRuleLocally]);
const handleSave = async () => {
const result = await saveRules(rules);
if (result.success) {
setOriginalRules(rules);
setHasChanges(false);
onSave?.();
} else {
showError('Fehler', result.error || 'Fehler beim Speichern');
}
};
const handleReset = () => {
// Direct reset - user clicked the reset button intentionally
fetchRules().then(fetchedRules => {
setOriginalRules(fetchedRules);
});
};
const handleJsonApply = (newRules: AccessRule[]) => {
// Replace all rules
newRules.forEach((rule, index) => {
if (!rule.id) {
rule.id = `temp-${Date.now()}-${index}`;
}
rule.roleId = roleId;
});
// This is a bit hacky - we need to update the store
// For now, we'll just save directly
saveRules(newRules);
};
// Render tabs
const tabs: { id: TabType; label: string; icon: React.ReactNode; count: number }[] = [
{ id: 'DATA', label: 'Daten', icon: <FaTable />, count: groupedRules.DATA.length },
{ id: 'UI', label: 'UI', icon: <FaDesktop />, count: groupedRules.UI.length },
{ id: 'RESOURCE', label: 'Ressourcen', icon: <FaServer />, count: groupedRules.RESOURCE.length },
{ id: 'JSON', label: 'JSON', icon: <FaCode />, count: rules.length },
];
if (loading) {
return (
<div className={styles.accessRulesEditor}>
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Berechtigungen...</span>
</div>
</div>
);
}
return (
<div className={styles.accessRulesEditor}>
<div className={styles.editorHeader}>
<h3 className={styles.editorTitle}>
Berechtigungen{roleName ? `: ${roleName}` : ''}
{isTemplate && <span className={styles.templateBadge}>Template</span>}
</h3>
{!readOnly && hasChanges && (
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={handleReset}
disabled={saving}
>
<FaUndo /> Zurücksetzen
</button>
</div>
)}
</div>
{error && (
<div className={styles.jsonError}>
Fehler: {error}
</div>
)}
<div className={styles.tabsContainer}>
<div className={styles.tabList}>
{tabs.map(tab => (
<button
key={tab.id}
className={`${styles.tab} ${activeTab === tab.id ? styles.active : ''}`}
onClick={() => setActiveTab(tab.id)}
>
<span className={styles.tabIcon}>{tab.icon}</span>
{tab.label}
<span className={styles.tabBadge}>{tab.count}</span>
</button>
))}
</div>
<div className={styles.tabContent}>
{activeTab === 'DATA' && (
<RulesSection
context="DATA"
rules={groupedRules.DATA}
availableObjects={catalogObjects.DATA || []}
readOnly={readOnly}
onUpdate={handleUpdate}
onDelete={handleDelete}
onAdd={handleAdd}
/>
)}
{activeTab === 'UI' && (
<RulesSection
context="UI"
rules={groupedRules.UI}
availableObjects={catalogObjects.UI || []}
readOnly={readOnly}
onUpdate={handleUpdate}
onDelete={handleDelete}
onAdd={handleAdd}
/>
)}
{activeTab === 'RESOURCE' && (
<RulesSection
context="RESOURCE"
rules={groupedRules.RESOURCE}
availableObjects={catalogObjects.RESOURCE || []}
readOnly={readOnly}
onUpdate={handleUpdate}
onDelete={handleDelete}
onAdd={handleAdd}
/>
)}
{activeTab === 'JSON' && (
<JsonEditor
rules={rules}
readOnly={readOnly}
onApply={handleJsonApply}
/>
)}
</div>
</div>
{!readOnly && (
<div className={styles.actionBar}>
<button
className={styles.primaryButton}
onClick={handleSave}
disabled={saving || !hasChanges}
>
{saving ? (
<>
<FaSpinner className="spinning" /> Speichern...
</>
) : (
<>
<FaSave /> Speichern
</>
)}
</button>
</div>
)}
</div>
);
};
export default AccessRulesEditor;

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

@ -0,0 +1,9 @@
/**
* AccessRules Components
*
* Components for editing RBAC access rules.
*/
export { AccessRulesEditor } from './AccessRulesEditor';
export { AccessLevelSelect } from './AccessLevelSelect';
export { AccessRulesTable } from './AccessRulesTable';

View file

@ -0,0 +1,72 @@
import React, { useState } from 'react';
import { IoIosDownload } from 'react-icons/io';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import styles from '../ActionButton.module.css';
export interface DownloadActionButtonProps<T = any> {
row: T;
onDownload: (row: T) => Promise<void> | void;
disabled?: boolean | { disabled: boolean; message?: string };
loading?: boolean;
className?: string;
title?: string;
hookData?: any;
idField?: string;
loadingStateName?: string;
}
export function DownloadActionButton<T = any>({
row,
onDownload,
disabled = false,
loading = false,
className = '',
title,
hookData,
idField = 'id',
loadingStateName = 'downloadingFiles'
}: DownloadActionButtonProps<T>) {
const { t } = useLanguage();
const [internalLoading, setInternalLoading] = useState(false);
// Extract disabled state and tooltip message
const isDisabled = typeof disabled === 'boolean' ? disabled : disabled?.disabled || false;
const disabledMessage = typeof disabled === 'object' ? disabled?.message : undefined;
const handleClick = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!isDisabled && !loading && !internalLoading) {
setInternalLoading(true);
try {
if (onDownload) {
await onDownload(row);
}
} finally {
setInternalLoading(false);
}
}
};
const buttonTitle = title || t('files.action.download', 'Download');
// Use hookData loading state if available
const loadingState = hookData?.[loadingStateName];
const actualIsLoading = loadingState?.has((row as any)[idField]) || loading || internalLoading;
// Determine the final button title (tooltip)
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
return (
<button
onClick={handleClick}
className={`${styles.actionButton} ${styles.download} ${actualIsLoading ? styles.loading : ''} ${isDisabled ? styles.disabled : ''} ${className}`}
title={finalTitle}
disabled={isDisabled || actualIsLoading}
>
<span className={styles.actionIcon}>
{actualIsLoading ? '⏳' : <IoIosDownload />}
</span>
</button>
);
}
export default DownloadActionButton;

View file

@ -0,0 +1,2 @@
export { DownloadActionButton, type DownloadActionButtonProps } from './DownloadActionButton';
export { DownloadActionButton as default } from './DownloadActionButton';

View file

@ -84,15 +84,23 @@ export function EditActionButton<T = any>({
const handleClick = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!isDisabled && !loading && !isEditing && !internalLoading && !fetchingData && !isPopupOpen) {
// If onEdit callback is provided, call it and return early (custom handling)
// The page will handle opening its own modal/form
if (onEdit) {
setInternalLoading(true);
try {
await onEdit(row);
} finally {
setInternalLoading(false);
}
return; // Don't open the built-in popup when custom onEdit is provided
}
// Otherwise, use the built-in popup form
setInternalLoading(true);
setFetchingData(true);
try {
// Call the onEdit callback if provided
if (onEdit) {
await onEdit(row);
}
const itemId = (row as any)[idField];
// Fetch current item data - use generic fetch function from hookData

View file

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

View file

@ -87,8 +87,9 @@ export function FormGeneratorControls({
onPageChange,
onPageSizeChange,
supportsBackendPagination = false,
hookData
hookData: _hookData // Reserved for future use
}: FormGeneratorControlsProps) {
void _hookData; // Suppress unused variable warning
const { t } = useLanguage();
// Check if all items are selected
@ -251,11 +252,9 @@ export function FormGeneratorControls({
»»
</button>
{/* Total items count */}
{/* Total items count - always show actual displayed data length */}
<span className={styles.paginationInfo}>
({hookData?.pagination?.totalItems != null
? hookData.pagination.totalItems.toString()
: (loading ? '...' : displayData.length.toString())} {t('formgen.pagination.items', 'items')})
({loading ? '...' : displayData.length.toString()} {t('formgen.pagination.items', 'items')})
</span>
</div>
)}

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useLanguage } from '../../../providers/language/LanguageContext';
import api from '../../../api';
import styles from './FormGeneratorForm.module.css';
@ -82,6 +82,8 @@ export interface FormGeneratorFormProps<T = any> {
transformField?: (attribute: AttributeDefinition) => AttributeDefinition;
// Optional: Custom validation function
customValidator?: (formData: T, attributes: AttributeDefinition[]) => Record<string, string>;
// Optional: Feature instance ID for feature-scoped options (replaces {instanceId} in API paths)
instanceId?: string;
}
// FormGeneratorForm component - Backend-driven form generation
@ -98,9 +100,11 @@ export function FormGeneratorForm<T extends Record<string, any>>({
attributes: providedAttributes,
filterFields,
transformField,
customValidator
customValidator,
instanceId
}: FormGeneratorFormProps<T>) {
const { t } = useLanguage();
const [formData, setFormData] = useState<T>(data || {} as T);
const [errors, setErrors] = useState<Record<string, string>>({});
const [fieldFocused, setFieldFocused] = useState<Record<string, boolean>>({});
@ -109,6 +113,21 @@ export function FormGeneratorForm<T extends Record<string, any>>({
const [optionsCache, setOptionsCache] = useState<Record<string, Array<{ value: string | number; label: string }>>>({});
const [loadingOptions, setLoadingOptions] = useState<Record<string, boolean>>({});
const [submitting, setSubmitting] = useState(false);
// Track which option keys have been fetched or are being fetched (using ref to avoid re-renders)
const fetchedOrFetchingOptions = useRef<Set<string>>(new Set());
// Helper to resolve API paths with {instanceId} placeholder - memoized to prevent useEffect re-runs
const resolveApiPath = useCallback((path: string): string => {
if (path.includes('{instanceId}')) {
const resolvedInstanceId = instanceId || (data as any)?.featureInstanceId;
if (!resolvedInstanceId) {
console.warn(`API path "${path}" requires instanceId but none provided`);
return path;
}
return path.replace('{instanceId}', resolvedInstanceId);
}
return path;
}, [instanceId, data]);
// Fetch attributes from backend
useEffect(() => {
@ -167,9 +186,13 @@ export function FormGeneratorForm<T extends Record<string, any>>({
filtered = filterFields(filtered);
} else {
// Default filtering based on mode
// Note: readonly fields (editable === false) should be shown but rendered as read-only
// Only hide fields where visible === false explicitly
if (mode === 'edit') {
filtered = filtered.filter(attr => attr.visible !== false && attr.editable !== false);
// Show all visible fields (readonly fields are rendered as non-editable in renderField)
filtered = filtered.filter(attr => attr.visible !== false);
} else if (mode === 'create') {
// In create mode, hide truly non-editable fields (user can't set them)
filtered = filtered.filter(attr => attr.visible !== false && attr.editable !== false);
} else if (mode === 'display') {
filtered = filtered.filter(attr => attr.visible !== false);
@ -234,66 +257,92 @@ export function FormGeneratorForm<T extends Record<string, any>>({
setFieldFocused({});
}, [data, getFilteredAttributes]);
// Fetch options for fields with optionsReference
// Fetch options for fields with optionsReference (API path)
// Backend provides options in standardized format: { value, label }
// OPTIMIZED: Only fetch options that are not already fetched or being fetched
useEffect(() => {
const fetchOptions = async () => {
const filteredAttrs = getFilteredAttributes();
const fieldsToFetch = filteredAttrs.filter(attr => {
if (typeof attr.options === 'string' && !optionsCache[attr.options]) {
return true;
// Collect unique option keys that need fetching
const optionKeysToFetch: string[] = [];
filteredAttrs.forEach(attr => {
if (typeof attr.options === 'string') {
const optionKey = attr.options;
// Only fetch if not already fetched or being fetched (using ref for immediate check)
if (!fetchedOrFetchingOptions.current.has(optionKey)) {
optionKeysToFetch.push(optionKey);
// Immediately mark as being fetched to prevent duplicate requests
fetchedOrFetchingOptions.current.add(optionKey);
}
}
return false;
});
if (fieldsToFetch.length === 0) return;
if (optionKeysToFetch.length === 0) return;
for (const field of fieldsToFetch) {
if (typeof field.options !== 'string') continue;
setLoadingOptions(prev => ({ ...prev, [field.name]: true }));
// Set loading state for relevant fields
const fieldsLoading: Record<string, boolean> = {};
filteredAttrs.forEach(attr => {
if (typeof attr.options === 'string' && optionKeysToFetch.includes(attr.options)) {
fieldsLoading[attr.name] = true;
}
});
setLoadingOptions(prev => ({ ...prev, ...fieldsLoading }));
// Fetch all options in parallel
const fetchPromises = optionKeysToFetch.map(async (optionKey) => {
try {
const response = await api.get(`/api/options/${field.options}`);
// Resolve {instanceId} placeholder if present
const apiPath = resolveApiPath(optionKey);
const response = await api.get(apiPath);
let fetchedOptions: Array<{ value: string | number; label: string }> = [];
if (Array.isArray(response.data)) {
// Backend returns standardized format: [{ value, label }]
fetchedOptions = response.data.map((opt: any) => {
if (typeof opt === 'string' || typeof opt === 'number') {
return { value: opt, label: String(opt) };
}
// Handle multilingual labels
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
value: opt.value,
label: labelValue
};
});
} else if (response.data?.options && Array.isArray(response.data.options)) {
fetchedOptions = response.data.options.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
value: opt.value,
label: labelValue
};
return { value: opt.value, label: labelValue };
});
}
setOptionsCache(prev => ({ ...prev, [field.options as string]: fetchedOptions }));
return { key: optionKey, options: fetchedOptions, error: null };
} catch (error: any) {
console.error(`Failed to fetch options for ${field.options}:`, error);
setOptionsCache(prev => ({ ...prev, [field.options as string]: [] }));
} finally {
setLoadingOptions(prev => ({ ...prev, [field.name]: false }));
console.error(`Failed to fetch options for ${optionKey}:`, error);
return { key: optionKey, options: [], error };
}
}
});
// Wait for all fetches to complete
const results = await Promise.all(fetchPromises);
// Update cache with all results at once
setOptionsCache(prev => {
const newCache = { ...prev };
results.forEach(({ key, options }) => {
newCache[key] = options;
});
return newCache;
});
// Clear loading states
const fieldsNotLoading: Record<string, boolean> = {};
filteredAttrs.forEach(attr => {
if (typeof attr.options === 'string' && optionKeysToFetch.includes(attr.options)) {
fieldsNotLoading[attr.name] = false;
}
});
setLoadingOptions(prev => ({ ...prev, ...fieldsNotLoading }));
};
fetchOptions();
}, [getFilteredAttributes, optionsCache]);
}, [getFilteredAttributes, resolveApiPath]);
// Handle field focus
const handleFieldFocus = (fieldName: string, focused: boolean) => {
@ -304,10 +353,22 @@ export function FormGeneratorForm<T extends Record<string, any>>({
};
// Handle field value changes
const handleFieldChange = (fieldName: string, value: any) => {
// For timestamp fields: Convert datetime-local string to Unix timestamp (float in seconds)
const handleFieldChange = (fieldName: string, value: any, fieldType?: AttributeType) => {
let processedValue = value;
// If field type is timestamp, convert datetime-local string to Unix timestamp
if (fieldType === 'timestamp' && typeof value === 'string' && value) {
const date = new Date(value);
if (!isNaN(date.getTime())) {
// Convert to Unix timestamp in seconds (float)
processedValue = date.getTime() / 1000;
}
}
setFormData(prev => ({
...prev,
[fieldName]: value
[fieldName]: processedValue
}));
// Clear error for this field when user starts typing
@ -319,6 +380,24 @@ export function FormGeneratorForm<T extends Record<string, any>>({
});
}
};
// Convert Unix timestamp (seconds) to datetime-local input format
const timestampToDatetimeLocal = (timestamp: number | string | null | undefined): string => {
if (timestamp === null || timestamp === undefined || timestamp === '') {
return '';
}
const numValue = typeof timestamp === 'string' ? parseFloat(timestamp) : timestamp;
if (isNaN(numValue)) {
return '';
}
// Unix timestamp in seconds - convert to milliseconds for Date
const date = new Date(numValue * 1000);
if (isNaN(date.getTime())) {
return '';
}
// Format as datetime-local: YYYY-MM-DDTHH:mm
return date.toISOString().slice(0, 16);
};
// Normalize options for a field
const normalizeOptions = (attr: AttributeDefinition): Array<{ value: string | number; label: string }> => {
@ -420,8 +499,17 @@ export function FormGeneratorForm<T extends Record<string, any>>({
// Timestamp/Date validation
if (isDateTimeType(attr.type)) {
const dateValue = new Date(String(value));
if (isNaN(dateValue.getTime())) {
// For timestamp fields, value is stored as Unix timestamp (float)
// For date/time fields, value is stored as string
let isValid = false;
if (attr.type === 'timestamp' && typeof value === 'number') {
// Unix timestamp in seconds - valid if it's a reasonable timestamp
isValid = value > 0 && value < 4102444800; // Before year 2100
} else {
const dateValue = new Date(String(value));
isValid = !isNaN(dateValue.getTime());
}
if (!isValid) {
newErrors[attr.name] = t('formgen.form.invalidDate', 'Invalid date format');
return;
}
@ -634,11 +722,17 @@ export function FormGeneratorForm<T extends Record<string, any>>({
const option = options.find(opt => String(opt.value) === String(v));
return option ? option.label : v;
}).join(', ') || t('common.none', 'None');
} else if (typeof value === 'object' && value !== null) {
// Convert objects/arrays to formatted JSON string for display
displayValue = JSON.stringify(value, null, 2);
}
// Use pre tag for JSON/object values to preserve formatting
const isJsonValue = typeof value === 'object' && value !== null;
return (
<div className={styles.floatingLabelInput} key={attr.name}>
<div className={styles.readonlyField}>
<div className={styles.readonlyField} style={isJsonValue ? { whiteSpace: 'pre-wrap', fontFamily: 'monospace', fontSize: '0.85em' } : undefined}>
{displayValue || t('common.na', 'N/A')}
</div>
<label className={styles.focusedLabel}>
@ -759,7 +853,23 @@ export function FormGeneratorForm<T extends Record<string, any>>({
const minHeight = minRows * 1.5 * 16;
const maxHeight = maxRows * 1.5 * 16;
const currentValue = value || '';
// Handle object/array values by converting to JSON string for display
const isObjectValue = typeof value === 'object' && value !== null;
// Check if string value looks like JSON (for fields that were originally objects but temporarily invalid)
const looksLikeJson = typeof value === 'string' && value.trim().match(/^[\[{]/);
const isJsonField = isObjectValue || looksLikeJson;
let currentValue = '';
if (isObjectValue) {
try {
currentValue = JSON.stringify(value, null, 2);
} catch {
currentValue = String(value);
}
} else {
currentValue = value || '';
}
const isContentField = attr.name === 'content' || attr.name.toLowerCase().includes('content');
const textareaClassName = isContentField
? `${styles.fieldTextarea} ${styles.contentTextarea} ${hasError ? styles.fieldError : ''}`
@ -771,7 +881,20 @@ export function FormGeneratorForm<T extends Record<string, any>>({
name={attr.name}
value={currentValue}
onChange={(e) => {
handleFieldChange(attr.name, e.target.value);
const newTextValue = e.target.value;
const trimmed = newTextValue.trim();
// Try to parse as JSON if it looks like JSON (starts with { or [)
if (trimmed.match(/^[\[{]/)) {
try {
const parsed = JSON.parse(newTextValue);
handleFieldChange(attr.name, parsed);
} catch {
// If parsing fails, store as string (user is still typing)
handleFieldChange(attr.name, newTextValue);
}
} else {
handleFieldChange(attr.name, newTextValue);
}
const textarea = e.target;
textarea.style.height = 'auto';
const scrollHeight = textarea.scrollHeight;
@ -785,6 +908,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
onBlur={() => handleFieldFocus(attr.name, false)}
className={textareaClassName}
rows={minRows}
style={isJsonField ? { fontFamily: 'monospace', fontSize: '0.85em' } : undefined}
ref={(textarea) => {
if (textarea) {
textarea.style.setProperty('min-height', `${minHeight}px`, 'important');
@ -794,7 +918,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
}
}}
/>
<label className={getLabelClass(attr.name, value)}>
<label className={getLabelClass(attr.name, currentValue)}>
{attr.label}
{attr.required && <span className={styles.required}>*</span>}
</label>
@ -826,26 +950,32 @@ export function FormGeneratorForm<T extends Record<string, any>>({
);
}
// Default input field (text, email, date, time, url, password, number, integer, float)
// Default input field (text, email, date, time, url, password, number, integer, float, timestamp)
const inputType = attributeTypeToInputType(attr.type);
// For timestamp fields, convert Unix timestamp (float) to datetime-local format for display
const displayValue = attr.type === 'timestamp'
? timestampToDatetimeLocal(value)
: (value || '');
return (
<div className={styles.floatingLabelInput} key={attr.name}>
<input
type={inputType}
value={value || ''}
value={displayValue}
onChange={(e) => {
let newValue: any = e.target.value;
if (isNumberType(attr.type)) {
newValue = e.target.value === '' ? '' : Number(e.target.value);
}
handleFieldChange(attr.name, newValue);
// Pass field type for timestamp conversion
handleFieldChange(attr.name, newValue, attr.type);
}}
onFocus={() => handleFieldFocus(attr.name, true)}
onBlur={() => handleFieldFocus(attr.name, false)}
className={`${styles.fieldInput} ${hasError ? styles.fieldError : ''}`}
/>
<label className={getLabelClass(attr.name, value)}>
<label className={getLabelClass(attr.name, displayValue)}>
{attr.label}
{attr.required && <span className={styles.required}>*</span>}
</label>

View file

@ -111,72 +111,32 @@ export function FormGeneratorList<T extends Record<string, any>>({
}: FormGeneratorListProps<T>) {
const { t } = useLanguage();
// Cache fields so they persist even when data is empty
// Cache fields so they persist even when data is empty (e.g., after filtering)
// NO AUTO-DETECTION - fields must come from backend attribute definitions
const fieldsRef = useRef<FieldConfig[]>([]);
const detectedFields = useMemo((): FieldConfig[] => {
// Always use providedFields if available
// Use providedFields from Pydantic attribute definitions
if (providedFields && providedFields.length > 0) {
fieldsRef.current = providedFields;
return providedFields;
}
// If we have cached fields and no new fields provided, use cached fields
if (fieldsRef.current.length > 0 && data.length === 0) {
// Use cached fields if data becomes empty (e.g., after filtering)
if (fieldsRef.current.length > 0) {
return fieldsRef.current;
}
// Only auto-detect if no fields provided AND we have data
if (data.length === 0) {
return fieldsRef.current;
}
// NO FIELDS PROVIDED - this is an error in the calling component
// The calling component should provide fields from the /attributes/{entityType} endpoint
console.warn(
'⚠️ FormGeneratorList: No fields provided! ' +
'Fields should come from Pydantic attribute definitions via /attributes/{entityType} endpoint. ' +
'Please ensure the calling component fetches and passes fields from the backend.'
);
const sampleRow = data[0];
const autoDetected = Object.keys(sampleRow).map(key => {
const value = sampleRow[key];
let type: FieldConfig['type'] = 'string';
// Check if field name suggests it's a timestamp/date field
const isTimestampField = /(at|date|time|timestamp|created|updated|expires|checked)$/i.test(key);
// Auto-detect type based on value
if (typeof value === 'number') {
if (isTimestampField || (value > 0 && value < 4102444800000)) {
if (value < 10000000000) {
type = 'date';
} else if (value < 4102444800000) {
type = 'date';
} else {
type = 'number';
}
} else {
type = 'number';
}
} else if (typeof value === 'boolean') {
type = 'boolean';
} else if (value instanceof Date || (typeof value === 'string' && !isNaN(Date.parse(value)) && value.includes('-'))) {
type = 'date';
} else if (isTimestampField && typeof value === 'string') {
const numValue = parseFloat(value);
if (!isNaN(numValue) && numValue > 0 && numValue < 4102444800000) {
type = 'date';
}
}
return {
key,
label: key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1'),
type,
editable: false
};
});
// Cache auto-detected fields
if (autoDetected.length > 0) {
fieldsRef.current = autoDetected;
}
return autoDetected;
// Return empty array - list will show no fields
return [];
}, [providedFields, data]);
// State management
@ -409,7 +369,6 @@ export function FormGeneratorList<T extends Record<string, any>>({
}
} else {
console.warn('No delete handler found in hookData or props');
alert('No delete handler configured');
return;
}
} else if (onDeleteMultiple) {
@ -428,7 +387,6 @@ export function FormGeneratorList<T extends Record<string, any>>({
}
} else {
console.warn('No delete handler provided');
alert('No delete handler configured');
return;
}
@ -438,7 +396,6 @@ export function FormGeneratorList<T extends Record<string, any>>({
console.log('Delete completed, selection cleared');
} catch (error) {
console.error('Delete failed:', error);
alert(`Delete failed: ${error}`);
} finally {
setIsDeleting(false);
}

View file

@ -4,10 +4,10 @@
gap: 10px;
width: 100%;
font-family: var(--font-family);
/* Ensure proper height constraints for scrolling */
/* Fill available space and constrain height */
min-height: 0;
flex: 1;
overflow: hidden;
/* No overflow - children handle their own scrolling */
}
.title {
@ -18,17 +18,18 @@
margin-bottom: 10px;
}
/* Table Container */
/* Table Container - scrollable area for table data only */
.tableContainer {
position: relative;
overflow: auto;
border: 1px solid var(--color-primary);
border-radius: 25px;
background: var(--color-bg);
/* Fill available space in flex container */
/* Fill remaining space after controls */
flex: 1;
min-height: 0;
/* Ensure scrolling within container */
max-height: 100%;
/* Clip content to border-radius but allow sticky to work */
isolation: isolate;
}
/* Empty table styling - no extra space, just header */
@ -69,7 +70,9 @@
/* Table Styles */
.table {
width: 100%;
border-collapse: collapse;
/* Use separate borders for sticky header support */
border-collapse: separate;
border-spacing: 0;
font-size: 14px;
background: var(--color-bg);
table-layout: fixed;
@ -96,9 +99,20 @@
background-color: rgba(255, 255, 255, 0.05) !important;
}
.th {
/* Sticky thead for table header */
.table thead {
position: sticky;
top: 0;
z-index: 10;
/* Shadow to separate header from scrolled content */
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.table thead tr {
background: var(--color-bg);
}
.th {
background: var(--color-bg);
padding: 10px 16px;
text-align: left;
@ -109,8 +123,10 @@
overflow-wrap: break-word;
word-break: break-word;
user-select: none;
z-index: 10;
overflow: visible;
/* Border separates header from scrolled content */
border-bottom: 2px solid var(--color-primary);
/* Shadow on the row, not individual cells */
}
.th.actionsColumn {
@ -297,6 +313,12 @@
overflow: visible;
}
/* FK Loading state - shows truncated ID while loading */
.fkLoading {
color: var(--color-text);
opacity: 0.6;
font-style: italic;
}
.tr {
transition: background-color 0.2s ease;
@ -322,6 +344,11 @@
position: relative;
}
/* Selection column header - background inherited from thead */
thead .selectColumn {
background: var(--color-bg);
}
/* Selection Column border only on body cells, not header */
tbody .selectColumn {
border-top: 1px solid var(--color-primary);
@ -370,6 +397,11 @@ tbody .selectColumn {
position: relative;
}
/* Actions column header - background inherited from thead */
thead .actionsColumn {
background: var(--color-bg);
}
/* Actions Column border only on body cells, not header */
tbody .actionsColumn {
border-top: 1px solid var(--color-primary);
@ -377,6 +409,7 @@ tbody .actionsColumn {
.actionButtons {
display: flex;
flex-wrap: nowrap;
gap: 2px;
justify-content: center;
align-items: center;
@ -384,6 +417,11 @@ tbody .actionsColumn {
margin: 0 auto;
}
/* Enable wrapping only when column exceeds 20% of container width */
.actionButtonsWrap {
flex-wrap: wrap;
}
.actionButton {
display: flex;
align-items: center;

View file

@ -17,6 +17,10 @@ import {
} from '../../../utils/attributeTypeMapper';
import type { AttributeType } from '../../../utils/attributeTypeMapper';
import { FaFilter } from 'react-icons/fa';
import api from '../../../api';
// FK Cache type: maps fkSource -> { id -> displayLabel }
type FkCacheType = Record<string, Record<string, string>>;
// Helper function to detect TextMultilingual objects
// TextMultilingual has structure: { en: string, ge?: string, fr?: string, it?: string }
@ -80,6 +84,8 @@ export interface ColumnConfig {
formatter?: (value: any, row: any) => React.ReactNode;
filterOptions?: string[]; // For enum/select filters
cellClassName?: (value: any, row: any) => string; // For custom cell styling
fkSource?: string; // API endpoint for FK resolution (e.g., "/api/users/")
fkDisplayField?: string; // Which field of FK target to display (e.g., "username", "name", "roleLabel")
}
export interface FormGeneratorTableProps<T = any> {
@ -196,90 +202,33 @@ export function FormGeneratorTable<T extends Record<string, any>>({
}
return 'en'; // Default to English
}, []);
// Use provided columns (from attributes) if available, otherwise auto-detect from data
// Columns should persist even when data is empty (e.g., after filtering)
// Use a ref to cache columns so they persist across data changes
// Use provided columns from Pydantic attribute definitions
// NO AUTO-DETECTION - columns must come from backend attribute definitions
// Use a ref to cache columns so they persist across data changes (e.g., when filtering)
const columnsRef = useRef<ColumnConfig[]>([]);
const detectedColumns = useMemo((): ColumnConfig[] => {
// Always use providedColumns if available (from attributes/hookData.columns)
// This ensures columns persist even when data is empty
// Use providedColumns from Pydantic attribute definitions
if (providedColumns && providedColumns.length > 0) {
columnsRef.current = providedColumns;
return providedColumns;
}
// If we have cached columns and no new columns provided, use cached columns
// This prevents columns from disappearing when data becomes empty
if (columnsRef.current.length > 0 && data.length === 0) {
// Use cached columns if data becomes empty (e.g., after filtering)
if (columnsRef.current.length > 0) {
return columnsRef.current;
}
// Only auto-detect if no columns provided AND we have data
if (data.length === 0) {
// Return cached columns if available, otherwise empty array
return columnsRef.current;
}
// NO COLUMNS PROVIDED - this is an error in the calling component
// The calling component should provide columns from the /attributes/{entityType} endpoint
console.warn(
'⚠️ FormGeneratorTable: No columns provided! ' +
'Columns should come from Pydantic attribute definitions via /attributes/{entityType} endpoint. ' +
'Please ensure the calling component fetches and passes columns from the backend.'
);
const sampleRow = data[0];
const autoDetected = Object.keys(sampleRow).map(key => {
const value = sampleRow[key];
let type: ColumnConfig['type'] = 'string';
// Check if field name suggests it's a timestamp/date field
const isTimestampField = /(at|date|time|timestamp|created|updated|expires|checked)$/i.test(key);
// Auto-detect type based on value
if (typeof value === 'number') {
// Check if it's a Unix timestamp (in seconds or milliseconds)
// Unix timestamps are typically between 1970-01-01 (0) and year 2100 (4102444800 in seconds, 4102444800000 in ms)
if (isTimestampField || (value > 0 && value < 4102444800000)) {
// If it's a reasonable timestamp range, treat as date
// Timestamps in seconds are < 4102444800, timestamps in ms are < 4102444800000
if (value < 10000000000) {
// Likely Unix timestamp in seconds (e.g., 1704067200)
type = 'date';
} else if (value < 4102444800000) {
// Could be Unix timestamp in milliseconds (e.g., 1704067200000)
type = 'date';
} else {
// Too large to be a timestamp, treat as number
type = 'number';
}
} else {
type = 'number';
}
} else if (typeof value === 'boolean') {
type = 'boolean';
} else if (value instanceof Date || (typeof value === 'string' && !isNaN(Date.parse(value)) && value.includes('-'))) {
type = 'date';
} else if (isTimestampField && typeof value === 'string') {
// Field name suggests timestamp but value is string - try to parse
const numValue = parseFloat(value);
if (!isNaN(numValue) && numValue > 0 && numValue < 4102444800000) {
type = 'date';
}
}
return {
key,
label: key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1'),
type,
sortable: true,
filterable: true,
searchable: type === 'string',
width: 150,
minWidth: 100,
maxWidth: 400
};
});
// Cache auto-detected columns
if (autoDetected.length > 0) {
columnsRef.current = autoDetected;
}
return autoDetected;
// Return empty array - table will show no columns
return [];
}, [providedColumns, data]);
// State management
@ -290,12 +239,19 @@ export function FormGeneratorTable<T extends Record<string, any>>({
const [sortConfigs, setSortConfigs] = useState<Array<{ key: string; direction: 'asc' | 'desc' }>>([]);
const [filters, setFilters] = useState<Record<string, any>>({});
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
// Actions column width - resizable, default based on number of buttons
const [actionsColumnWidth, setActionsColumnWidth] = useState<number | null>(null);
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [currentPageSize, setCurrentPageSize] = useState(pageSize);
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
const filterDropdownRef = useRef<HTMLDivElement>(null);
// FK Resolution: Cache for resolved FK values (fkSource -> { id -> displayLabel })
const [fkCache, setFkCache] = useState<FkCacheType>({});
const [fkLoading, setFkLoading] = useState<Record<string, boolean>>({});
const fkLoadedSourcesRef = useRef<Set<string>>(new Set());
// Generate a storage key based on column names for localStorage persistence
const storageKey = useMemo(() => {
if (detectedColumns.length === 0) return null;
@ -380,6 +336,40 @@ export function FormGeneratorTable<T extends Record<string, any>>({
// Refs for resizing
const tableRef = useRef<HTMLTableElement>(null);
const tableContainerRef = useRef<HTMLDivElement>(null);
// Track container width for actions column 20% threshold
const [containerWidth, setContainerWidth] = useState<number>(0);
// Calculate default actions column width and track container width
const defaultActionsWidth = useMemo(() => {
return actionButtons.length > 0 ? Math.max(60, actionButtons.length * 32 + 16) : 0;
}, [actionButtons.length]);
// Current actions column width (user-defined or default)
const currentActionsWidth = actionsColumnWidth ?? defaultActionsWidth;
// Check if actions column exceeds 20% of container width (enable wrapping)
const shouldWrapActionButtons = containerWidth > 0 && currentActionsWidth > containerWidth * 0.20;
// Track container width changes
useEffect(() => {
const container = tableContainerRef.current;
if (!container) return;
const updateContainerWidth = () => {
setContainerWidth(container.clientWidth);
};
// Initial measurement
updateContainerWidth();
// Observe resize
const resizeObserver = new ResizeObserver(updateContainerWidth);
resizeObserver.observe(container);
return () => resizeObserver.disconnect();
}, []);
const resizingColumn = useRef<string | null>(null);
const startX = useRef<number>(0);
const startWidth = useRef<number>(0);
@ -445,6 +435,161 @@ export function FormGeneratorTable<T extends Record<string, any>>({
}
}).current;
// Helper function to convert any field value to display string
// Handles: string, boolean, number, TextMultilingual, objects
const convertToDisplayString = useCallback((fieldValue: any, language: string): string => {
if (fieldValue === null || fieldValue === undefined) {
return '-';
}
// Boolean → language-neutral symbols (✓/✗)
if (typeof fieldValue === 'boolean') {
return fieldValue ? '✓' : '✗';
}
// Number → String
if (typeof fieldValue === 'number') {
return String(fieldValue);
}
// String → direct
if (typeof fieldValue === 'string') {
return fieldValue;
}
// Object - check for TextMultilingual (has 'en' key)
if (typeof fieldValue === 'object' && fieldValue !== null) {
// TextMultilingual: { en: "...", ge: "...", fr: "...", it: "..." }
if ('en' in fieldValue) {
// Map frontend language codes to backend codes
const langMap: Record<string, string> = { 'de': 'ge', 'en': 'en', 'fr': 'fr', 'it': 'it' };
const backendLang = langMap[language] || language;
// Try current language first, then fallback
if (fieldValue[backendLang] && typeof fieldValue[backendLang] === 'string' && fieldValue[backendLang].trim()) {
return fieldValue[backendLang];
}
if (fieldValue.en && typeof fieldValue.en === 'string' && fieldValue.en.trim()) {
return fieldValue.en;
}
// Try other languages
for (const lang of ['ge', 'fr', 'it']) {
if (fieldValue[lang] && typeof fieldValue[lang] === 'string' && fieldValue[lang].trim()) {
return fieldValue[lang];
}
}
}
// Other objects → try to stringify
try {
return JSON.stringify(fieldValue);
} catch {
return String(fieldValue);
}
}
// Fallback
return String(fieldValue);
}, []);
// FK Resolution: Load FK data in bulk for columns with fkSource
useEffect(() => {
if (data.length === 0 || detectedColumns.length === 0) return;
// Find columns with fkSource that haven't been loaded yet
const fkColumns = detectedColumns.filter(col =>
col.fkSource && !fkLoadedSourcesRef.current.has(col.fkSource)
);
if (fkColumns.length === 0) return;
// For each FK column, collect unique IDs from data and fetch them
const loadFkData = async () => {
for (const column of fkColumns) {
const fkSource = column.fkSource!;
const displayField = column.fkDisplayField; // Explicit field from Pydantic model
// Skip if already loading
if (fkLoading[fkSource]) continue;
// Collect unique IDs from data for this column
const uniqueIds = new Set<string>();
data.forEach(row => {
const value = row[column.key];
if (value && typeof value === 'string' && value.length > 0) {
uniqueIds.add(value);
}
});
if (uniqueIds.size === 0) {
fkLoadedSourcesRef.current.add(fkSource);
continue;
}
// Mark as loading
setFkLoading(prev => ({ ...prev, [fkSource]: true }));
try {
// Fetch all items from the FK source endpoint
const response = await api.get(fkSource);
// Build cache: id -> display label
const cacheForSource: Record<string, string> = {};
const items = Array.isArray(response.data) ? response.data : response.data?.items || [];
items.forEach((item: any) => {
if (!item || !item.id) return;
let displayLabel = item.id; // Fallback to ID
// Use the EXPLICIT display field from Pydantic model (fkDisplayField)
if (displayField && item[displayField] !== undefined) {
displayLabel = convertToDisplayString(item[displayField], currentLanguage);
} else {
// Fallback: if no displayField specified, try common fields
// This should rarely happen if models are properly configured
const fallbackFields = ['name', 'label', 'username', 'roleLabel', 'title'];
for (const field of fallbackFields) {
if (item[field] !== undefined) {
displayLabel = convertToDisplayString(item[field], currentLanguage);
break;
}
}
}
cacheForSource[item.id] = displayLabel;
});
// Update cache
setFkCache(prev => ({
...prev,
[fkSource]: { ...(prev[fkSource] || {}), ...cacheForSource }
}));
// Mark as loaded
fkLoadedSourcesRef.current.add(fkSource);
} catch (error) {
console.error(`Failed to load FK data from ${fkSource}:`, error);
// Mark as loaded to prevent infinite retries
fkLoadedSourcesRef.current.add(fkSource);
} finally {
setFkLoading(prev => ({ ...prev, [fkSource]: false }));
}
}
};
loadFkData();
}, [data, detectedColumns, currentLanguage, fkLoading, convertToDisplayString]);
// Helper function to resolve FK value to display label
const resolveFkValue = useCallback((value: string, fkSource: string): string => {
const sourceCache = fkCache[fkSource];
if (sourceCache && sourceCache[value]) {
return sourceCache[value];
}
// Return truncated ID while loading or if not found
return value.length > 8 ? `${value.substring(0, 8)}...` : value;
}, [fkCache]);
// Data is already filtered, sorted, and paginated by the backend
// No client-side processing needed
@ -677,6 +822,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
// Handle column resizing - use refs to store stable handler references
const handleMouseMoveRef = useRef<((e: MouseEvent) => void) | null>(null);
const handleMouseUpRef = useRef<(() => void) | null>(null);
// Track if we're resizing the actions column
const resizingActionsColumn = useRef<boolean>(false);
const handleMouseDown = (e: React.MouseEvent, columnKey: string) => {
if (!resizable) return;
@ -698,13 +845,14 @@ export function FormGeneratorTable<T extends Record<string, any>>({
// Prevent extending beyond table container
const tableContainer = tableRef.current?.parentElement;
if (tableContainer) {
const containerWidth = tableContainer.clientWidth;
const actionsColumnWidth = actionButtons.length > 0 ? 120 : 0;
const cWidth = tableContainer.clientWidth;
// Calculate actions column width dynamically: ~32px per button + padding
const actionsColWidth = currentActionsWidth;
const selectColumnWidth = selectable ? 50 : 0;
const fixedWidth = actionsColumnWidth + selectColumnWidth;
const fixedWidth = actionsColWidth + selectColumnWidth;
// Maximum allowed width - simple calculation to prevent overflow
const maxAllowedWidth = containerWidth - fixedWidth - 100; // Leave space for other columns
const maxAllowedWidth = cWidth - fixedWidth - 100; // Leave space for other columns
newWidth = Math.min(newWidth, Math.max(100, maxAllowedWidth));
}
@ -742,6 +890,48 @@ export function FormGeneratorTable<T extends Record<string, any>>({
document.addEventListener('mousemove', mouseMoveHandler);
document.addEventListener('mouseup', mouseUpHandler);
};
// Handle actions column resizing (separate handler)
const handleActionsColumnMouseDown = (e: React.MouseEvent) => {
if (!resizable) return;
e.preventDefault();
e.stopPropagation();
resizingActionsColumn.current = true;
startX.current = e.clientX;
startWidth.current = currentActionsWidth;
const mouseMoveHandler = (moveEvent: MouseEvent) => {
if (!resizingActionsColumn.current) return;
const diff = moveEvent.clientX - startX.current;
// Minimum width: default width based on buttons, maximum: 40% of container
const minWidth = defaultActionsWidth;
const maxWidth = containerWidth > 0 ? containerWidth * 0.4 : 400;
const newWidth = Math.max(minWidth, Math.min(maxWidth, startWidth.current + diff));
setActionsColumnWidth(newWidth);
};
const mouseUpHandler = () => {
resizingActionsColumn.current = false;
if (handleMouseMoveRef.current) {
document.removeEventListener('mousemove', handleMouseMoveRef.current);
}
if (handleMouseUpRef.current) {
document.removeEventListener('mouseup', handleMouseUpRef.current);
}
handleMouseMoveRef.current = null;
handleMouseUpRef.current = null;
};
handleMouseMoveRef.current = mouseMoveHandler;
handleMouseUpRef.current = mouseUpHandler;
document.addEventListener('mousemove', mouseMoveHandler);
document.addEventListener('mouseup', mouseUpHandler);
};
// Cleanup on unmount
useEffect(() => {
@ -916,6 +1106,19 @@ export function FormGeneratorTable<T extends Record<string, any>>({
return renderBooleanCell(value, column, row);
}
// FK Resolution: If column has fkSource and value is a string (UUID), resolve to display label
if (column.fkSource && typeof value === 'string' && value.length > 0) {
const resolvedLabel = resolveFkValue(value, column.fkSource);
const isLoading = fkLoading[column.fkSource];
// Show loading indicator or resolved label
if (isLoading && !fkCache[column.fkSource]?.[value]) {
return <span className={styles.fkLoading}>{value.substring(0, 8)}...</span>;
}
return resolvedLabel;
}
// Check if this is an ID or hash field that should be truncated and copyable
// Do this BEFORE checking for custom formatters to ensure IDs/hashes are always copyable
const isId = isIdField(column.key);
@ -1002,13 +1205,14 @@ export function FormGeneratorTable<T extends Record<string, any>>({
return column.formatter(value, row);
}
// Check if this is a timestamp field even if column type isn't 'date'
const isTimestampField = /(at|date|time|timestamp|created|updated|expires|checked)$/i.test(column.key);
const isLikelyTimestamp = typeof value === 'number' && value > 0 && value < 4102444800000;
// Check if this is a timestamp field based on name OR explicit type
// Do NOT treat arbitrary numbers as timestamps - only if field name suggests it
const isTimestampField = /(at|date|time|timestamp|created|updated|expires|checked|valuta)$/i.test(column.key);
const isExplicitDateType = column.type && isDateTimeType(column.type);
// If it's a timestamp field or looks like a timestamp, format as date
// Also check if column type is a date/time type
if ((isTimestampField || isLikelyTimestamp || (column.type && isDateTimeType(column.type))) && typeof value === 'number') {
// Only format as date if: field name suggests timestamp OR explicit date type
// Do NOT format based on value range alone - this causes amounts/percentages to show as dates
if ((isTimestampField || isExplicitDateType) && typeof value === 'number') {
try {
// Handle Unix timestamps in seconds (backend format)
let timestamp: number;
@ -1164,7 +1368,10 @@ export function FormGeneratorTable<T extends Record<string, any>>({
)}
{/* Table */}
<div className={`${styles.tableContainer} ${displayData.length === 0 && !loading ? styles.emptyTable : ''}`}>
<div
ref={tableContainerRef}
className={`${styles.tableContainer} ${displayData.length === 0 && !loading ? styles.emptyTable : ''}`}
>
{/* Loading overlay - shown while loading */}
{loading && (
<div className={styles.loadingOverlay}>
@ -1201,9 +1408,18 @@ export function FormGeneratorTable<T extends Record<string, any>>({
{actionButtons.length > 0 && (
<th
className={styles.actionsColumn}
style={{ width: '120px', minWidth: '120px', maxWidth: '120px' }}
style={{
width: `${currentActionsWidth}px`,
minWidth: `${defaultActionsWidth}px`,
position: 'relative'
}}
>
{resizable && (
<div
className={styles.resizeHandle}
onMouseDown={handleActionsColumnMouseDown}
/>
)}
</th>
)}
{detectedColumns.map(column => (
@ -1354,7 +1570,10 @@ export function FormGeneratorTable<T extends Record<string, any>>({
{actionButtons.length > 0 && (
<td
className={styles.actionsColumn}
style={{ width: '120px', minWidth: '120px', maxWidth: '120px' }}
style={{
width: `${currentActionsWidth}px`,
minWidth: `${defaultActionsWidth}px`
}}
>
<div
ref={(el) => {
@ -1364,14 +1583,29 @@ export function FormGeneratorTable<T extends Record<string, any>>({
actionButtonsRefs.current.delete(index);
}
}}
className={styles.actionButtons}
className={`${styles.actionButtons} ${shouldWrapActionButtons ? styles.actionButtonsWrap : ''}`}
>
{/* Standard action buttons (edit, delete, view, copy) */}
{actionButtons.map((actionButton, actionIndex) => {
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;

View file

@ -0,0 +1,372 @@
/**
* MandateNavigation Styles
*
* Hierarchische Navigation:
* System Mandant Feature Instanz Views
*/
.navigation {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0 0.5rem;
}
/* Separator */
.separator {
height: 1px;
background: var(--border-color, #e0e0e0);
margin: 0.75rem 0.5rem;
}
/* Section (System, Admin) */
.section {
margin-bottom: 0.5rem;
}
.sectionHeader {
padding: 0.5rem 0.75rem;
}
.sectionTitle {
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.1em;
color: var(--text-tertiary, #888);
text-transform: uppercase;
}
.sectionContent {
display: flex;
flex-direction: column;
gap: 2px;
}
/* Nav Item (Links) */
.navItem {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
border-radius: 6px;
color: var(--text-secondary, #666);
text-decoration: none;
font-size: 0.875rem;
transition: all 0.15s ease;
}
.navItem:hover {
background: var(--hover-bg, rgba(0, 0, 0, 0.04));
color: var(--text-primary, #1a1a1a);
}
.navItem.active {
background: var(--primary-light, #e0e7ff);
color: var(--primary-color, #2563eb);
font-weight: 500;
}
.navIcon {
font-size: 1rem;
flex-shrink: 0;
}
/* Mandate Group */
.mandateGroup {
margin-bottom: 0.25rem;
}
.mandateHeader {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 0.75rem;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
transition: background 0.15s ease;
}
.mandateHeader:hover {
background: var(--hover-bg, rgba(0, 0, 0, 0.04));
}
.mandateLabel {
flex: 1;
text-align: left;
}
.mandateContent {
margin-left: 0.25rem;
padding-left: 0.75rem;
border-left: 2px solid var(--border-color, #e0e0e0);
}
.activeMandate > .mandateContent {
border-left-color: var(--primary-color, #2563eb);
}
/* Feature Group */
.featureGroup {
margin-bottom: 0.25rem;
}
.featureHeader {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
font-size: 0.8125rem;
color: var(--text-secondary, #666);
transition: background 0.15s ease;
}
.featureHeader:hover {
background: var(--hover-bg, rgba(0, 0, 0, 0.04));
}
.featureIcon {
display: flex;
align-items: center;
font-size: 0.875rem;
}
.featureLabel {
flex: 1;
text-align: left;
font-weight: 500;
}
.instanceCount {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
background: var(--surface-color, #f0f0f0);
border-radius: 9999px;
color: var(--text-tertiary, #888);
}
.featureContent {
margin-left: 0.25rem;
padding-left: 0.75rem;
}
.activeFeature > .featureHeader {
color: var(--primary-color, #2563eb);
}
/* Instance Group */
.instanceGroup {
margin-bottom: 0.125rem;
}
.instanceHeader {
display: flex;
align-items: center;
gap: 0.375rem;
width: 100%;
padding: 0.375rem 0.5rem;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
font-size: 0.75rem;
color: var(--text-secondary, #666);
transition: background 0.15s ease;
}
.instanceHeader:hover {
background: var(--hover-bg, rgba(0, 0, 0, 0.04));
}
.instanceLabel {
flex: 1;
text-align: left;
font-weight: 500;
}
.roleBadge {
font-size: 0.625rem;
padding: 0.0625rem 0.375rem;
background: var(--surface-color, #f0f0f0);
border-radius: 9999px;
color: var(--text-tertiary, #888);
text-transform: uppercase;
letter-spacing: 0.025em;
}
.instanceViews {
margin-left: 0.25rem;
padding-left: 1rem;
}
.activeInstance > .instanceHeader {
color: var(--primary-color, #2563eb);
background: var(--primary-light, #e0e7ff);
}
.activeInstance .roleBadge {
background: var(--primary-color, #2563eb);
color: white;
}
/* View Item */
.viewItem {
display: block;
padding: 0.375rem 0.5rem;
border-radius: 4px;
color: var(--text-secondary, #666);
text-decoration: none;
font-size: 0.75rem;
transition: all 0.15s ease;
}
.viewItem:hover {
background: var(--hover-bg, rgba(0, 0, 0, 0.04));
color: var(--text-primary, #1a1a1a);
}
.viewItem.active {
background: var(--primary-light, #e0e7ff);
color: var(--primary-color, #2563eb);
font-weight: 500;
}
/* Chevron */
.chevron {
font-size: 0.625rem;
color: var(--text-tertiary, #888);
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;
text-align: center;
color: var(--text-secondary, #666);
font-size: 0.875rem;
}
.emptyHint {
font-size: 0.75rem;
color: var(--text-tertiary, #888);
margin-top: 0.5rem;
}
/* Dark Theme */
:global(.dark-theme) .separator {
background: var(--border-dark, #333);
}
:global(.dark-theme) .sectionTitle {
color: var(--text-tertiary-dark, #666);
}
:global(.dark-theme) .navItem {
color: var(--text-secondary-dark, #aaa);
}
:global(.dark-theme) .navItem:hover {
background: var(--hover-bg-dark, rgba(255, 255, 255, 0.06));
color: var(--text-primary-dark, #fff);
}
:global(.dark-theme) .navItem.active {
background: var(--primary-dark-bg, #1e3a5f);
color: var(--primary-light, #93c5fd);
}
:global(.dark-theme) .mandateHeader {
color: var(--text-primary-dark, #fff);
}
:global(.dark-theme) .mandateHeader:hover {
background: var(--hover-bg-dark, rgba(255, 255, 255, 0.06));
}
:global(.dark-theme) .mandateContent {
border-left-color: var(--border-dark, #444);
}
:global(.dark-theme) .activeMandate > .mandateContent {
border-left-color: var(--primary-light, #93c5fd);
}
:global(.dark-theme) .featureHeader {
color: var(--text-secondary-dark, #aaa);
}
:global(.dark-theme) .featureHeader:hover {
background: var(--hover-bg-dark, rgba(255, 255, 255, 0.06));
}
:global(.dark-theme) .activeFeature > .featureHeader {
color: var(--primary-light, #93c5fd);
}
:global(.dark-theme) .instanceCount,
:global(.dark-theme) .roleBadge {
background: var(--surface-dark, #2a2a2a);
color: var(--text-tertiary-dark, #888);
}
:global(.dark-theme) .instanceHeader {
color: var(--text-secondary-dark, #aaa);
}
:global(.dark-theme) .instanceHeader:hover {
background: var(--hover-bg-dark, rgba(255, 255, 255, 0.06));
}
:global(.dark-theme) .activeInstance > .instanceHeader {
color: var(--primary-light, #93c5fd);
background: var(--primary-dark-bg, #1e3a5f);
}
:global(.dark-theme) .activeInstance .roleBadge {
background: var(--primary-color, #2563eb);
color: white;
}
:global(.dark-theme) .viewItem {
color: var(--text-secondary-dark, #aaa);
}
:global(.dark-theme) .viewItem:hover {
background: var(--hover-bg-dark, rgba(255, 255, 255, 0.06));
color: var(--text-primary-dark, #fff);
}
:global(.dark-theme) .viewItem.active {
background: var(--primary-dark-bg, #1e3a5f);
color: var(--primary-light, #93c5fd);
}

View file

@ -0,0 +1,236 @@
/**
* MandateNavigation
*
* Hierarchische Navigation für das Multi-Tenant-System.
* Verwendet TreeNavigation für flexible Baumstruktur.
*
* 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 { 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';
// =============================================================================
// HELPER FUNCTIONS - Convert API blocks to TreeItems
// =============================================================================
/**
* 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,
};
}
/**
* 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 featureInstanceToTreeNode(instance: FeatureInstance): TreeNodeItem {
return {
id: instance.id,
label: instance.uiLabel,
children: instance.views.map(featureViewToTreeNode),
defaultExpanded: false,
};
}
/**
* Convert a MandateFeature to TreeNodeItem
*/
function mandateFeatureToTreeNode(feature: MandateFeature): TreeNodeItem | null {
if (feature.instances.length === 0) {
return null;
}
return {
id: feature.uiComponent,
label: feature.uiLabel,
icon: getPageIcon(feature.uiComponent),
badge: feature.instances.length,
children: feature.instances.map(featureInstanceToTreeNode),
defaultExpanded: false,
};
}
/**
* Convert a NavigationMandate to TreeNodeItem
*/
function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem | null {
if (mandate.features.length === 0) {
return null;
}
const children = mandate.features
.map(mandateFeatureToTreeNode)
.filter((node): node is TreeNodeItem => node !== null);
if (children.length === 0) {
return null;
}
return {
id: mandate.id,
label: mandate.uiLabel,
children,
defaultExpanded: true,
};
}
/**
* Convert a DynamicBlock to array of TreeNodeItems (mandate nodes)
*/
function dynamicBlockToTreeNodes(block: DynamicBlock): TreeNodeItem[] {
return block.mandates
.map(navigationMandateToTreeNode)
.filter((node): node is TreeNodeItem => node !== null);
}
// =============================================================================
// LOADING STATE
// =============================================================================
const LoadingState: React.FC = () => (
<div className={styles.loadingState}>
<FaSpinner className={styles.spinner} />
<span>Navigation wird geladen...</span>
</div>
);
// =============================================================================
// EMPTY STATE
// =============================================================================
const EmptyState: React.FC = () => (
<div className={styles.emptyState}>
<p>Keine Feature-Instanzen verfügbar.</p>
<p className={styles.emptyHint}>
Kontaktiere einen Administrator, um Zugriff zu erhalten.
</p>
</div>
);
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export const MandateNavigation: React.FC = () => {
// Fetch navigation from new API (blocks structure, already filtered by permissions)
const { blocks, loading } = useNavigation('de');
// Build navigation items from blocks
const navigationItems: TreeItem[] = useMemo(() => {
const items: TreeItem[] = [];
// 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' });
}
}
// Remove trailing separator if present
while (items.length > 0 && (items[items.length - 1] as TreeItem & { type?: string }).type === 'separator') {
items.pop();
}
return items;
}, [blocks]);
// Check if user has any navigation (static or dynamic)
const hasNavigation = blocks.length > 0;
// Show loading state while navigation is being fetched
if (loading) {
return (
<div className={styles.navigation}>
<LoadingState />
</div>
);
}
return (
<div className={styles.navigation}>
{hasNavigation ? (
<TreeNavigation
items={navigationItems}
autoExpandActive={true}
/>
) : (
<EmptyState />
)}
</div>
);
};
export default MandateNavigation;

View file

@ -0,0 +1,303 @@
/**
* TreeNavigation Styles
*
* Flexible hierarchical navigation with support for:
* - Dynamic sublevels
* - Sections and separators
* - Various visual states (active, disabled, hover)
*/
.treeNavigation {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0 0.5rem;
}
/* ============================================ */
/* SEPARATOR */
/* ============================================ */
.separator {
height: 1px;
background: var(--border-color, #e0e0e0);
margin: 0.75rem 0.5rem;
}
/* ============================================ */
/* SECTION */
/* ============================================ */
.treeSection {
margin-bottom: 0.5rem;
}
.sectionHeader {
padding: 0.5rem 0.75rem;
}
.sectionTitle {
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.1em;
color: var(--text-tertiary, #888);
text-transform: uppercase;
}
.sectionContent {
display: flex;
flex-direction: column;
gap: 2px;
}
/* ============================================ */
/* TREE NODE */
/* ============================================ */
.treeNodeContainer {
display: flex;
flex-direction: column;
}
.treeNode {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
text-decoration: none;
font-family: inherit;
text-align: left;
color: var(--text-secondary, #666);
font-size: 0.875rem;
transition: all 0.15s ease;
}
.treeNode:hover {
background: var(--hover-bg, rgba(0, 0, 0, 0.04));
color: var(--text-primary, #1a1a1a);
}
.treeNode.active {
background: var(--primary-light, #e0e7ff);
color: var(--primary-color, #2563eb);
font-weight: 500;
}
.treeNode.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
/* ============================================ */
/* LEVEL-SPECIFIC STYLES */
/* ============================================ */
/* Root level (level 0) */
.levelRoot {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
padding: 0.625rem 0.75rem;
}
.levelRoot .nodeLabel {
flex: 1;
}
/* Level 1 */
.levelOne {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary, #666);
padding: 0.5rem 0.75rem;
}
/* Level 2 */
.levelTwo {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary, #666);
padding: 0.375rem 0.5rem;
}
/* Level 3 */
.levelThree {
font-size: 0.75rem;
color: var(--text-secondary, #666);
padding: 0.375rem 0.5rem;
}
/* Deep levels (4+) */
.levelDeep {
font-size: 0.6875rem;
color: var(--text-tertiary, #888);
padding: 0.25rem 0.5rem;
}
/* ============================================ */
/* NODE CHILDREN (INDENTATION) */
/* ============================================ */
.treeNodeChildren {
margin-left: 0.25rem;
padding-left: 0.75rem;
border-left: 2px solid var(--border-color, #e0e0e0);
}
/* Active parent highlights the border */
.treeNodeContainer:has(> .treeNode.active) > .treeNodeChildren {
border-left-color: var(--primary-color, #2563eb);
}
/* Also highlight if any descendant is active */
.treeNodeContainer:has(.treeNode.active) > .treeNodeChildren {
border-left-color: var(--primary-light, #93c5fd);
}
/* ============================================ */
/* NODE ELEMENTS */
/* ============================================ */
.chevron {
display: flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
font-size: 0.625rem;
color: var(--text-tertiary, #888);
flex-shrink: 0;
cursor: pointer;
border-radius: 3px;
transition: background 0.1s ease;
}
.chevron:hover {
background: var(--hover-bg, rgba(0, 0, 0, 0.06));
}
.chevronSpacer {
width: 1rem;
flex-shrink: 0;
}
.nodeIcon {
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
flex-shrink: 0;
color: inherit;
}
.nodeLabel {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nodeBadge {
font-size: 0.625rem;
padding: 0.0625rem 0.375rem;
background: var(--surface-color, #f0f0f0);
border-radius: 9999px;
color: var(--text-tertiary, #888);
text-transform: uppercase;
letter-spacing: 0.025em;
flex-shrink: 0;
}
/* Badge variants */
.badgePrimary {
background: var(--primary-color, #2563eb);
color: white;
}
.badgeSuccess {
background: var(--success-color, #22c55e);
color: white;
}
.badgeWarning {
background: var(--warning-color, #f59e0b);
color: white;
}
/* Active node badge */
.treeNode.active .nodeBadge {
background: var(--primary-color, #2563eb);
color: white;
}
/* ============================================ */
/* DARK THEME */
/* ============================================ */
:global(.dark-theme) .separator {
background: var(--border-dark, #333);
}
:global(.dark-theme) .sectionTitle {
color: var(--text-tertiary-dark, #666);
}
:global(.dark-theme) .treeNode {
color: var(--text-secondary-dark, #aaa);
}
:global(.dark-theme) .treeNode:hover {
background: var(--hover-bg-dark, rgba(255, 255, 255, 0.06));
color: var(--text-primary-dark, #fff);
}
:global(.dark-theme) .treeNode.active {
background: var(--primary-dark-bg, #1e3a5f);
color: var(--primary-light, #93c5fd);
}
:global(.dark-theme) .levelRoot {
color: var(--text-primary-dark, #fff);
}
:global(.dark-theme) .levelOne,
:global(.dark-theme) .levelTwo,
:global(.dark-theme) .levelThree {
color: var(--text-secondary-dark, #aaa);
}
:global(.dark-theme) .levelDeep {
color: var(--text-tertiary-dark, #888);
}
:global(.dark-theme) .treeNodeChildren {
border-left-color: var(--border-dark, #444);
}
:global(.dark-theme) .treeNodeContainer:has(.treeNode.active) > .treeNodeChildren {
border-left-color: var(--primary-light, #93c5fd);
}
:global(.dark-theme) .nodeBadge {
background: var(--surface-dark, #2a2a2a);
color: var(--text-tertiary-dark, #888);
}
:global(.dark-theme) .chevron {
color: var(--text-tertiary-dark, #666);
}
:global(.dark-theme) .chevron:hover {
background: var(--hover-bg-dark, rgba(255, 255, 255, 0.1));
}
:global(.dark-theme) .treeNode.active .nodeBadge {
background: var(--primary-color, #2563eb);
color: white;
}

View file

@ -0,0 +1,378 @@
/**
* TreeNavigation
*
* A flexible, recursive tree navigation component that supports:
* - Dynamic sublevels of any depth
* - Expandable/collapsible nodes
* - Auto-expand based on active path
* - Customizable icons and badges
* - Section headers
* - NavLink integration with React Router
*/
import React, { useState, useEffect, ReactNode } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { FaChevronDown, FaChevronRight } from 'react-icons/fa';
import styles from './TreeNavigation.module.css';
// =============================================================================
// TYPES
// =============================================================================
export interface TreeNodeItem {
/** Unique identifier for this node */
id: string;
/** Display label */
label: string;
/** Icon to display (React component or element) */
icon?: ReactNode;
/** Badge content (e.g., count, role) */
badge?: string | number;
/** Optional badge style variant */
badgeVariant?: 'default' | 'primary' | 'success' | 'warning';
/** Path for navigation (if this is a link) */
path?: string;
/** Child nodes */
children?: TreeNodeItem[];
/** Whether this node is expanded by default */
defaultExpanded?: boolean;
/** Whether this node can be expanded/collapsed (default: true if has children) */
expandable?: boolean;
/** Custom onClick handler (overrides navigation) */
onClick?: () => void;
/** Whether this node is disabled */
disabled?: boolean;
/** Additional CSS class */
className?: string;
/** Indent level (auto-calculated) */
level?: number;
/** Data attribute for testing/identification */
dataId?: string;
}
export interface TreeSectionItem {
/** Section type */
type: 'section';
/** Section title */
title: string;
/** Child nodes in this section */
children: TreeNodeItem[];
/** Whether this section is initially visible */
visible?: boolean;
}
export interface TreeSeparatorItem {
/** Separator type */
type: 'separator';
}
export type TreeItem = TreeNodeItem | TreeSectionItem | TreeSeparatorItem;
export interface TreeNavigationProps {
/** Array of tree items to render */
items: TreeItem[];
/** Whether to auto-expand nodes when their path is active */
autoExpandActive?: boolean;
/** Callback when a node is clicked */
onNodeClick?: (node: TreeNodeItem) => void;
/** Maximum depth to render (0 = unlimited) */
maxDepth?: number;
/** Additional CSS class for the container */
className?: string;
}
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
/**
* Check if a node or any of its descendants has the active path
*/
function hasActivePath(node: TreeNodeItem, currentPath: string): boolean {
if (node.path && currentPath.startsWith(node.path)) {
return true;
}
if (node.children) {
return node.children.some(child => hasActivePath(child, currentPath));
}
return false;
}
/**
* Type guard to check if item is a TreeNodeItem
*/
function isTreeNode(item: TreeItem): item is TreeNodeItem {
return !('type' in item);
}
/**
* Type guard to check if item is a TreeSectionItem
*/
function isTreeSection(item: TreeItem): item is TreeSectionItem {
return 'type' in item && item.type === 'section';
}
/**
* Type guard to check if item is a TreeSeparatorItem
*/
function isTreeSeparator(item: TreeItem): item is TreeSeparatorItem {
return 'type' in item && item.type === 'separator';
}
// =============================================================================
// TREE NODE COMPONENT
// =============================================================================
interface TreeNodeProps {
node: TreeNodeItem;
level: number;
autoExpandActive: boolean;
currentPath: string;
onNodeClick?: (node: TreeNodeItem) => void;
maxDepth: number;
}
const TreeNode: React.FC<TreeNodeProps> = ({
node,
level,
autoExpandActive,
currentPath,
onNodeClick,
maxDepth,
}) => {
const hasChildren = node.children && node.children.length > 0;
const isExpandable = node.expandable !== false && hasChildren;
const shouldAutoExpand = autoExpandActive && hasActivePath(node, currentPath);
const [isExpanded, setIsExpanded] = useState(
node.defaultExpanded ?? shouldAutoExpand ?? false
);
// Auto-expand when path becomes active
useEffect(() => {
if (autoExpandActive && hasActivePath(node, currentPath) && !isExpanded) {
setIsExpanded(true);
}
}, [currentPath, autoExpandActive, node]);
// Check if this exact node is active
const isActive = node.path ? currentPath === node.path || currentPath.startsWith(node.path + '/') : false;
// Handle click
const handleClick = (e: React.MouseEvent) => {
if (node.disabled) {
e.preventDefault();
return;
}
if (node.onClick) {
e.preventDefault();
node.onClick();
return;
}
if (isExpandable && !node.path) {
// If only expandable (no path), toggle expand
setIsExpanded(!isExpanded);
} else if (isExpandable && node.path) {
// If both expandable and has path, expand on click but allow navigation
if (!isExpanded) {
setIsExpanded(true);
}
}
if (onNodeClick) {
onNodeClick(node);
}
};
// Handle chevron click separately
const handleChevronClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsExpanded(!isExpanded);
};
// Get level-specific styles
const getLevelClass = () => {
switch (level) {
case 0: return styles.levelRoot;
case 1: return styles.levelOne;
case 2: return styles.levelTwo;
case 3: return styles.levelThree;
default: return styles.levelDeep;
}
};
// Render the node content
const nodeContent = (
<>
{isExpandable && (
<span className={styles.chevron} onClick={handleChevronClick}>
{isExpanded ? <FaChevronDown /> : <FaChevronRight />}
</span>
)}
{!isExpandable && hasChildren === false && (
<span className={styles.chevronSpacer} />
)}
{node.icon && <span className={styles.nodeIcon}>{node.icon}</span>}
<span className={styles.nodeLabel} title={node.label}>{node.label}</span>
{node.badge !== undefined && (
<span
className={`${styles.nodeBadge} ${node.badgeVariant ? styles[`badge${node.badgeVariant.charAt(0).toUpperCase() + node.badgeVariant.slice(1)}`] : ''}`}
>
{node.badge}
</span>
)}
</>
);
// Determine if we should render as NavLink or button
const nodeClasses = `${styles.treeNode} ${getLevelClass()} ${isActive ? styles.active : ''} ${node.disabled ? styles.disabled : ''} ${node.className || ''}`;
const nodeElement = node.path ? (
<NavLink
to={node.path}
className={nodeClasses}
onClick={handleClick}
data-id={node.dataId}
>
{nodeContent}
</NavLink>
) : (
<button
type="button"
className={nodeClasses}
onClick={handleClick}
disabled={node.disabled}
data-id={node.dataId}
>
{nodeContent}
</button>
);
// Check max depth
const canRenderChildren = maxDepth === 0 || level < maxDepth;
return (
<div className={styles.treeNodeContainer}>
{nodeElement}
{isExpanded && hasChildren && canRenderChildren && (
<div className={styles.treeNodeChildren}>
{node.children!.map((child, index) => (
<TreeNode
key={child.id || `${node.id}-child-${index}`}
node={child}
level={level + 1}
autoExpandActive={autoExpandActive}
currentPath={currentPath}
onNodeClick={onNodeClick}
maxDepth={maxDepth}
/>
))}
</div>
)}
</div>
);
};
// =============================================================================
// TREE SECTION COMPONENT
// =============================================================================
interface TreeSectionProps {
section: TreeSectionItem;
autoExpandActive: boolean;
currentPath: string;
onNodeClick?: (node: TreeNodeItem) => void;
maxDepth: number;
}
const TreeSection: React.FC<TreeSectionProps> = ({
section,
autoExpandActive,
currentPath,
onNodeClick,
maxDepth,
}) => {
if (section.visible === false) {
return null;
}
return (
<div className={styles.treeSection}>
<div className={styles.sectionHeader}>
<span className={styles.sectionTitle}>{section.title}</span>
</div>
<div className={styles.sectionContent}>
{section.children.map((node, index) => (
<TreeNode
key={node.id || `section-${section.title}-${index}`}
node={node}
level={0}
autoExpandActive={autoExpandActive}
currentPath={currentPath}
onNodeClick={onNodeClick}
maxDepth={maxDepth}
/>
))}
</div>
</div>
);
};
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export const TreeNavigation: React.FC<TreeNavigationProps> = ({
items,
autoExpandActive = true,
onNodeClick,
maxDepth = 0,
className = '',
}) => {
const location = useLocation();
const currentPath = location.pathname;
return (
<nav className={`${styles.treeNavigation} ${className}`}>
{items.map((item, index) => {
if (isTreeSeparator(item)) {
return <div key={`separator-${index}`} className={styles.separator} />;
}
if (isTreeSection(item)) {
return (
<TreeSection
key={`section-${item.title}-${index}`}
section={item}
autoExpandActive={autoExpandActive}
currentPath={currentPath}
onNodeClick={onNodeClick}
maxDepth={maxDepth}
/>
);
}
if (isTreeNode(item)) {
return (
<TreeNode
key={item.id || `node-${index}`}
node={item}
level={0}
autoExpandActive={autoExpandActive}
currentPath={currentPath}
onNodeClick={onNodeClick}
maxDepth={maxDepth}
/>
);
}
return null;
})}
</nav>
);
};
export default TreeNavigation;

View file

@ -0,0 +1,8 @@
/**
* TreeNavigation Component Index
*
* Export all tree navigation related types and components
*/
export { TreeNavigation, type TreeNavigationProps, type TreeItem, type TreeNodeItem, type TreeSectionItem, type TreeSeparatorItem } from './TreeNavigation';
export { default } from './TreeNavigation';

View file

@ -0,0 +1,333 @@
/**
* UserSection Styles
*/
.userSection {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-top: 1px solid var(--border-color, #e0e0e0);
}
/* Notification Bell */
.notificationBell {
flex-shrink: 0;
}
.userButton {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem;
border: none;
border-radius: 8px;
background: transparent;
cursor: pointer;
transition: background 0.2s;
text-align: left;
}
.userButton:hover {
background: var(--hover-bg, rgba(0, 0, 0, 0.05));
}
.avatar {
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--primary-color, #2563eb);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
}
.userInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.userName {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary, #1a1a1a);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.userEmail {
font-size: 0.75rem;
color: var(--text-secondary, #666);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chevron {
flex-shrink: 0;
font-size: 0.625rem;
color: var(--text-tertiary, #888);
}
/* Menu */
.menu {
position: absolute;
bottom: 100%;
left: 0.5rem;
right: 0.5rem;
margin-bottom: 0.25rem;
padding: 0.25rem;
background: var(--bg-primary, #ffffff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 100;
}
.menuItem {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
border-radius: 6px;
background: transparent;
font-size: 0.875rem;
color: var(--text-primary, #1a1a1a);
cursor: pointer;
transition: background 0.2s;
text-align: left;
}
.menuItem:hover {
background: var(--hover-bg, rgba(0, 0, 0, 0.05));
}
.menuItem:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.menuIcon {
font-size: 1rem;
}
.menuDivider {
height: 1px;
margin: 0.25rem 0;
background: var(--border-color, #e0e0e0);
}
/* Dark Theme */
:global(.dark-theme) .userSection {
border-top-color: var(--border-dark, #333);
}
:global(.dark-theme) .userButton:hover {
background: var(--hover-bg-dark, rgba(255, 255, 255, 0.05));
}
:global(.dark-theme) .userName {
color: var(--text-primary-dark, #ffffff);
}
:global(.dark-theme) .userEmail {
color: var(--text-secondary-dark, #aaa);
}
:global(.dark-theme) .chevron {
color: var(--text-tertiary-dark, #888);
}
:global(.dark-theme) .menu {
background: var(--surface-dark, #1a1a1a);
border-color: var(--border-dark, #444);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
:global(.dark-theme) .menuItem {
color: var(--text-primary-dark, #ffffff);
}
:global(.dark-theme) .menuItem:hover {
background: var(--hover-bg-dark, rgba(255, 255, 255, 0.1));
}
:global(.dark-theme) .menuDivider {
background: var(--border-dark, #444);
}
/* Modal Overlay */
.modalOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: var(--bg-primary, #ffffff);
border-radius: 12px;
max-width: 700px;
width: 100%;
max-height: 80vh;
overflow: hidden;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
}
.modalHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.modalHeader h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
}
.modalClose {
background: transparent;
border: none;
font-size: 1.25rem;
cursor: pointer;
color: var(--text-tertiary, #888);
padding: 0.25rem;
line-height: 1;
}
.modalClose:hover {
color: var(--text-primary, #1a1a1a);
}
.modalContent {
padding: 1.5rem;
overflow-y: auto;
max-height: calc(80vh - 60px);
}
.legalSection {
margin-bottom: 1.5rem;
}
.legalSection h3 {
color: var(--text-primary, #1a1a1a);
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 1rem 0;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--primary-color, #F25843);
}
.legalSection h4 {
color: var(--text-primary, #1a1a1a);
font-size: 0.9375rem;
font-weight: 600;
margin: 1rem 0 0.5rem 0;
}
.legalSection p {
color: var(--text-secondary, #666);
font-size: 0.875rem;
line-height: 1.6;
margin: 0 0 0.75rem 0;
}
.legalSection ul {
margin: 0 0 0.75rem 1.5rem;
padding: 0;
}
.legalSection li {
color: var(--text-secondary, #666);
font-size: 0.875rem;
line-height: 1.6;
margin-bottom: 0.5rem;
}
.legalLinks {
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color, #e0e0e0);
}
.legalLinks a {
color: var(--primary-color, #F25843);
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 6px;
background: var(--primary-dark-bg, rgba(242, 88, 67, 0.1));
transition: background 0.2s;
}
.legalLinks a:hover {
background: var(--primary-light, rgba(242, 88, 67, 0.2));
}
/* Dark Theme Modal */
:global(.dark-theme) .modal {
background: var(--surface-dark, #1a1a1a);
}
:global(.dark-theme) .modalHeader {
border-bottom-color: var(--border-dark, #333);
}
:global(.dark-theme) .modalHeader h2 {
color: var(--text-primary-dark, #ffffff);
}
:global(.dark-theme) .modalClose {
color: var(--text-tertiary-dark, #888);
}
:global(.dark-theme) .modalClose:hover {
color: var(--text-primary-dark, #ffffff);
}
:global(.dark-theme) .legalSection h3 {
color: var(--text-primary-dark, #ffffff);
}
:global(.dark-theme) .legalSection h4 {
color: var(--text-primary-dark, #ffffff);
}
:global(.dark-theme) .legalSection p,
:global(.dark-theme) .legalSection li {
color: var(--text-secondary-dark, #aaa);
}
:global(.dark-theme) .legalLinks {
border-top-color: var(--border-dark, #333);
}
:global(.dark-theme) .legalLinks a {
color: var(--primary-light, #FF9A8A);
}

View file

@ -0,0 +1,159 @@
/**
* UserSection Component
*
* Zeigt Benutzerinformationen und Logout-Button in der Sidebar.
*/
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useCurrentUser } from '../../hooks/useUsers';
import { useMsal } from '@azure/msal-react';
import { NotificationBell } from '../NotificationBell';
import styles from './UserSection.module.css';
export const UserSection: React.FC = () => {
const { user, logout } = useCurrentUser();
const { instance: msalInstance } = useMsal();
const navigate = useNavigate();
const [isLoggingOut, setIsLoggingOut] = useState(false);
const [showMenu, setShowMenu] = useState(false);
const [showLegalModal, setShowLegalModal] = useState(false);
const handleLogout = async () => {
setIsLoggingOut(true);
try {
await logout(msalInstance);
} catch (error) {
console.error('Logout failed:', error);
setIsLoggingOut(false);
}
};
const handleSettings = () => {
navigate('/settings');
setShowMenu(false);
};
const handleLegal = () => {
setShowLegalModal(true);
setShowMenu(false);
};
if (!user) {
return null;
}
// Initialen für Avatar
const initials = user.fullName
? user.fullName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
: user.username.slice(0, 2).toUpperCase();
return (
<div className={styles.userSection}>
{/* Notification Bell */}
<NotificationBell className={styles.notificationBell} />
<button
className={styles.userButton}
onClick={() => setShowMenu(!showMenu)}
aria-expanded={showMenu}
>
<div className={styles.avatar}>
{initials}
</div>
<div className={styles.userInfo}>
<span className={styles.userName}>{user.fullName || user.username}</span>
<span className={styles.userEmail}>{user.email}</span>
</div>
<span className={styles.chevron}>
{showMenu ? '▲' : '▼'}
</span>
</button>
{showMenu && (
<div className={styles.menu}>
<button
className={styles.menuItem}
onClick={handleSettings}
>
<span className={styles.menuIcon}></span>
Einstellungen
</button>
<button
className={styles.menuItem}
onClick={handleLegal}
>
<span className={styles.menuIcon}>📜</span>
Rechtliche Hinweise
</button>
<div className={styles.menuDivider} />
<button
className={styles.menuItem}
onClick={handleLogout}
disabled={isLoggingOut}
>
<span className={styles.menuIcon}>🚪</span>
{isLoggingOut ? 'Abmelden...' : 'Abmelden'}
</button>
</div>
)}
{/* Legal Modal */}
{showLegalModal && (
<div className={styles.modalOverlay} onClick={() => setShowLegalModal(false)}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2>Rechtliche Hinweise</h2>
<button
className={styles.modalClose}
onClick={() => setShowLegalModal(false)}
>
</button>
</div>
<div className={styles.modalContent}>
<div className={styles.legalSection}>
<h3>Datenverarbeitung und KI-Nutzung</h3>
<h4>1. Einwilligung zur Datenverarbeitung</h4>
<p>Mit der Nutzung dieser Anwendung stimmen Sie zu und erklären sich mit den folgenden Bedingungen zur Verarbeitung Ihrer Daten durch künstliche Intelligenz einverstanden:</p>
<ul>
<li>Sie autorisieren die Erfassung, Verarbeitung, Übertragung und Speicherung aller Daten, die Sie bei der Nutzung unserer Dienste bereitstellen.</li>
<li>Nutzerdaten können an Drittanbieter von künstlicher Intelligenz übertragen werden (z.B. OpenAI).</li>
<li>Diese Einwilligung erstreckt sich auf alle Inhalte, einschließlich Text, Bilder, Dokumente und Gesprächsverläufe.</li>
</ul>
<h4>2. Anerkennung der KI-Verarbeitungsrisiken</h4>
<ul>
<li>KI-Systeme können unerwartete oder ungenaue Ausgaben erzeugen.</li>
<li>KI-Dienste können Daten gemäß ihren eigenen Nutzungsbedingungen speichern oder daraus lernen.</li>
<li>Trotz Sicherheitsmaßnahmen können Daten anfällig für unbefugten Zugriff sein.</li>
</ul>
<h4>3. Haftungsausschluss</h4>
<p>Im größtmöglichen Umfang verzichten Sie auf Ansprüche, die sich aus der KI-Verarbeitung ergeben, einschließlich Datenverletzungen und unbeabsichtigter Offenlegung.</p>
</div>
<div className={styles.legalLinks}>
<a href="/poweron-privacy.html" target="_blank" rel="noopener noreferrer">
Datenschutzrichtlinie
</a>
<a href="/poweron-terms.html" target="_blank" rel="noopener noreferrer">
Nutzungsbedingungen
</a>
<a href="/poweron-home.html" target="_blank" rel="noopener noreferrer">
Über PowerOn
</a>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default UserSection;

View file

@ -0,0 +1,5 @@
/**
* Navigation Components Export
*/
export { MandateNavigation } from './MandateNavigation';

View file

@ -0,0 +1,368 @@
/* NotificationBell Component Styles */
.notificationBell {
position: relative;
display: inline-flex;
align-items: center;
}
/* Bell Button */
.bellButton {
position: relative;
background: transparent;
border: none;
cursor: pointer;
padding: 8px;
border-radius: 8px;
color: var(--text-secondary, #6c757d);
transition: all 0.2s ease;
}
.bellButton:hover {
background: var(--hover-bg, rgba(0, 0, 0, 0.05));
color: var(--text-primary, #333);
}
.bellIcon {
font-size: 18px;
}
/* Badge */
.badge {
position: absolute;
top: 2px;
right: 2px;
min-width: 18px;
height: 18px;
padding: 0 5px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
text-align: center;
color: white;
background: var(--danger-color, #dc3545);
border-radius: 10px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
/* Dropdown */
.dropdown {
position: fixed;
bottom: 80px;
left: 290px;
width: 360px;
max-height: 480px;
background: var(--card-bg, white);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
overflow: hidden;
z-index: 9999;
animation: slideIn 0.2s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Header */
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid var(--border-color, #eee);
}
.header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #333);
}
.markAllRead {
background: none;
border: none;
color: var(--primary-color, #007bff);
font-size: 12px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.2s;
}
.markAllRead:hover {
background: var(--primary-light, rgba(0, 123, 255, 0.1));
}
/* Content */
.content {
max-height: 400px;
overflow-y: auto;
}
.loading,
.error,
.empty {
padding: 32px;
text-align: center;
color: var(--text-secondary, #6c757d);
}
.error {
color: var(--danger-color, #dc3545);
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.emptyIcon {
font-size: 32px;
opacity: 0.3;
}
/* Notification Item */
.notification {
position: relative;
display: flex;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--border-color, #eee);
cursor: pointer;
transition: background 0.2s;
}
.notification:hover {
background: var(--hover-bg, rgba(0, 0, 0, 0.02));
}
.notification:last-child {
border-bottom: none;
}
.notification.unread {
background: var(--primary-light, rgba(0, 123, 255, 0.05));
}
.notification.unread::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--primary-color, #007bff);
}
.notification.success {
background: var(--success-light, rgba(40, 167, 69, 0.1));
}
/* Success Overlay */
.successOverlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: var(--success-light, rgba(40, 167, 69, 0.95));
color: var(--success-color, #28a745);
font-weight: 500;
animation: fadeIn 0.3s ease;
z-index: 1;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* Icon */
.icon {
flex-shrink: 0;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--bg-secondary, #f5f5f5);
color: var(--text-secondary, #6c757d);
font-size: 14px;
}
.icon_invitation {
background: var(--primary-light, rgba(0, 123, 255, 0.1));
color: var(--primary-color, #007bff);
}
.icon_system {
background: var(--info-light, rgba(23, 162, 184, 0.1));
color: var(--info-color, #17a2b8);
}
.icon_workflow {
background: var(--warning-light, rgba(255, 193, 7, 0.1));
color: var(--warning-color, #ffc107);
}
.icon_mention {
background: var(--purple-light, rgba(111, 66, 193, 0.1));
color: var(--purple-color, #6f42c1);
}
/* Notification Content */
.notificationContent {
flex: 1;
min-width: 0;
}
.title {
font-weight: 600;
font-size: 14px;
color: var(--text-primary, #333);
margin-bottom: 2px;
}
.message {
font-size: 13px;
color: var(--text-secondary, #6c757d);
line-height: 1.4;
margin-bottom: 4px;
/* Truncate long messages */
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.time {
font-size: 11px;
color: var(--text-muted, #999);
}
/* Actions */
.actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.actionButton {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.actionButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.action_primary {
background: var(--primary-color, #007bff);
color: white;
}
.action_primary:hover:not(:disabled) {
background: var(--primary-dark, #0056b3);
}
.action_danger {
background: transparent;
color: var(--danger-color, #dc3545);
border: 1px solid var(--danger-color, #dc3545);
}
.action_danger:hover:not(:disabled) {
background: var(--danger-light, rgba(220, 53, 69, 0.1));
}
.action_default {
background: var(--bg-secondary, #f5f5f5);
color: var(--text-primary, #333);
}
.action_default:hover:not(:disabled) {
background: var(--bg-tertiary, #e9e9e9);
}
/* Action Result */
.actionResult {
margin-top: 8px;
padding: 8px;
font-size: 12px;
background: var(--success-light, rgba(40, 167, 69, 0.1));
color: var(--success-color, #28a745);
border-radius: 4px;
}
/* Dismiss Button */
.dismissButton {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--text-muted, #999);
cursor: pointer;
border-radius: 4px;
opacity: 0;
transition: all 0.2s;
}
.notification:hover .dismissButton {
opacity: 1;
}
.dismissButton:hover {
background: var(--danger-light, rgba(220, 53, 69, 0.1));
color: var(--danger-color, #dc3545);
}
/* Scrollbar */
.content::-webkit-scrollbar {
width: 6px;
}
.content::-webkit-scrollbar-track {
background: transparent;
}
.content::-webkit-scrollbar-thumb {
background: var(--border-color, #ddd);
border-radius: 3px;
}
.content::-webkit-scrollbar-thumb:hover {
background: var(--text-muted, #999);
}

View file

@ -0,0 +1,257 @@
/**
* NotificationBell Component
*
* Displays a bell icon with unread count badge.
* Clicking opens a dropdown with recent notifications.
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { FaBell, FaCheck, FaTimes, FaEnvelope, FaCog, FaExclamationTriangle, FaCheckCircle } from 'react-icons/fa';
import { useNotifications, UserNotification } from '../../hooks/useNotifications';
import styles from './NotificationBell.module.css';
// Icon mapping for notification types
const typeIcons: Record<string, React.ReactNode> = {
invitation: <FaEnvelope />,
system: <FaCog />,
workflow: <FaCog />,
mention: <FaExclamationTriangle />
};
// Format timestamp to relative time
function formatRelativeTime(timestamp: number): string {
const now = Date.now() / 1000;
const diff = now - timestamp;
if (diff < 60) return 'Gerade eben';
if (diff < 3600) return `vor ${Math.floor(diff / 60)} Min.`;
if (diff < 86400) return `vor ${Math.floor(diff / 3600)} Std.`;
if (diff < 604800) return `vor ${Math.floor(diff / 86400)} Tagen`;
const date = new Date(timestamp * 1000);
return date.toLocaleDateString('de-DE');
}
interface NotificationBellProps {
className?: string;
}
export const NotificationBell: React.FC<NotificationBellProps> = ({ className }) => {
const {
notifications,
unreadCount,
loading,
error,
fetchNotifications,
markAsRead,
markAllAsRead,
executeAction,
dismissNotification,
startPolling,
stopPolling
} = useNotifications();
const [isOpen, setIsOpen] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// Start polling on mount
useEffect(() => {
startPolling(30000); // Poll every 30 seconds
return () => stopPolling();
}, [startPolling, stopPolling]);
// Fetch notifications when dropdown opens
useEffect(() => {
if (isOpen) {
fetchNotifications({ limit: 10 });
}
}, [isOpen, fetchNotifications]);
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen]);
// Handle action button click
const handleAction = useCallback(async (
notification: UserNotification,
actionId: string,
event: React.MouseEvent
) => {
event.stopPropagation();
setActionLoading(`${notification.id}-${actionId}`);
const result = await executeAction(notification.id, actionId);
setActionLoading(null);
if (result) {
setActionSuccess(notification.id);
// Clear success state after animation
setTimeout(() => {
setActionSuccess(null);
fetchNotifications({ limit: 10 });
}, 2000);
}
}, [executeAction, fetchNotifications]);
// Handle dismiss
const handleDismiss = useCallback(async (
notification: UserNotification,
event: React.MouseEvent
) => {
event.stopPropagation();
await dismissNotification(notification.id);
}, [dismissNotification]);
// Handle notification click (mark as read)
const handleNotificationClick = useCallback(async (notification: UserNotification) => {
if (notification.status === 'unread') {
await markAsRead(notification.id);
}
}, [markAsRead]);
// Filter out dismissed notifications
const visibleNotifications = notifications.filter(n => n.status !== 'dismissed');
return (
<div className={`${styles.notificationBell} ${className || ''}`} ref={dropdownRef}>
{/* Bell Button */}
<button
className={styles.bellButton}
onClick={() => setIsOpen(!isOpen)}
aria-label={`Benachrichtigungen ${unreadCount > 0 ? `(${unreadCount} ungelesen)` : ''}`}
>
<FaBell className={styles.bellIcon} />
{unreadCount > 0 && (
<span className={styles.badge}>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{/* Dropdown */}
{isOpen && (
<div className={styles.dropdown}>
{/* Header */}
<div className={styles.header}>
<h3>Benachrichtigungen</h3>
{visibleNotifications.some(n => n.status === 'unread') && (
<button
className={styles.markAllRead}
onClick={() => markAllAsRead()}
>
Alle als gelesen markieren
</button>
)}
</div>
{/* Content */}
<div className={styles.content}>
{loading && visibleNotifications.length === 0 && (
<div className={styles.loading}>Lade...</div>
)}
{error && (
<div className={styles.error}>{error}</div>
)}
{!loading && !error && visibleNotifications.length === 0 && (
<div className={styles.empty}>
<FaBell className={styles.emptyIcon} />
<p>Keine Benachrichtigungen</p>
</div>
)}
{visibleNotifications.map(notification => (
<div
key={notification.id}
className={`
${styles.notification}
${notification.status === 'unread' ? styles.unread : ''}
${actionSuccess === notification.id ? styles.success : ''}
`}
onClick={() => handleNotificationClick(notification)}
>
{/* Success overlay */}
{actionSuccess === notification.id && (
<div className={styles.successOverlay}>
<FaCheckCircle />
<span>{notification.actionResult || 'Erfolgreich'}</span>
</div>
)}
{/* Icon */}
<div className={`${styles.icon} ${styles[`icon_${notification.type}`]}`}>
{typeIcons[notification.type] || <FaBell />}
</div>
{/* Content */}
<div className={styles.notificationContent}>
<div className={styles.title}>{notification.title}</div>
<div className={styles.message}>{notification.message}</div>
<div className={styles.time}>{formatRelativeTime(notification.createdAt)}</div>
{/* Actions */}
{notification.actions && notification.status !== 'actioned' && (
<div className={styles.actions}>
{notification.actions.map(action => (
<button
key={action.actionId}
className={`${styles.actionButton} ${styles[`action_${action.style}`]}`}
onClick={(e) => handleAction(notification, action.actionId, e)}
disabled={actionLoading === `${notification.id}-${action.actionId}`}
>
{actionLoading === `${notification.id}-${action.actionId}` ? (
'...'
) : action.actionId === 'accept' ? (
<><FaCheck /> {action.label}</>
) : action.actionId === 'decline' ? (
<><FaTimes /> {action.label}</>
) : (
action.label
)}
</button>
))}
</div>
)}
{/* Action result */}
{notification.actionTaken && (
<div className={styles.actionResult}>
{notification.actionResult}
</div>
)}
</div>
{/* Dismiss button */}
{notification.status !== 'actioned' && (
<button
className={styles.dismissButton}
onClick={(e) => handleDismiss(notification, e)}
aria-label="Schliessen"
>
<FaTimes />
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
);
};
export default NotificationBell;

View file

@ -0,0 +1,2 @@
export { NotificationBell } from './NotificationBell';
export { default } from './NotificationBell';

View file

@ -0,0 +1,507 @@
/* =============================================================================
* RBAC Export/Import Component Styles
* ============================================================================= */
.rbacExportImport {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
@media (max-width: 768px) {
.rbacExportImport {
grid-template-columns: 1fr;
}
}
/* =============================================================================
* Section
* ============================================================================= */
.section {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
}
.sectionHeader {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.sectionIcon {
color: var(--primary-color);
font-size: 1.125rem;
}
.sectionTitle {
font-size: 1rem;
font-weight: 600;
margin: 0;
color: var(--text-primary);
}
.sectionContent {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.sectionDescription {
font-size: 0.875rem;
color: var(--text-secondary);
margin: 0;
line-height: 1.5;
}
/* =============================================================================
* Buttons
* ============================================================================= */
.primaryButton {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--primary-color);
color: white;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.primaryButton:hover:not(:disabled) {
background: var(--primary-color-dark);
}
.primaryButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.primaryButton.danger {
background: #c53030;
}
.primaryButton.danger:hover:not(:disabled) {
background: #9b2c2c;
}
.clearButton {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: none;
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-tertiary);
cursor: pointer;
transition: all 0.2s;
}
.clearButton:hover {
background: #fed7d7;
color: #c53030;
border-color: #fc8181;
}
.previewButton {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 0.8125rem;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
}
.previewButton:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.closeButton {
background: none;
border: none;
font-size: 1.25rem;
color: var(--text-tertiary);
cursor: pointer;
padding: 0.25rem;
line-height: 1;
}
.closeButton:hover {
color: var(--text-primary);
}
/* =============================================================================
* File Upload
* ============================================================================= */
.fileUpload {
display: flex;
align-items: center;
gap: 0.5rem;
}
.fileInput {
display: none;
}
.fileLabel {
flex: 1;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border: 2px dashed var(--border-color);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
color: var(--text-secondary);
font-size: 0.875rem;
}
.fileLabel:hover {
border-color: var(--primary-color);
background: var(--bg-secondary);
}
.fileIcon {
font-size: 1.25rem;
}
/* =============================================================================
* Import Info & Stats
* ============================================================================= */
.importInfo {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
background: var(--bg-secondary);
border-radius: 6px;
}
.importStats {
display: flex;
gap: 1rem;
font-size: 0.8125rem;
color: var(--text-secondary);
}
/* =============================================================================
* Import Mode Selection
* ============================================================================= */
.importModeSection {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.importModeTitle {
font-size: 0.875rem;
font-weight: 600;
margin: 0;
color: var(--text-primary);
}
.importModes {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.importModeOption {
display: grid;
grid-template-columns: auto auto 1fr;
grid-template-rows: auto auto;
gap: 0.25rem 0.5rem;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.importModeOption:hover {
background: var(--bg-secondary);
}
.importModeOption.selected {
border-color: var(--primary-color);
background: var(--primary-color-light);
}
.radioInput {
grid-row: span 2;
align-self: center;
accent-color: var(--primary-color);
}
.modeIcon {
grid-row: span 2;
align-self: center;
font-size: 1.125rem;
}
.modeLabel {
font-weight: 500;
color: var(--text-primary);
font-size: 0.875rem;
}
.modeDescription {
font-size: 0.75rem;
color: var(--text-tertiary);
grid-column: 3;
}
/* =============================================================================
* Messages
* ============================================================================= */
.errorMessage {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: #fed7d7;
color: #c53030;
border-radius: 6px;
font-size: 0.875rem;
}
.warningMessage {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem;
background: #fefcbf;
color: #744210;
border-radius: 6px;
font-size: 0.8125rem;
line-height: 1.4;
}
/* =============================================================================
* Modal
* ============================================================================= */
.modalOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: var(--bg-primary);
border-radius: 8px;
max-width: 500px;
width: 100%;
max-height: 80vh;
overflow: auto;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
/* =============================================================================
* Preview
* ============================================================================= */
.preview {
display: flex;
flex-direction: column;
}
.previewHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid var(--border-color);
}
.previewTitle {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.previewContent {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.previewSection {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.previewSection h5 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
}
.previewList {
margin: 0;
padding-left: 1.25rem;
font-size: 0.8125rem;
color: var(--text-primary);
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.previewList code {
background: var(--bg-secondary);
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.75rem;
}
.featureBadge,
.contextBadge {
display: inline-block;
padding: 0.125rem 0.375rem;
background: var(--primary-color-light);
color: var(--primary-color);
font-size: 0.625rem;
font-weight: 600;
border-radius: 3px;
margin-left: 0.5rem;
text-transform: uppercase;
}
.contextBadge {
background: var(--bg-tertiary);
color: var(--text-secondary);
margin-left: 0;
margin-right: 0.5rem;
}
.moreItems {
color: var(--text-tertiary);
font-style: italic;
}
/* =============================================================================
* Import Result
* ============================================================================= */
.importResult {
display: flex;
flex-direction: column;
}
.resultHeader {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border-bottom: 1px solid var(--border-color);
}
.importResult.success .resultHeader {
background: #c6f6d5;
}
.importResult.error .resultHeader {
background: #fed7d7;
}
.resultIcon {
font-size: 1.25rem;
}
.importResult.success .resultIcon {
color: #38a169;
}
.importResult.error .resultIcon {
color: #c53030;
}
.resultTitle {
flex: 1;
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.resultContent {
padding: 1rem;
}
.resultStats {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.375rem;
font-size: 0.875rem;
}
.resultErrors {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.resultErrors h5 {
margin: 0 0 0.5rem;
font-size: 0.875rem;
color: #c53030;
}
.resultErrors ul {
margin: 0;
padding-left: 1.25rem;
font-size: 0.8125rem;
color: #c53030;
}
/* =============================================================================
* Spinning Animation
* ============================================================================= */
.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

View file

@ -0,0 +1,458 @@
/**
* RbacExportImport
*
* Component for exporting and importing RBAC configurations.
* Supports mandate-level and global exports with different import modes.
*/
import React, { useState, useRef } from 'react';
import {
FaDownload,
FaUpload,
FaFileExport,
FaFileImport,
FaSpinner,
FaCheckCircle,
FaExclamationTriangle,
FaInfoCircle,
FaTrash,
FaEye,
} from 'react-icons/fa';
import {
useRbacExportImport,
type RbacExport,
type ImportMode,
type RbacImportResult,
} from '../../hooks/useRbacExportImport';
import styles from './RbacExportImport.module.css';
// =============================================================================
// TYPES
// =============================================================================
interface RbacExportImportProps {
mandateId?: string;
mandateName?: string;
isGlobal?: boolean;
featureCode?: string;
}
// =============================================================================
// IMPORT MODE OPTIONS
// =============================================================================
const IMPORT_MODES: { value: ImportMode; label: string; description: string; icon: React.ReactNode }[] = [
{
value: 'merge',
label: 'Zusammenführen',
description: 'Bestehende Regeln aktualisieren, neue hinzufügen',
icon: <FaCheckCircle style={{ color: '#38a169' }} />,
},
{
value: 'add_only',
label: 'Nur hinzufügen',
description: 'Nur neue Regeln hinzufügen, bestehende nicht ändern',
icon: <FaInfoCircle style={{ color: '#3182ce' }} />,
},
{
value: 'replace',
label: 'Ersetzen',
description: 'Alle bestehenden Regeln löschen und ersetzen',
icon: <FaExclamationTriangle style={{ color: '#d69e2e' }} />,
},
];
// =============================================================================
// PREVIEW COMPONENT
// =============================================================================
interface PreviewProps {
data: RbacExport;
onClose: () => void;
}
const ExportPreview: React.FC<PreviewProps> = ({ data, onClose }) => {
return (
<div className={styles.preview}>
<div className={styles.previewHeader}>
<h4 className={styles.previewTitle}>Export-Vorschau</h4>
<button className={styles.closeButton} onClick={onClose}></button>
</div>
<div className={styles.previewContent}>
<div className={styles.previewSection}>
<h5>Scope</h5>
<ul className={styles.previewList}>
<li><strong>Typ:</strong> {data.scope.type}</li>
{data.scope.mandateName && <li><strong>Mandant:</strong> {data.scope.mandateName}</li>}
{data.scope.featureCode && <li><strong>Feature:</strong> {data.scope.featureCode}</li>}
</ul>
</div>
<div className={styles.previewSection}>
<h5>Rollen ({data.roles.length})</h5>
<ul className={styles.previewList}>
{data.roles.slice(0, 5).map((role, i) => (
<li key={i}>
<code>{role.roleLabel}</code>
{role.featureCode && <span className={styles.featureBadge}>{role.featureCode}</span>}
</li>
))}
{data.roles.length > 5 && (
<li className={styles.moreItems}>... und {data.roles.length - 5} weitere</li>
)}
</ul>
</div>
<div className={styles.previewSection}>
<h5>Regeln ({data.accessRules.length})</h5>
<ul className={styles.previewList}>
{data.accessRules.slice(0, 5).map((rule, i) => (
<li key={i}>
<span className={styles.contextBadge}>{rule.context}</span>
<code>{rule.item || '(global)'}</code>
</li>
))}
{data.accessRules.length > 5 && (
<li className={styles.moreItems}>... und {data.accessRules.length - 5} weitere</li>
)}
</ul>
</div>
</div>
</div>
);
};
// =============================================================================
// IMPORT RESULT COMPONENT
// =============================================================================
interface ImportResultProps {
result: RbacImportResult;
onClose: () => void;
}
const ImportResult: React.FC<ImportResultProps> = ({ result, onClose }) => {
const isSuccess = result.status === 'success';
return (
<div className={`${styles.importResult} ${isSuccess ? styles.success : styles.error}`}>
<div className={styles.resultHeader}>
{isSuccess ? (
<FaCheckCircle className={styles.resultIcon} />
) : (
<FaExclamationTriangle className={styles.resultIcon} />
)}
<h4 className={styles.resultTitle}>
{isSuccess ? 'Import erfolgreich' : 'Import fehlgeschlagen'}
</h4>
<button className={styles.closeButton} onClick={onClose}></button>
</div>
<div className={styles.resultContent}>
<ul className={styles.resultStats}>
<li><strong>Modus:</strong> {IMPORT_MODES.find(m => m.value === result.mode)?.label}</li>
<li><strong>Rollen erstellt:</strong> {result.rolesCreated}</li>
<li><strong>Rollen aktualisiert:</strong> {result.rolesUpdated}</li>
<li><strong>Regeln erstellt:</strong> {result.rulesCreated}</li>
<li><strong>Regeln aktualisiert:</strong> {result.rulesUpdated}</li>
</ul>
{result.errors && result.errors.length > 0 && (
<div className={styles.resultErrors}>
<h5>Fehler:</h5>
<ul>
{result.errors.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
</div>
)}
</div>
</div>
);
};
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export const RbacExportImport: React.FC<RbacExportImportProps> = ({
mandateId,
mandateName,
isGlobal = false,
featureCode,
}) => {
const {
exporting,
importing,
error,
lastImportResult,
exportMandateRbac,
exportGlobalRbac,
importMandateRbac,
importGlobalRbac,
downloadExport,
parseImportFile,
reset,
} = useRbacExportImport();
const [importMode, setImportMode] = useState<ImportMode>('merge');
const [importFile, setImportFile] = useState<File | null>(null);
const [importData, setImportData] = useState<RbacExport | null>(null);
const [parseError, setParseError] = useState<string | null>(null);
const [showPreview, setShowPreview] = useState(false);
const [showResult, setShowResult] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Handle export
const handleExport = async () => {
let result;
if (isGlobal) {
result = await exportGlobalRbac(featureCode);
} else if (mandateId) {
result = await exportMandateRbac(mandateId, featureCode);
} else {
return;
}
if (result.success && result.data) {
downloadExport(result.data);
}
};
// Handle file selection
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setImportFile(file);
setParseError(null);
const result = await parseImportFile(file);
if (result.success && result.data) {
setImportData(result.data);
} else {
setParseError(result.error || 'Fehler beim Parsen');
setImportData(null);
}
};
// Handle import
const handleImport = async () => {
if (!importData) return;
let result;
if (isGlobal) {
result = await importGlobalRbac(importData, importMode);
} else if (mandateId) {
result = await importMandateRbac(mandateId, importData, importMode);
} else {
return;
}
if (result.success) {
setShowResult(true);
// Clear import state
setImportFile(null);
setImportData(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
// Clear import state
const handleClearImport = () => {
setImportFile(null);
setImportData(null);
setParseError(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
// Handle close result
const handleCloseResult = () => {
setShowResult(false);
reset();
};
return (
<div className={styles.rbacExportImport}>
{/* Export Section */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<FaFileExport className={styles.sectionIcon} />
<h3 className={styles.sectionTitle}>Export</h3>
</div>
<div className={styles.sectionContent}>
<p className={styles.sectionDescription}>
Exportiert alle Rollen und Berechtigungen
{isGlobal ? ' der globalen Templates' : ` des Mandanten "${mandateName || mandateId}"`}
{featureCode ? ` für Feature "${featureCode}"` : ''} als JSON-Datei.
</p>
<button
className={styles.primaryButton}
onClick={handleExport}
disabled={exporting || (!isGlobal && !mandateId)}
>
{exporting ? (
<>
<FaSpinner className="spinning" /> Exportieren...
</>
) : (
<>
<FaDownload /> RBAC exportieren
</>
)}
</button>
</div>
</div>
{/* Import Section */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<FaFileImport className={styles.sectionIcon} />
<h3 className={styles.sectionTitle}>Import</h3>
</div>
<div className={styles.sectionContent}>
{/* File Upload */}
<div className={styles.fileUpload}>
<input
type="file"
ref={fileInputRef}
accept=".json"
onChange={handleFileSelect}
className={styles.fileInput}
id="rbac-import-file"
/>
<label htmlFor="rbac-import-file" className={styles.fileLabel}>
{importFile ? (
<>
<FaCheckCircle className={styles.fileIcon} style={{ color: '#38a169' }} />
<span>{importFile.name}</span>
</>
) : (
<>
<FaUpload className={styles.fileIcon} />
<span>JSON-Datei auswählen oder hier ablegen</span>
</>
)}
</label>
{importFile && (
<button
className={styles.clearButton}
onClick={handleClearImport}
title="Datei entfernen"
>
<FaTrash />
</button>
)}
</div>
{/* Parse Error */}
{parseError && (
<div className={styles.errorMessage}>
<FaExclamationTriangle /> {parseError}
</div>
)}
{/* Import Data Info */}
{importData && (
<div className={styles.importInfo}>
<div className={styles.importStats}>
<span><strong>Rollen:</strong> {importData.roles.length}</span>
<span><strong>Regeln:</strong> {importData.accessRules.length}</span>
<span><strong>Quelle:</strong> {importData.scope.type}</span>
</div>
<button
className={styles.previewButton}
onClick={() => setShowPreview(true)}
>
<FaEye /> Vorschau
</button>
</div>
)}
{/* Import Mode Selection */}
{importData && (
<div className={styles.importModeSection}>
<h4 className={styles.importModeTitle}>Import-Modus</h4>
<div className={styles.importModes}>
{IMPORT_MODES.map(mode => (
<label
key={mode.value}
className={`${styles.importModeOption} ${importMode === mode.value ? styles.selected : ''}`}
>
<input
type="radio"
name="importMode"
value={mode.value}
checked={importMode === mode.value}
onChange={(e) => setImportMode(e.target.value as ImportMode)}
className={styles.radioInput}
/>
<span className={styles.modeIcon}>{mode.icon}</span>
<span className={styles.modeLabel}>{mode.label}</span>
<span className={styles.modeDescription}>{mode.description}</span>
</label>
))}
</div>
</div>
)}
{/* Import Button */}
{importData && (
<button
className={`${styles.primaryButton} ${importMode === 'replace' ? styles.danger : ''}`}
onClick={handleImport}
disabled={importing || (!isGlobal && !mandateId)}
>
{importing ? (
<>
<FaSpinner className="spinning" /> Importieren...
</>
) : (
<>
<FaUpload /> RBAC importieren
</>
)}
</button>
)}
{/* Warning for replace mode */}
{importMode === 'replace' && importData && (
<div className={styles.warningMessage}>
<FaExclamationTriangle />
<strong>Achtung:</strong> Im Modus "Ersetzen" werden alle bestehenden Rollen und Regeln gelöscht!
</div>
)}
</div>
</div>
{/* Error Message */}
{error && (
<div className={styles.errorMessage}>
<FaExclamationTriangle /> {error}
</div>
)}
{/* Preview Modal */}
{showPreview && importData && (
<div className={styles.modalOverlay} onClick={() => setShowPreview(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<ExportPreview data={importData} onClose={() => setShowPreview(false)} />
</div>
</div>
)}
{/* Result Modal */}
{showResult && lastImportResult && (
<div className={styles.modalOverlay} onClick={handleCloseResult}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<ImportResult result={lastImportResult} onClose={handleCloseResult} />
</div>
</div>
)}
</div>
);
};
export default RbacExportImport;

View file

@ -0,0 +1,5 @@
/**
* RBAC Export/Import Components
*/
export { RbacExportImport } from './RbacExportImport';

View file

@ -70,7 +70,7 @@ const SidebarUser: React.FC<SidebarUserProps> = ({ isMinimized = false }) => {
enabled: cached.enabled ?? true, // Assume enabled if logged in
roleLabels: cached.roleLabels || [],
authenticationAuthority: cached.authenticationAuthority || 'local',
mandateId: cached.mandateId || ''
isSysAdmin: cached.isSysAdmin || false
};
setUser(userData);
setUserError(null);
@ -99,7 +99,7 @@ const SidebarUser: React.FC<SidebarUserProps> = ({ isMinimized = false }) => {
enabled: cached.enabled ?? true,
roleLabels: cached.roleLabels || [],
authenticationAuthority: cached.authenticationAuthority || 'local',
mandateId: cached.mandateId || ''
isSysAdmin: cached.isSysAdmin || false
};
setUser(userData);
setUserError(null);

View file

@ -0,0 +1,481 @@
import { useState, useEffect } from 'react';
import { IoIosBusiness, IoIosContact, IoIosTime, IoIosRefresh } from 'react-icons/io';
import sharedStyles from '../../core/PageManager/pages.module.css';
import styles from './SpeechSettings.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
interface MandateData {
id: string;
mandate_general: {
company_name: string;
industry: string;
contact_info: {
email: string;
phone: string;
street: string;
postal_code: string;
city: string;
country: string;
};
business_hours: string;
timezone: string;
};
setup_contacts: boolean;
}
interface SpeechSettingsProps {
onDataUpdate?: (data: MandateData) => void;
}
function SpeechSettings({ onDataUpdate }: SpeechSettingsProps) {
const { t } = useLanguage();
const [formData, setFormData] = useState<MandateData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
const [focusedFields, setFocusedFields] = useState<Set<string>>(new Set());
// Load data from localStorage on component mount
useEffect(() => {
const loadSpeechData = () => {
try {
const savedData = localStorage.getItem('speechSignUpData');
const timestamp = localStorage.getItem('speechSignUpTimestamp');
if (savedData && timestamp) {
const parsedData = JSON.parse(savedData);
const savedTime = parseInt(timestamp);
const now = Date.now();
const twentyFourHours = 24 * 60 * 60 * 1000;
// Check if data is still valid (within 24 hours)
if (now - savedTime < twentyFourHours) {
setFormData(parsedData);
} else {
// Data expired, clear it
localStorage.removeItem('speechSignUpData');
localStorage.removeItem('speechSignUpTimestamp');
}
}
} catch (error) {
console.error('Error loading speech data:', error);
} finally {
setIsLoading(false);
}
};
loadSpeechData();
}, []);
const handleInputChange = (field: string, value: string) => {
if (!formData) return;
const newData = { ...formData };
const fieldParts = field.split('.');
if (fieldParts.length === 2) {
// Handle nested fields like mandate_general.company_name
const [parent, child] = fieldParts;
if (parent === 'mandate_general' && child in newData.mandate_general) {
(newData.mandate_general as any)[child] = value;
}
} else if (fieldParts.length === 3) {
// Handle deeply nested fields like mandate_general.contact_info.email
const [parent, child, grandchild] = fieldParts;
if (parent === 'mandate_general' && child === 'contact_info' && grandchild in newData.mandate_general.contact_info) {
(newData.mandate_general.contact_info as any)[grandchild] = value;
}
} else if (field === 'setup_contacts') {
newData.setup_contacts = value === 'true';
}
setFormData(newData);
setSaveMessage(null);
};
const handleFocus = (field: string) => {
setFocusedFields(prev => new Set(prev).add(field));
};
const handleBlur = (field: string) => {
setFocusedFields(prev => {
const newSet = new Set(prev);
newSet.delete(field);
return newSet;
});
};
const handleSave = async () => {
if (!formData) return;
setIsSaving(true);
try {
// Save to localStorage
localStorage.setItem('speechSignUpData', JSON.stringify(formData));
localStorage.setItem('speechSignUpTimestamp', Date.now().toString());
// Dispatch event to notify other components
window.dispatchEvent(new CustomEvent('speechSignUpChanged'));
setSaveMessage({ type: 'success', text: t('speech.settings.save_success') });
// Notify parent component if callback provided
if (onDataUpdate) {
onDataUpdate(formData);
}
// Clear message after 3 seconds
setTimeout(() => setSaveMessage(null), 3000);
} catch (error) {
console.error('Error saving speech settings:', error);
setSaveMessage({ type: 'error', text: t('speech.settings.save_error') });
} finally {
setIsSaving(false);
}
};
const handleReset = () => {
// Direct reset - user clicked the reset button intentionally
localStorage.removeItem('speechSignUpData');
localStorage.removeItem('speechSignUpTimestamp');
window.dispatchEvent(new CustomEvent('speechSignUpChanged'));
setFormData(null);
setSaveMessage({ type: 'success', text: t('speech.settings.reset_success') });
setTimeout(() => setSaveMessage(null), 3000);
};
if (isLoading) {
return (
<div className={styles.container}>
<div className={styles.loading}>
{t('common.loading')}
</div>
</div>
);
}
if (!formData) {
return (
<div className={styles.container}>
<div className={styles.noData}>
<p>{t('speech.settings.no_data')}</p>
<button
className={sharedStyles.primaryButton}
onClick={() => window.location.href = '/speech'}
>
{t('speech.settings.sign_up_now')}
</button>
</div>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.header}>
<h2 className={styles.title}>{t('speech.settings.title')}</h2>
<p className={styles.description}>{t('speech.settings.description')}</p>
</div>
{saveMessage && (
<div className={`${styles.message} ${saveMessage.type === 'success' ? styles.successMessage : styles.errorMessage}`}>
{saveMessage.text}
</div>
)}
<div className={styles.form}>
{/* Company Information Section */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<IoIosBusiness className={styles.sectionIcon} />
<h3 className={styles.sectionTitle}>{t('speech.settings.company_info')}</h3>
</div>
<div className={styles.formRow}>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="text"
id="company_name"
className={styles.formInput}
value={formData.mandate_general.company_name}
onChange={(e) => handleInputChange('mandate_general.company_name', e.target.value)}
onFocus={() => handleFocus('company_name')}
onBlur={() => handleBlur('company_name')}
required
/>
<label
htmlFor="company_name"
className={`${styles.floatingLabel} ${formData.mandate_general.company_name || focusedFields.has('company_name') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.company_name')} *
</label>
</div>
</div>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="text"
id="industry"
className={styles.formInput}
value={formData.mandate_general.industry}
onChange={(e) => handleInputChange('mandate_general.industry', e.target.value)}
onFocus={() => handleFocus('industry')}
onBlur={() => handleBlur('industry')}
required
/>
<label
htmlFor="industry"
className={`${styles.floatingLabel} ${formData.mandate_general.industry || focusedFields.has('industry') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.industry')} *
</label>
</div>
</div>
</div>
</div>
{/* Contact Information Section */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<IoIosContact className={styles.sectionIcon} />
<h3 className={styles.sectionTitle}>{t('speech.settings.contact_info')}</h3>
</div>
<div className={styles.formRow}>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="email"
id="email"
className={styles.formInput}
value={formData.mandate_general.contact_info.email}
onChange={(e) => handleInputChange('mandate_general.contact_info.email', e.target.value)}
onFocus={() => handleFocus('email')}
onBlur={() => handleBlur('email')}
required
/>
<label
htmlFor="email"
className={`${styles.floatingLabel} ${formData.mandate_general.contact_info.email || focusedFields.has('email') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.email')} *
</label>
</div>
</div>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="tel"
id="phone"
className={styles.formInput}
value={formData.mandate_general.contact_info.phone}
onChange={(e) => handleInputChange('mandate_general.contact_info.phone', e.target.value)}
onFocus={() => handleFocus('phone')}
onBlur={() => handleBlur('phone')}
required
/>
<label
htmlFor="phone"
className={`${styles.floatingLabel} ${formData.mandate_general.contact_info.phone || focusedFields.has('phone') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.phone')} *
</label>
</div>
</div>
</div>
<div className={styles.formRow}>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="text"
id="street"
className={styles.formInput}
value={formData.mandate_general.contact_info.street}
onChange={(e) => handleInputChange('mandate_general.contact_info.street', e.target.value)}
onFocus={() => handleFocus('street')}
onBlur={() => handleBlur('street')}
required
/>
<label
htmlFor="street"
className={`${styles.floatingLabel} ${formData.mandate_general.contact_info.street || focusedFields.has('street') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.street')} *
</label>
</div>
</div>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="text"
id="postal_code"
className={styles.formInput}
value={formData.mandate_general.contact_info.postal_code}
onChange={(e) => handleInputChange('mandate_general.contact_info.postal_code', e.target.value)}
onFocus={() => handleFocus('postal_code')}
onBlur={() => handleBlur('postal_code')}
required
/>
<label
htmlFor="postal_code"
className={`${styles.floatingLabel} ${formData.mandate_general.contact_info.postal_code || focusedFields.has('postal_code') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.postal_code')} *
</label>
</div>
</div>
</div>
<div className={styles.formRow}>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="text"
id="city"
className={styles.formInput}
value={formData.mandate_general.contact_info.city}
onChange={(e) => handleInputChange('mandate_general.contact_info.city', e.target.value)}
onFocus={() => handleFocus('city')}
onBlur={() => handleBlur('city')}
required
/>
<label
htmlFor="city"
className={`${styles.floatingLabel} ${formData.mandate_general.contact_info.city || focusedFields.has('city') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.city')} *
</label>
</div>
</div>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="text"
id="country"
className={styles.formInput}
value={formData.mandate_general.contact_info.country}
onChange={(e) => handleInputChange('mandate_general.contact_info.country', e.target.value)}
onFocus={() => handleFocus('country')}
onBlur={() => handleBlur('country')}
required
/>
<label
htmlFor="country"
className={`${styles.floatingLabel} ${formData.mandate_general.contact_info.country || focusedFields.has('country') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.country')} *
</label>
</div>
</div>
</div>
</div>
{/* Business Hours Section */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<IoIosTime className={styles.sectionIcon} />
<h3 className={styles.sectionTitle}>{t('speech.settings.business_hours')}</h3>
</div>
<div className={styles.formRow}>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="text"
id="business_hours"
className={styles.formInput}
value={formData.mandate_general.business_hours}
onChange={(e) => handleInputChange('mandate_general.business_hours', e.target.value)}
onFocus={() => handleFocus('business_hours')}
onBlur={() => handleBlur('business_hours')}
required
/>
<label
htmlFor="business_hours"
className={`${styles.floatingLabel} ${formData.mandate_general.business_hours || focusedFields.has('business_hours') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.business_hours')} *
</label>
</div>
</div>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<select
id="timezone"
className={styles.formSelect}
value={formData.mandate_general.timezone}
onChange={(e) => handleInputChange('mandate_general.timezone', e.target.value)}
onFocus={() => handleFocus('timezone')}
onBlur={() => handleBlur('timezone')}
required
>
<option value="">{t('speech.signup.select_timezone')}</option>
<option value="UTC-12">UTC-12 (Baker Island)</option>
<option value="UTC-11">UTC-11 (American Samoa)</option>
<option value="UTC-10">UTC-10 (Hawaii)</option>
<option value="UTC-9">UTC-9 (Alaska)</option>
<option value="UTC-8">UTC-8 (Pacific Time)</option>
<option value="UTC-7">UTC-7 (Mountain Time)</option>
<option value="UTC-6">UTC-6 (Central Time)</option>
<option value="UTC-5">UTC-5 (Eastern Time)</option>
<option value="UTC-4">UTC-4 (Atlantic Time)</option>
<option value="UTC-3">UTC-3 (Brazil)</option>
<option value="UTC-2">UTC-2 (Mid-Atlantic)</option>
<option value="UTC-1">UTC-1 (Azores)</option>
<option value="UTC+0">UTC+0 (Greenwich Mean Time)</option>
<option value="UTC+1">UTC+1 (Central European Time)</option>
<option value="UTC+2">UTC+2 (Eastern European Time)</option>
<option value="UTC+3">UTC+3 (Moscow Time)</option>
<option value="UTC+4">UTC+4 (Gulf Standard Time)</option>
<option value="UTC+5">UTC+5 (Pakistan Standard Time)</option>
<option value="UTC+6">UTC+6 (Bangladesh Standard Time)</option>
<option value="UTC+7">UTC+7 (Indochina Time)</option>
<option value="UTC+8">UTC+8 (China Standard Time)</option>
<option value="UTC+9">UTC+9 (Japan Standard Time)</option>
<option value="UTC+10">UTC+10 (Australian Eastern Time)</option>
<option value="UTC+11">UTC+11 (Solomon Islands)</option>
<option value="UTC+12">UTC+12 (New Zealand)</option>
</select>
<label
htmlFor="timezone"
className={`${styles.floatingLabel} ${formData.mandate_general.timezone || focusedFields.has('timezone') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.timezone')} *
</label>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className={styles.actions}>
<button
className={sharedStyles.secondaryButton}
onClick={handleReset}
disabled={isSaving}
>
<IoIosRefresh className={styles.resetIcon} />
{t('speech.settings.reset')}
</button>
<button
className={sharedStyles.primaryButton}
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? t('speech.settings.saving') : t('speech.settings.save')}
</button>
</div>
</div>
</div>
);
}
export default SpeechSettings;

View file

@ -14,9 +14,11 @@ export interface ChatMessageProps {
onFileDelete?: (file: WorkflowFile) => Promise<void>;
onFileRemove?: (file: WorkflowFile) => Promise<void>;
onFileView?: (file: WorkflowFile) => Promise<void>;
onFileDownload?: (file: WorkflowFile) => Promise<void>;
deletingFiles?: Set<string>;
previewingFiles?: Set<string>;
removingFiles?: Set<string>;
downloadingFiles?: Set<string>;
workflowId?: string;
}
@ -30,9 +32,11 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
onFileDelete,
onFileRemove,
onFileView,
onFileDownload,
deletingFiles,
previewingFiles,
removingFiles,
downloadingFiles,
workflowId
}) => {
const isUser = message.role?.toLowerCase() === 'user';
@ -131,9 +135,11 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
onFileDelete={onFileDelete}
onFileRemove={onFileRemove}
onFileView={onFileView}
onFileDownload={onFileDownload}
deletingFiles={deletingFiles}
previewingFiles={previewingFiles}
removingFiles={removingFiles}
downloadingFiles={downloadingFiles}
workflowId={workflowId}
/>
)}

View file

@ -1,7 +1,7 @@
import React, { useMemo } from 'react';
import { MessageDocument, Message } from '../MessagesTypes';
import { formatFileSize } from '../MessageUtils';
import { ViewActionButton, DeleteActionButton, RemoveActionButton } from '../../../FormGenerator/ActionButtons';
import { ViewActionButton, DeleteActionButton, RemoveActionButton, DownloadActionButton } from '../../../FormGenerator/ActionButtons';
import { WorkflowFile } from '../../../../hooks/usePlayground';
import styles from '../Messages.module.css';
@ -12,9 +12,11 @@ export interface DocumentItemProps {
onFileDelete?: (file: WorkflowFile) => Promise<void>;
onFileRemove?: (file: WorkflowFile) => Promise<void>;
onFileView?: (file: WorkflowFile) => Promise<void>;
onFileDownload?: (file: WorkflowFile) => Promise<void>;
deletingFiles?: Set<string>;
previewingFiles?: Set<string>;
removingFiles?: Set<string>;
downloadingFiles?: Set<string>;
workflowId?: string;
}
@ -28,9 +30,11 @@ export const DocumentItem: React.FC<DocumentItemProps> = ({
onFileDelete,
onFileRemove,
onFileView,
onFileDownload,
deletingFiles = new Set(),
previewingFiles = new Set(),
removingFiles = new Set(),
downloadingFiles = new Set(),
workflowId: _workflowId
}) => {
// Convert MessageDocument to WorkflowFile format for compatibility with action buttons
@ -47,6 +51,7 @@ export const DocumentItem: React.FC<DocumentItemProps> = ({
const isDeleting = deletingFiles.has(document.fileId);
const isPreviewing = previewingFiles.has(document.fileId);
const isRemoving = removingFiles.has(document.fileId);
const isDownloading = downloadingFiles.has(document.fileId);
// Create hookData object for action buttons
const hookData = useMemo(() => ({
@ -65,8 +70,9 @@ export const DocumentItem: React.FC<DocumentItemProps> = ({
},
deletingItems: deletingFiles,
previewingFiles: previewingFiles,
removingItems: removingFiles
}), [onFileDelete, workflowFile, deletingFiles, previewingFiles, removingFiles]);
removingItems: removingFiles,
downloadingFiles: downloadingFiles
}), [onFileDelete, workflowFile, deletingFiles, previewingFiles, removingFiles, downloadingFiles]);
const handleView = async () => {
if (onFileView) {
@ -74,6 +80,12 @@ export const DocumentItem: React.FC<DocumentItemProps> = ({
}
};
const handleDownload = async () => {
if (onFileDownload) {
await onFileDownload(workflowFile);
}
};
const handleRemove = async () => {
if (onFileRemove) {
await onFileRemove(workflowFile);
@ -89,7 +101,7 @@ export const DocumentItem: React.FC<DocumentItemProps> = ({
{formatFileSize(document.fileSize)} {document.mimeType}
</div>
</div>
{(onFileView || onFileDelete || onFileRemove) && (
{(onFileView || onFileDownload || onFileDelete || onFileRemove) && (
<div style={{ display: 'flex', gap: '4px', flexShrink: 0 }}>
{onFileView && (
<ViewActionButton
@ -103,6 +115,17 @@ export const DocumentItem: React.FC<DocumentItemProps> = ({
typeField="mimeType"
/>
)}
{onFileDownload && (
<DownloadActionButton
row={workflowFile}
onDownload={handleDownload}
disabled={isDeleting || isRemoving}
loading={isDownloading}
hookData={hookData}
idField="fileId"
loadingStateName="downloadingFiles"
/>
)}
{onFileRemove && (
<RemoveActionButton
row={workflowFile}

View file

@ -19,9 +19,11 @@ const Messages: React.FC<MessagesProps> = ({
onFileDelete,
onFileRemove,
onFileView,
onFileDownload,
deletingFiles,
previewingFiles,
removingFiles,
downloadingFiles,
workflowId
}) => {
if (!messages || messages.length === 0) {
@ -61,9 +63,11 @@ const Messages: React.FC<MessagesProps> = ({
onFileDelete={onFileDelete}
onFileRemove={onFileRemove}
onFileView={onFileView}
onFileDownload={onFileDownload}
deletingFiles={deletingFiles}
previewingFiles={previewingFiles}
removingFiles={removingFiles}
downloadingFiles={downloadingFiles}
workflowId={workflowId}
/>
);
@ -79,9 +83,11 @@ const Messages: React.FC<MessagesProps> = ({
onFileDelete={onFileDelete}
onFileRemove={onFileRemove}
onFileView={onFileView}
onFileDownload={onFileDownload}
deletingFiles={deletingFiles}
previewingFiles={previewingFiles}
removingFiles={removingFiles}
downloadingFiles={downloadingFiles}
workflowId={workflowId}
/>
);

View file

@ -110,9 +110,11 @@ export interface MessagesProps {
onFileDelete?: (file: WorkflowFile) => Promise<void>;
onFileRemove?: (file: WorkflowFile) => Promise<void>;
onFileView?: (file: WorkflowFile) => Promise<void>;
onFileDownload?: (file: WorkflowFile) => Promise<void>;
deletingFiles?: Set<string>;
previewingFiles?: Set<string>;
removingFiles?: Set<string>;
downloadingFiles?: Set<string>;
workflowId?: string;
}

View file

@ -0,0 +1,130 @@
.toastContainer {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 400px;
pointer-events: none;
}
.toast {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
background: var(--surface-color, #ffffff);
border: 1px solid var(--border-color, #e0e0e0);
pointer-events: auto;
min-width: 300px;
}
.toast.success {
border-left: 4px solid #22c55e;
background: linear-gradient(90deg, rgba(34, 197, 94, 0.08) 0%, var(--surface-color, #ffffff) 100%);
}
.toast.error {
border-left: 4px solid #ef4444;
background: linear-gradient(90deg, rgba(239, 68, 68, 0.08) 0%, var(--surface-color, #ffffff) 100%);
}
.toast.warning {
border-left: 4px solid #f59e0b;
background: linear-gradient(90deg, rgba(245, 158, 11, 0.08) 0%, var(--surface-color, #ffffff) 100%);
}
.toast.info {
border-left: 4px solid #3b82f6;
background: linear-gradient(90deg, rgba(59, 130, 246, 0.08) 0%, var(--surface-color, #ffffff) 100%);
}
.icon {
flex-shrink: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.icon.success {
color: #22c55e;
}
.icon.error {
color: #ef4444;
}
.icon.warning {
color: #f59e0b;
}
.icon.info {
color: #3b82f6;
}
.content {
flex: 1;
min-width: 0;
}
.title {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-color, #1f2937);
margin: 0 0 0.25rem 0;
}
.message {
font-size: 0.8125rem;
color: var(--text-secondary, #6b7280);
margin: 0;
white-space: pre-line;
line-height: 1.4;
}
.closeButton {
flex-shrink: 0;
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
color: var(--text-secondary, #9ca3af);
border-radius: 4px;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
}
.closeButton:hover {
background: var(--hover-color, rgba(0, 0, 0, 0.05));
color: var(--text-color, #374151);
}
/* Dark theme support */
:global(.dark) .toast {
background: var(--surface-color, #1f2937);
border-color: var(--border-color, #374151);
}
:global(.dark) .toast.success {
background: linear-gradient(90deg, rgba(34, 197, 94, 0.12) 0%, var(--surface-color, #1f2937) 100%);
}
:global(.dark) .toast.error {
background: linear-gradient(90deg, rgba(239, 68, 68, 0.12) 0%, var(--surface-color, #1f2937) 100%);
}
:global(.dark) .toast.warning {
background: linear-gradient(90deg, rgba(245, 158, 11, 0.12) 0%, var(--surface-color, #1f2937) 100%);
}
:global(.dark) .toast.info {
background: linear-gradient(90deg, rgba(59, 130, 246, 0.12) 0%, var(--surface-color, #1f2937) 100%);
}

View file

@ -0,0 +1,76 @@
import { motion, AnimatePresence } from 'framer-motion';
import { FaCheckCircle, FaExclamationCircle, FaExclamationTriangle, FaInfoCircle, FaTimes } from 'react-icons/fa';
import styles from './Toast.module.css';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface ToastData {
id: string;
type: ToastType;
title: string;
message?: string;
duration?: number;
}
interface ToastProps {
toast: ToastData;
onClose: (id: string) => void;
}
const _getIcon = (type: ToastType) => {
switch (type) {
case 'success':
return <FaCheckCircle />;
case 'error':
return <FaExclamationCircle />;
case 'warning':
return <FaExclamationTriangle />;
case 'info':
return <FaInfoCircle />;
}
};
export const Toast: React.FC<ToastProps> = ({ toast, onClose }) => {
return (
<motion.div
className={`${styles.toast} ${styles[toast.type]}`}
initial={{ opacity: 0, x: 100, scale: 0.9 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 100, scale: 0.9 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
layout
>
<div className={`${styles.icon} ${styles[toast.type]}`}>
{_getIcon(toast.type)}
</div>
<div className={styles.content}>
<p className={styles.title}>{toast.title}</p>
{toast.message && <p className={styles.message}>{toast.message}</p>}
</div>
<button
className={styles.closeButton}
onClick={() => onClose(toast.id)}
aria-label="Schließen"
>
<FaTimes />
</button>
</motion.div>
);
};
interface ToastContainerProps {
toasts: ToastData[];
onClose: (id: string) => void;
}
export const ToastContainer: React.FC<ToastContainerProps> = ({ toasts, onClose }) => {
return (
<div className={styles.toastContainer}>
<AnimatePresence mode="popLayout">
{toasts.map((toast) => (
<Toast key={toast.id} toast={toast} onClose={onClose} />
))}
</AnimatePresence>
</div>
);
};

View file

@ -0,0 +1,2 @@
export { Toast, ToastContainer } from './Toast';
export type { ToastType, ToastData } from './Toast';

View file

@ -0,0 +1,59 @@
/**
* VoiceLanguageSelect Styles
*
* Compact select component for voice language selection.
* Designed to fit next to icon buttons.
*/
.container {
display: inline-flex;
align-items: center;
}
.select {
height: 36px;
padding: 0 0.5rem;
padding-right: 1.5rem;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--surface-color);
color: var(--text-primary);
font-size: 0.8125rem;
cursor: pointer;
transition: all 0.2s;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L2 4h8z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.4rem center;
min-width: 120px;
}
.select:hover:not(:disabled) {
background-color: var(--bg-secondary);
border-color: var(--primary-color, #f25843);
}
.select:focus {
outline: none;
border-color: var(--primary-color, #f25843);
box-shadow: 0 0 0 2px rgba(242, 88, 67, 0.1);
}
.select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Compact mode - smaller width */
.compact .select {
min-width: 70px;
padding-left: 0.375rem;
font-size: 0.75rem;
}
/* Dark mode adjustments */
@media (prefers-color-scheme: dark) {
.select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23aaa' d='M6 8L2 4h8z'/%3E%3C/svg%3E");
}
}

View file

@ -0,0 +1,138 @@
/**
* VoiceLanguageSelect
*
* Reusable component for selecting voice/speech recognition language.
* Defaults to user's profile language.
* Can be used for speech-to-text, text-to-speech, and translation features.
*/
import React from 'react';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './VoiceLanguageSelect.module.css';
// Voice language options with full locale codes for Google Cloud Speech
export interface VoiceLanguageOption {
code: string; // Full locale code (e.g., 'de-DE')
label: string; // Display label
shortCode: string; // Short code for mapping (e.g., 'de')
flag?: string; // Optional flag emoji
}
// Supported languages for speech recognition
export const voiceLanguages: VoiceLanguageOption[] = [
{ code: 'de-DE', label: 'Deutsch', shortCode: 'de', flag: '🇩🇪' },
{ code: 'de-CH', label: 'Deutsch (Schweiz)', shortCode: 'de', flag: '🇨🇭' },
{ code: 'en-US', label: 'English (US)', shortCode: 'en', flag: '🇺🇸' },
{ code: 'en-GB', label: 'English (UK)', shortCode: 'en', flag: '🇬🇧' },
{ code: 'fr-FR', label: 'Français', shortCode: 'fr', flag: '🇫🇷' },
{ code: 'fr-CH', label: 'Français (Suisse)', shortCode: 'fr', flag: '🇨🇭' },
{ code: 'it-IT', label: 'Italiano', shortCode: 'it', flag: '🇮🇹' },
{ code: 'it-CH', label: 'Italiano (Svizzera)', shortCode: 'it', flag: '🇨🇭' },
{ code: 'es-ES', label: 'Español', shortCode: 'es', flag: '🇪🇸' },
{ code: 'pt-BR', label: 'Português', shortCode: 'pt', flag: '🇧🇷' },
];
// Map user profile language (short code) to default voice language (full code)
const profileToVoiceLanguage: Record<string, string> = {
'de': 'de-DE',
'en': 'en-US',
'fr': 'fr-FR',
'it': 'it-IT',
'es': 'es-ES',
'pt': 'pt-BR',
};
export interface VoiceLanguageSelectProps {
value: string;
onChange: (languageCode: string) => void;
disabled?: boolean;
compact?: boolean; // Compact mode shows only flag/short code
showFlags?: boolean; // Show flag emojis
className?: string;
title?: string;
}
/**
* Get the default voice language based on user's profile language
*/
export const getDefaultVoiceLanguage = (profileLanguage?: string): string => {
if (profileLanguage && profileToVoiceLanguage[profileLanguage]) {
return profileToVoiceLanguage[profileLanguage];
}
return 'de-DE'; // Default fallback
};
export const VoiceLanguageSelect: React.FC<VoiceLanguageSelectProps> = ({
value,
onChange,
disabled = false,
compact = false,
showFlags = true,
className = '',
title = 'Sprache für Spracherkennung',
}) => {
const { currentLanguage } = useLanguage();
// Get the currently selected language option
const selectedOption = voiceLanguages.find(lang => lang.code === value);
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
onChange(e.target.value);
};
return (
<div className={`${styles.container} ${compact ? styles.compact : ''} ${className}`}>
<select
className={styles.select}
value={value}
onChange={handleChange}
disabled={disabled}
title={title}
>
{voiceLanguages.map((lang) => (
<option key={lang.code} value={lang.code}>
{showFlags && lang.flag ? `${lang.flag} ` : ''}
{compact ? lang.code.split('-')[0].toUpperCase() : lang.label}
</option>
))}
</select>
</div>
);
};
/**
* Hook to manage voice language state with user profile default
*/
export const useVoiceLanguage = (initialValue?: string) => {
const { currentLanguage } = useLanguage();
// Track if user has manually changed the language
const hasManuallyChanged = React.useRef(false);
// Initialize with user's profile language (or provided initial value)
const [voiceLanguage, setVoiceLanguage] = React.useState<string>(
initialValue || getDefaultVoiceLanguage(currentLanguage)
);
// Update voice language when user profile language changes (only if not manually set)
React.useEffect(() => {
if (!initialValue && !hasManuallyChanged.current) {
const newDefault = getDefaultVoiceLanguage(currentLanguage);
setVoiceLanguage(newDefault);
}
}, [currentLanguage, initialValue]);
// Wrapper to track manual changes
const handleSetVoiceLanguage = React.useCallback((newLanguage: string) => {
hasManuallyChanged.current = true;
setVoiceLanguage(newLanguage);
}, []);
return {
voiceLanguage,
setVoiceLanguage: handleSetVoiceLanguage,
voiceLanguages,
};
};
export default VoiceLanguageSelect;

View file

@ -0,0 +1,8 @@
export {
VoiceLanguageSelect,
useVoiceLanguage,
getDefaultVoiceLanguage,
voiceLanguages,
type VoiceLanguageOption,
type VoiceLanguageSelectProps
} from './VoiceLanguageSelect';

View file

@ -19,3 +19,5 @@ export type { WorkflowStatusProps } from './WorkflowStatus/WorkflowStatusTypes';
export * from './AutoScroll';
export * from './Tabs';
export type { TabsProps, Tab } from './Tabs';
export * from './Toast';
export * from './VoiceLanguageSelect';

138
src/config/pageRegistry.tsx Normal file
View file

@ -0,0 +1,138 @@
/**
* 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.gdpr': <FaShieldAlt />,
'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 />,
'page.admin.user-access-overview': <FaUserShield />,
// 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.expense-import': <FaFileAlt />,
'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

@ -8,9 +8,12 @@ interface FileContextType {
refetch: () => Promise<void>;
handleFileUpload: (file: File, workflowId?: string) => Promise<{ success: boolean; fileData?: any; error?: string }>;
handleFileDelete: (fileId: string, onOptimisticDelete?: () => void) => Promise<boolean>;
handleFilePreview: (fileId: string, fileName: string, mimeType?: string) => Promise<{ success: boolean; previewUrl?: string | null; blob?: Blob; isJsonContent?: boolean; decodedContent?: string; error?: string }>;
handleFileDownload: (fileId: string, fileName: string) => Promise<void>;
uploadingFile: boolean;
deletingFiles: Set<string>;
previewingFiles: Set<string>;
downloadingFiles: Set<string>;
}
const FileContext = createContext<FileContextType | undefined>(undefined);
@ -20,9 +23,12 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
const {
handleFileUpload: hookHandleFileUpload,
handleFileDelete: hookHandleFileDelete,
handleFilePreview,
handleFileDownload,
uploadingFile,
deletingFiles,
previewingFiles
previewingFiles,
downloadingFiles
} = useFileOperations();
// Centralized file upload that updates the shared state
@ -77,9 +83,12 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
refetch,
handleFileUpload,
handleFileDelete,
handleFilePreview,
handleFileDownload,
uploadingFile,
deletingFiles,
previewingFiles
previewingFiles,
downloadingFiles
}}
>
{children}

View file

@ -0,0 +1,97 @@
import { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react';
import { ToastContainer, ToastData, ToastType } from '../components/UiComponents/Toast';
interface ToastOptions {
title: string;
message?: string;
duration?: number;
}
interface ToastContextValue {
showToast: (type: ToastType, options: ToastOptions) => void;
showSuccess: (title: string, message?: string) => void;
showError: (title: string, message?: string) => void;
showWarning: (title: string, message?: string) => void;
showInfo: (title: string, message?: string) => void;
closeToast: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
const DEFAULT_DURATION = 5000;
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<ToastData[]>([]);
const timeoutRefs = useRef<Map<string, NodeJS.Timeout>>(new Map());
const closeToast = useCallback((id: string) => {
// Clear timeout if exists
const timeout = timeoutRefs.current.get(id);
if (timeout) {
clearTimeout(timeout);
timeoutRefs.current.delete(id);
}
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
const showToast = useCallback((type: ToastType, options: ToastOptions) => {
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const duration = options.duration ?? DEFAULT_DURATION;
const newToast: ToastData = {
id,
type,
title: options.title,
message: options.message,
duration,
};
setToasts((prev) => [...prev, newToast]);
// Auto-close after duration
if (duration > 0) {
const timeout = setTimeout(() => {
closeToast(id);
}, duration);
timeoutRefs.current.set(id, timeout);
}
}, [closeToast]);
const showSuccess = useCallback((title: string, message?: string) => {
showToast('success', { title, message });
}, [showToast]);
const showError = useCallback((title: string, message?: string) => {
showToast('error', { title, message, duration: 8000 }); // Errors stay longer
}, [showToast]);
const showWarning = useCallback((title: string, message?: string) => {
showToast('warning', { title, message, duration: 6000 });
}, [showToast]);
const showInfo = useCallback((title: string, message?: string) => {
showToast('info', { title, message });
}, [showToast]);
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
timeoutRefs.current.forEach((timeout) => clearTimeout(timeout));
};
}, []);
return (
<ToastContext.Provider value={{ showToast, showSuccess, showError, showWarning, showInfo, closeToast }}>
{children}
<ToastContainer toasts={toasts} onClose={closeToast} />
</ToastContext.Provider>
);
};
export const useToast = (): ToastContextValue => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};

View file

@ -2357,8 +2357,6 @@ const PageRenderer: React.FC<PageRendererProps> = ({
</div>
</div>
{/* Message Overlay Component */}
{hookData?.MessageOverlayComponent && <hookData.MessageOverlayComponent />}
</div>
</DragDropOverlay>
);

View file

@ -3,7 +3,7 @@ import { allPageData, SidebarItem } from './data';
import { useLanguage } from '../../providers/language/LanguageContext';
import { resolveLanguageText, GenericPageData } from './pageInterface';
import { usePermissions } from '../../hooks/usePermissions';
import { FaHome, FaHatWizard, FaBriefcase, FaBuilding } from 'react-icons/fa';
import { FaHome, FaHatWizard, FaBriefcase, FaProjectDiagram } from 'react-icons/fa';
import { RiFolderSettingsFill } from 'react-icons/ri';
import { SidebarSubmenuItemData } from '../../components/Sidebar/sidebarTypes';
@ -17,25 +17,21 @@ const parentGroupConfig: Record<string, {
icon: FaHome,
defaultOrder: 1
},
'start.real-estate': {
icon: FaBuilding,
defaultOrder: 1
},
'start.trustee': {
icon: FaBriefcase,
'workflows': {
icon: FaProjectDiagram,
defaultOrder: 2
},
'trustee': {
icon: FaBriefcase,
defaultOrder: 2
},
'administration': {
icon: RiFolderSettingsFill,
defaultOrder: 3
},
'basedata': {
icon: RiFolderSettingsFill,
defaultOrder: 4
},
'admin': {
icon: FaHatWizard,
defaultOrder: 4
defaultOrder: 5
}
};

View file

@ -0,0 +1,282 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../pageInterface';
import { FaCog, FaPlus } from 'react-icons/fa';
import { useAutomations, useAutomationOperations } from '../../../../hooks/useAutomations';
// Helper function to convert attribute definitions to column config
const attributesToColumns = (attributes: any[]) => {
return attributes
.filter(attr => {
// Exclude template, placeholders and complex fields from table display
const attrNameLower = attr.name.toLowerCase();
const excludedColumns = ['template', 'placeholders', 'executionlogs', 'execution_logs'];
return !excludedColumns.includes(attrNameLower);
})
.map(attr => {
const attrNameLower = attr.name.toLowerCase();
const isDateField = attr.type === 'date' ||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
const column: any = {
key: attr.name,
label: attr.label || attr.name,
type: attr.type || 'string',
width: attr.width || 200,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
sortable: attr.sortable !== false,
filterable: isDateField ? false : (attr.filterable !== false),
searchable: attr.searchable !== false,
filterOptions: attr.filterOptions
};
// Format schedule field
if (attrNameLower === 'schedule') {
column.formatter = (value: any) => {
if (!value) return '-';
const scheduleLabels: Record<string, string> = {
'0 */4 * * *': 'Every 4 hours',
'0 22 * * *': 'Daily at 22:00',
'0 10 * * 1': 'Weekly Monday 10:00'
};
return scheduleLabels[value] || value;
};
}
// Format active field as badge
if (attrNameLower === 'active') {
column.type = 'boolean';
}
// Format placeholders as count
if (attrNameLower === 'placeholders') {
column.formatter = (value: any) => {
if (!value) return '-';
let obj;
if (typeof value === 'string') {
try {
obj = JSON.parse(value);
} catch {
return '-';
}
} else if (typeof value === 'object') {
obj = value;
} else {
return '-';
}
const count = Object.keys(obj).length;
return `${count} placeholder${count !== 1 ? 's' : ''}`;
};
}
return column;
});
};
// Hook factory function for automations data
const createAutomationsHook = () => {
return () => {
const {
automations,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchAutomationById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
} = useAutomations();
const {
handleAutomationDelete,
handleAutomationCreate,
handleAutomationUpdate,
handleAutomationExecute,
handleAutomationToggleActive,
deletingAutomations,
creatingAutomation,
executingAutomations,
deleteError,
createError,
updateError
} = useAutomationOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
// Handle single automation deletion
const handleDeleteSingle = useCallback(async (automation: any) => {
const success = await handleAutomationDelete(automation.id);
if (success) {
refetch();
}
}, [handleAutomationDelete, refetch]);
// Handle multiple automation deletion
const handleDeleteMultiple = useCallback(async (selectedAutomations: any[]) => {
const results = await Promise.all(
selectedAutomations.map(a => handleAutomationDelete(a.id))
);
const allSuccessful = results.every(result => result);
if (allSuccessful) {
refetch();
}
}, [handleAutomationDelete, refetch]);
// Wrapped create handler
const wrappedHandleAutomationCreate = useCallback(async (formData: any) => {
return await handleAutomationCreate(formData);
}, [handleAutomationCreate]);
return {
data: automations,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
// Operations
handleDelete: handleAutomationDelete,
handleDeleteMultiple,
handleAutomationCreate: wrappedHandleAutomationCreate,
handleAutomationUpdate,
handleAutomationExecute,
handleAutomationToggleActive,
// FormGenerator specific handlers
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
// Loading states
deletingAutomations,
creatingAutomation,
executingAutomations,
// Error states
deleteError,
createError,
updateError,
// Attributes and permissions
attributes,
permissions,
pagination,
columns: generatedColumns,
// Functions for EditActionButton
fetchAutomationById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
};
};
};
export const automationsPageData: GenericPageData = {
id: 'workflows-automations',
path: 'workflows/automations',
name: 'automations.title',
description: 'automations.description',
// Parent page - under 'workflows' group
parentPath: 'workflows',
// Visual
icon: FaCog,
title: 'automations.title',
subtitle: 'automations.subtitle',
// Header buttons
headerButtons: [
{
id: 'new-automation',
label: 'automations.new_button',
icon: FaPlus,
variant: 'primary',
formConfig: {
fields: [], // Fields will be generated dynamically from attributes
popupTitle: 'automations.modal.create.title',
popupSize: 'large',
createOperationName: 'handleAutomationCreate',
successMessage: 'automations.create.success',
errorMessage: 'automations.create.error'
}
}
],
// Content sections - using generic table approach
content: [
{
id: 'automations-table',
type: 'table',
tableConfig: {
hookFactory: createAutomationsHook,
// Columns are generated dynamically from attributes via hookData.columns
actionButtons: [
{
type: 'play',
title: 'automations.action.execute',
idField: 'id',
operationName: 'handleAutomationExecute',
loadingStateName: 'executingAutomations',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasExecute = hookData.permissions.update !== 'n' && hookData.permissions.view;
return { disabled: !hasExecute, message: 'No permission to execute automations' };
}
},
{
type: 'edit',
title: 'automations.action.edit',
idField: 'id',
nameField: 'label',
operationName: 'handleAutomationUpdate',
loadingStateName: 'updatingAutomations',
fetchItemFunctionName: 'fetchAutomationById',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
return { disabled: !hasUpdate, message: 'No permission to edit automations' };
}
},
{
type: 'delete',
title: 'automations.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingAutomations',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasDelete = hookData.permissions.delete !== 'n' && hookData.permissions.view;
return { disabled: !hasDelete, message: 'No permission to delete automations' };
}
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'automations-table'
}
}
],
// Page behavior
persistent: false,
preload: false,
preserveState: true,
moduleEnabled: true,
// Lifecycle hooks
onActivate: async () => {
if (import.meta.env.DEV) console.log('Automations activated');
},
onLoad: async () => {
if (import.meta.env.DEV) console.log('Automations loaded');
},
onUnload: async () => {
if (import.meta.env.DEV) console.log('Automations unloaded');
}
};

View file

@ -111,13 +111,13 @@ const createConnectionsHook = () => {
};
export const connectionsPageData: GenericPageData = {
id: 'administration-connections',
path: 'administration/connections',
id: 'basedata-connections',
path: 'basedata/connections',
name: 'connections.title',
description: 'connections.title',
// Parent page
parentPath: 'administration',
parentPath: 'basedata',
// Visual
icon: FaLink,

View file

@ -6,18 +6,18 @@ import { HiOutlineCollection } from 'react-icons/hi';
import { createDashboardHook } from '../../../../hooks/usePlayground';
export const dashboardPageData: GenericPageData = {
id: 'start-dashboard',
path: 'start/dashboard',
name: 'Dashboard',
description: 'Main dashboard with overview and quick actions',
id: 'workflows-playground',
path: 'workflows/playground',
name: 'chatPlayground.title',
description: 'chatPlayground.description',
// Parent page
parentPath: 'start',
// Parent page - now under 'workflows' group
parentPath: 'workflows',
// Visual
icon: LuTicket,
title: 'Dashboard',
subtitle: 'Welcome to your workspace',
title: 'chatPlayground.title',
subtitle: 'chatPlayground.subtitle',
// Header buttons
headerButtons: [

View file

@ -82,8 +82,7 @@ const createFilesHook = () => {
previewingFiles,
editingFiles,
downloadingFiles,
uploadingFile,
MessageOverlayComponent
uploadingFile
} = useFileOperations();
const generatedColumns = attributes && attributes.length > 0
@ -148,8 +147,6 @@ const createFilesHook = () => {
uploadingFile,
// Upload functionality for button and drag-and-drop
handleUpload,
// Message overlay component
MessageOverlayComponent,
// Attributes and permissions for dynamic column/button generation
attributes,
permissions,
@ -164,13 +161,13 @@ const createFilesHook = () => {
};
export const filesPageData: GenericPageData = {
id: 'administration-files',
path: 'administration/files',
id: 'basedata-files',
path: 'basedata/files',
name: 'files.title',
description: 'files.title',
description: 'files.description',
// Parent page
parentPath: 'administration',
// Parent page - now under 'basedata' group (formerly 'administration')
parentPath: 'basedata',
// Visual
icon: FaRegFileAlt,

View file

@ -2,6 +2,7 @@
export { dashboardPageData } from './dashboard';
export { filesPageData } from './files';
export { workflowsPageData } from './workflows';
export { automationsPageData } from './automations';
export { connectionsPageData } from './connections';
export { teamMembersPageData } from './admin/team-members';
export { promptsPageData } from './prompts';
@ -27,6 +28,7 @@ export {
import { dashboardPageData } from './dashboard';
import { filesPageData } from './files';
import { workflowsPageData } from './workflows';
import { automationsPageData } from './automations';
import { connectionsPageData } from './connections';
import { teamMembersPageData } from './admin/team-members';
import { promptsPageData } from './prompts';
@ -41,18 +43,23 @@ import { trusteePages } from './trustee';
// Array of all page data
export const allPageData = [
dashboardPageData,
// Workflows group
dashboardPageData, // Chat Playground
workflowsPageData, // Workflows list
automationsPageData, // Automations
// Basedata group
filesPageData,
workflowsPageData,
connectionsPageData,
promptsPageData,
// Other pages
connectionsPageData,
speechPageData,
settingsPageData,
pekPageData,
pekTablesPageData,
chatbotPageData,
// Trustee pages (before Administration)
// Trustee pages
...trusteePages,
// Administration pages
// Admin pages
teamMembersPageData,
mandatesPageData,
rbacRulesPageData,

View file

@ -128,13 +128,13 @@ const createPromptsHook = () => {
export const promptsPageData: GenericPageData = {
id: 'administration-prompts',
path: 'administration/prompts',
id: 'basedata-prompts',
path: 'basedata/prompts',
name: 'prompts.title',
description: 'prompts.description',
// Parent page
parentPath: 'administration',
// Parent page - now under 'basedata' group (formerly 'administration')
parentPath: 'basedata',
// Visual
icon: FaLightbulb,

View file

@ -145,13 +145,13 @@ const createWorkflowsHook = () => {
};
export const workflowsPageData: GenericPageData = {
id: 'administration-workflows',
path: 'administration/workflows',
id: 'workflows-list',
path: 'workflows/list',
name: 'workflows.title',
description: 'workflows.title',
description: 'workflows.description',
// Parent page
parentPath: 'administration',
// Parent page - now under 'workflows' group
parentPath: 'workflows',
// Visual
icon: FaProjectDiagram,

View file

@ -1,8 +1,30 @@
// Export the page management system
/**
* @deprecated This PageManager system is deprecated.
*
* New pages should be created in src/pages/ and use:
* - src/components/Navigation/MandateNavigation.tsx for navigation
* - src/App.tsx for routing
*
* Migration targets (new location):
* - workflows /workflows/list
* - automations /workflows/automations
* - playground /workflows/playground
* - prompts /basedata/prompts
* - files /basedata/files
* - connections /basedata/connections
* - chatbot /chatbot (migrate to feature)
* - pek /pek (migrate to feature)
* - speech /speech (migrate to feature)
*
* This module is kept for backward compatibility with Sidebar.tsx
* and will be fully removed in a future release.
*/
// Export the page management system (DEPRECATED)
export { default as PageManager } from './PageManager';
export { default as PageRenderer } from './PageRenderer';
export { default as SidebarProvider } from './SidebarProvider';
// Export data and interfaces
// Export data and interfaces (DEPRECATED)
export * from './data';
export * from './pageInterface';

View file

@ -251,8 +251,6 @@ export interface GenericDataHook {
dashboardTree?: any; // Dashboard log tree structure
onToggleOperationExpanded?: (operationId: string) => void;
getChildOperations?: (parentId: string | null) => string[];
// Message overlay component
MessageOverlayComponent?: () => React.ReactElement;
// Settings-specific properties
settingsData?: any; // Unified data object for settings fields
settingsFields?: Record<string, SettingsFieldConfig[]>; // Field definitions per sectionId

View file

@ -62,6 +62,7 @@ export function useDashboardInputForm() {
processDashboardLogs,
clearDashboard,
toggleOperationExpanded,
toggleRoundExpanded,
updateCurrentRound,
getChildOperations
} = useDashboardLogTree();
@ -82,7 +83,7 @@ export function useDashboardInputForm() {
useEffect(() => {
const checkPermissions = async () => {
try {
const uiPerm = await canView('UI', 'playground');
const uiPerm = await canView('UI', 'ui.system.playground');
setPlaygroundUIPermission(uiPerm);
if (uiPerm) {
@ -475,24 +476,62 @@ export function useDashboardInputForm() {
}
}
}, [workflowId, messages, fileContext, request]);
// handleFileView is a no-op because ViewActionButton's ContentPreview handles the preview internally
const handleFileView = useCallback(async (_file: WorkflowFile) => {
// The ViewActionButton component handles the preview via ContentPreview
// No additional action needed here
}, []);
const handleFileDownload = useCallback(async (file: WorkflowFile) => {
if (!file.fileId) return;
await fileContext.handleFileDownload(file.fileId, file.fileName);
}, [fileContext]);
const onInputChange = useCallback((value: string) => {
setInputValue(value);
}, []);
// Separate stop handler - only stops the workflow without sending new input
const handleStop = useCallback(async () => {
if (!workflowId) return { success: false, error: 'No workflow to stop' };
try {
const result = await stopWorkflow();
return result;
} catch (error: any) {
return { success: false, error: error.message || 'Failed to stop workflow' };
}
}, [workflowId, stopWorkflow]);
const handleSubmit = useCallback(async () => {
if (isRunning && workflowId) {
const trimmedInput = inputValue.trim();
// If running and no new input, just stop
if (isRunning && workflowId && !trimmedInput) {
try {
const result = await stopWorkflow();
if (result.success) {
resetWorkflow();
}
await stopWorkflow();
} catch (error) {
// Ignore stop errors
}
return;
}
const trimmedInput = inputValue.trim();
// If running with new input, stop first then continue with new input
if (isRunning && workflowId && trimmedInput) {
try {
// Stop the current workflow
await stopWorkflow();
// Continue below to send new input
} catch (error) {
// Ignore stop errors, try to continue anyway
}
}
// No input and not running = nothing to do
if (!trimmedInput || startingWorkflow) {
return;
}
if (!trimmedInput || startingWorkflow) {
return;
}
@ -737,7 +776,9 @@ export function useDashboardInputForm() {
inputValue,
onInputChange,
handleSubmit,
handleStop,
isSubmitting: startingWorkflow || isStopping,
isStopping,
workflowId: workflowId || undefined,
workflowStatus,
currentRound,
@ -746,6 +787,7 @@ export function useDashboardInputForm() {
logs: unifiedContentLogs || [], // Unified content logs (without operationId)
dashboardTree, // Dashboard log tree (logs with operationId)
onToggleOperationExpanded: toggleOperationExpanded,
onToggleRoundExpanded: toggleRoundExpanded,
getChildOperations,
workflowItems,
selectedWorkflowId: workflowId || selectedWorkflowId || null,
@ -767,9 +809,12 @@ export function useDashboardInputForm() {
handleFileUpload,
handleFileDelete,
handleFileRemove,
handleFileView,
uploadingFile: fileContext.uploadingFile,
deletingFiles: fileContext.deletingFiles,
previewingFiles: fileContext.previewingFiles,
downloadingFiles: fileContext.downloadingFiles,
handleFileDownload,
isFileAttachmentPopupOpen,
setIsFileAttachmentPopupOpen,
allUserFiles: fileContext.files || [],

View file

@ -9,6 +9,14 @@ interface OperationData {
latestStatus: string | null;
operationName: string | null; // Stable name from first log
latestMessage: string | null; // Latest status message that updates
roundNumber: number | null; // Track which round this operation belongs to
}
interface RoundData {
operations: Map<string, OperationData>;
rootOperations: string[];
expanded: boolean;
isCompleted: boolean;
}
interface DashboardLogTree {
@ -16,6 +24,7 @@ interface DashboardLogTree {
rootOperations: string[];
logExpandedStates: Map<string, boolean>;
currentRound: number | null;
rounds: Map<number, RoundData>;
}
export function useDashboardLogTree() {
@ -23,7 +32,8 @@ export function useDashboardLogTree() {
operations: new Map(),
rootOperations: [],
logExpandedStates: new Map(),
currentRound: null
currentRound: null,
rounds: new Map()
});
const treeRef = useRef<DashboardLogTree>(tree);
@ -42,7 +52,8 @@ export function useDashboardLogTree() {
operations: new Map(prevTree.operations),
rootOperations: [...prevTree.rootOperations],
logExpandedStates: new Map(prevTree.logExpandedStates),
currentRound: prevTree.currentRound
currentRound: prevTree.currentRound,
rounds: new Map(prevTree.rounds)
};
// Process each log
@ -53,6 +64,14 @@ export function useDashboardLogTree() {
const operationId = log.operationId;
const logId = generateLogId(log);
const logRoundNumber = (log as any).roundNumber as number | null | undefined;
// Update current round tracking
if (logRoundNumber !== null && logRoundNumber !== undefined) {
if (newTree.currentRound === null || logRoundNumber > newTree.currentRound) {
newTree.currentRound = logRoundNumber;
}
}
// Get or create operation
const existingOperation = newTree.operations.get(operationId);
@ -106,6 +125,11 @@ export function useDashboardLogTree() {
const latestStatus = log.status !== undefined && log.status !== null
? log.status
: existingOperation?.latestStatus ?? null;
// Get round number for this operation (from log or existing)
const roundNumber = logRoundNumber !== null && logRoundNumber !== undefined
? logRoundNumber
: existingOperation?.roundNumber ?? null;
// Create new operation object to ensure React detects the change
const operation: OperationData = {
@ -115,14 +139,74 @@ export function useDashboardLogTree() {
latestProgress,
latestStatus,
operationName,
latestMessage
latestMessage,
roundNumber
};
newTree.operations.set(operationId, operation);
// Add operation to its round
if (roundNumber !== null) {
if (!newTree.rounds.has(roundNumber)) {
newTree.rounds.set(roundNumber, {
operations: new Map(),
rootOperations: [],
expanded: true, // New rounds start expanded
isCompleted: false
});
}
const round = newTree.rounds.get(roundNumber)!;
round.operations.set(operationId, operation);
}
});
// Rebuild root operations list (operations without parentId)
// Use Set to ensure uniqueness, then convert back to array
// Rebuild root operations list per round
newTree.rounds.forEach((round, roundNumber) => {
const rootOpsSet = new Set<string>();
round.operations.forEach((op, opId) => {
if (op.parentId === null) {
rootOpsSet.add(opId);
} else {
// Check if parent is in a different round - then this is a root in THIS round
const parentOp = newTree.operations.get(op.parentId);
if (!parentOp || parentOp.roundNumber !== roundNumber) {
rootOpsSet.add(opId);
}
}
});
// Sort by timestamp
round.rootOperations = Array.from(rootOpsSet).sort((opIdA, opIdB) => {
const opA = round.operations.get(opIdA);
const opB = round.operations.get(opIdB);
if (!opA || !opB) return 0;
const logsA = Array.from(opA.logs.values());
const logsB = Array.from(opB.logs.values());
if (logsA.length === 0 && logsB.length === 0) return 0;
if (logsA.length === 0) return 1;
if (logsB.length === 0) return -1;
const earliestA = Math.min(...logsA.map(log => log.timestamp || 0));
const earliestB = Math.min(...logsB.map(log => log.timestamp || 0));
return earliestA - earliestB;
});
// Update completion status
const allOpsCompleted = Array.from(round.operations.values()).every(op =>
op.latestStatus === 'completed' || op.latestStatus === 'success'
);
round.isCompleted = allOpsCompleted;
// Auto-collapse completed rounds (except current)
if (round.isCompleted && roundNumber !== newTree.currentRound) {
round.expanded = false;
}
});
// Rebuild global root operations list (operations without parentId)
const rootOpsSet = new Set<string>();
newTree.operations.forEach((op, opId) => {
if (op.parentId === null) {
@ -135,18 +219,17 @@ export function useDashboardLogTree() {
const opB = newTree.operations.get(opIdB);
if (!opA || !opB) return 0;
// Get earliest log timestamp for each operation
const logsA = Array.from(opA.logs.values());
const logsB = Array.from(opB.logs.values());
if (logsA.length === 0 && logsB.length === 0) return 0;
if (logsA.length === 0) return 1; // Put operations without logs at the end
if (logsA.length === 0) return 1;
if (logsB.length === 0) return -1;
const earliestA = Math.min(...logsA.map(log => log.timestamp || 0));
const earliestB = Math.min(...logsB.map(log => log.timestamp || 0));
return earliestA - earliestB; // Ascending order (oldest first)
return earliestA - earliestB;
});
return newTree;
@ -158,7 +241,8 @@ export function useDashboardLogTree() {
operations: new Map(),
rootOperations: [],
logExpandedStates: new Map(),
currentRound: resetRound ? null : treeRef.current.currentRound
currentRound: resetRound ? null : treeRef.current.currentRound,
rounds: new Map()
});
}, []);
@ -187,13 +271,24 @@ export function useDashboardLogTree() {
const updateCurrentRound = useCallback((round: number | null) => {
setTree(prevTree => {
// Clear dashboard if round changes
// Only update current round, keep all rounds data
// Auto-collapse previous rounds when new round starts
if (prevTree.currentRound !== null && round !== null && prevTree.currentRound !== round) {
const newRounds = new Map(prevTree.rounds);
// Collapse the old current round
const oldRound = newRounds.get(prevTree.currentRound);
if (oldRound) {
newRounds.set(prevTree.currentRound, {
...oldRound,
expanded: false
});
}
return {
operations: new Map(),
rootOperations: [],
logExpandedStates: new Map(),
currentRound: round
...prevTree,
currentRound: round,
rounds: newRounds
};
}
@ -203,6 +298,26 @@ export function useDashboardLogTree() {
};
});
}, []);
const toggleRoundExpanded = useCallback((roundNumber: number) => {
setTree(prevTree => {
const round = prevTree.rounds.get(roundNumber);
if (!round) {
return prevTree;
}
const newRounds = new Map(prevTree.rounds);
newRounds.set(roundNumber, {
...round,
expanded: !round.expanded
});
return {
...prevTree,
rounds: newRounds
};
});
}, []);
const getChildOperations = useCallback((parentId: string | null): string[] => {
const currentTree = treeRef.current;
@ -231,6 +346,7 @@ export function useDashboardLogTree() {
processDashboardLogs,
clearDashboard,
toggleOperationExpanded,
toggleRoundExpanded,
updateCurrentRound,
getChildOperations
};

View file

@ -32,6 +32,10 @@ export function useWorkflowLifecycle() {
const statusRef = useRef<string>('idle');
const statusChangedFromRunningAtRef = useRef<number | null>(null);
const lastRenderedTimestampRef = useRef<number | null>(null);
// Track processed stat IDs to avoid double-counting
const processedStatIdsRef = useRef<Set<string>>(new Set());
// Track cumulative stats
const cumulativeStatsRef = useRef({ priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 });
const { startWorkflow, stopWorkflow, startingWorkflow, stoppingWorkflows } = useWorkflowOperations();
const { request } = useApiRequest();
const pollingController = useWorkflowPolling();
@ -268,21 +272,52 @@ export function useWorkflowLifecycle() {
return [...allLogs].sort(sortLogs);
});
// Process stats and keep the latest one (highest createdAt)
// Process stats - aggregate only NEW stat entries (avoid double-counting)
const statsItems = timeline.filter(item => item.type === 'stat');
if (statsItems.length > 0) {
// Sort by createdAt descending to get the latest
const sortedStats = [...statsItems].sort((a, b) => b.createdAt - a.createdAt);
const latestStatItem = sortedStats[0];
const statData = latestStatItem.item || latestStatItem;
let hasNewStats = false;
if (statData && (statData.priceUsd !== undefined || statData.processingTime !== undefined ||
statData.bytesSent !== undefined || statData.bytesReceived !== undefined)) {
statsItems.forEach(statItem => {
const statData = statItem.item || statItem;
const statId = statData?.id || statItem.id;
// Skip if already processed
if (statId && processedStatIdsRef.current.has(statId)) {
return;
}
if (statData) {
hasNewStats = true;
// Mark as processed
if (statId) {
processedStatIdsRef.current.add(statId);
}
// Add to cumulative stats
if (statData.priceUsd !== undefined && statData.priceUsd !== null) {
cumulativeStatsRef.current.priceUsd += statData.priceUsd;
}
if (statData.processingTime !== undefined && statData.processingTime !== null) {
cumulativeStatsRef.current.processingTime += statData.processingTime;
}
if (statData.bytesSent !== undefined && statData.bytesSent !== null) {
cumulativeStatsRef.current.bytesSent += statData.bytesSent;
}
if (statData.bytesReceived !== undefined && statData.bytesReceived !== null) {
cumulativeStatsRef.current.bytesReceived += statData.bytesReceived;
}
}
});
// Update state with cumulative totals
if (hasNewStats || (cumulativeStatsRef.current.bytesSent > 0 || cumulativeStatsRef.current.bytesReceived > 0 ||
cumulativeStatsRef.current.processingTime > 0 || cumulativeStatsRef.current.priceUsd > 0)) {
setLatestStats({
priceUsd: statData.priceUsd,
processingTime: statData.processingTime,
bytesSent: statData.bytesSent,
bytesReceived: statData.bytesReceived
priceUsd: cumulativeStatsRef.current.priceUsd,
processingTime: cumulativeStatsRef.current.processingTime,
bytesSent: cumulativeStatsRef.current.bytesSent,
bytesReceived: cumulativeStatsRef.current.bytesReceived
});
}
}
@ -366,6 +401,9 @@ export function useWorkflowLifecycle() {
setDashboardLogs([]);
setUnifiedContentLogs([]);
setLatestStats(null);
// Reset stats tracking
processedStatIdsRef.current.clear();
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
return;
}
@ -426,6 +464,9 @@ export function useWorkflowLifecycle() {
setDashboardLogs(prev => prev.length > 0 ? [] : prev);
setUnifiedContentLogs(prev => prev.length > 0 ? [] : prev);
setLatestStats(null);
// Reset stats tracking
processedStatIdsRef.current.clear();
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
setCurrentRound(prev => prev !== undefined ? undefined : prev);
if (statusChangedFromRunningAt !== null) {
setStatusChangedFromRunningAt(null);
@ -516,6 +557,9 @@ export function useWorkflowLifecycle() {
updateWorkflowStatus('idle');
setCurrentRound(undefined);
setLatestStats(null);
// Reset stats tracking
processedStatIdsRef.current.clear();
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
setStatusChangedFromRunningAt(null);
statusChangedFromRunningAtRef.current = null;
lastRenderedTimestampRef.current = null;
@ -525,8 +569,10 @@ export function useWorkflowLifecycle() {
const selectWorkflow = useCallback(async (workflowIdToSelect: string) => {
try {
setWorkflowId(workflowIdToSelect);
// Reset lastRenderedTimestamp for new workflow selection
// Reset lastRenderedTimestamp and stats for new workflow selection
lastRenderedTimestampRef.current = null;
processedStatIdsRef.current.clear();
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
const workflowData = await fetchWorkflowApi(request, workflowIdToSelect).catch(() => null);

243
src/hooks/useAccessRules.ts Normal file
View file

@ -0,0 +1,243 @@
/**
* useAccessRules Hook
*
* Hook for managing RBAC access rules for a role.
* Supports both system admin (template roles) and feature admin (instance roles).
*/
import { useState, useCallback } from 'react';
import api from '../api';
// =============================================================================
// TYPES
// =============================================================================
export type RuleContext = 'DATA' | 'UI' | 'RESOURCE';
export type AccessLevel = 'n' | 'm' | 'g' | 'a' | null;
// =============================================================================
// ACCESS LEVEL LABELS
// =============================================================================
export const ACCESS_LEVEL_OPTIONS: { value: 'n' | 'm' | 'g' | 'a'; label: string; color: string }[] = [
{ value: 'n', label: 'Keine', color: '#e53e3e' },
{ value: 'm', label: 'Eigene', color: '#d69e2e' },
{ value: 'g', label: 'Gruppe', color: '#3182ce' },
{ value: 'a', label: 'Alle', color: '#38a169' },
];
export const getAccessLevelLabel = (level: AccessLevel | null): string => {
if (!level) return '-';
const option = ACCESS_LEVEL_OPTIONS.find(opt => opt.value === level);
return option?.label || level;
};
export const getAccessLevelColor = (level: AccessLevel | null): string => {
if (!level) return '#718096';
const option = ACCESS_LEVEL_OPTIONS.find(opt => opt.value === level);
return option?.color || '#718096';
};
export interface AccessRule {
id: string;
roleId: string;
context: RuleContext;
item: string | null;
view: boolean;
read: AccessLevel;
create: AccessLevel;
update: AccessLevel;
delete: AccessLevel;
}
export interface AccessRuleCreate {
context: RuleContext;
item?: string | null;
view?: boolean;
read?: AccessLevel;
create?: AccessLevel;
update?: AccessLevel;
delete?: AccessLevel;
}
interface GroupedRules {
DATA: AccessRule[];
UI: AccessRule[];
RESOURCE: AccessRule[];
}
interface SaveResult {
success: boolean;
error?: string;
}
// =============================================================================
// HOOK
// =============================================================================
export function useAccessRules(roleId: string, apiBasePath: string = '/api/rbac') {
const [rules, setRules] = useState<AccessRule[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Determine if this is a feature-instance API path
const isInstanceApi = apiBasePath.includes('/instance-roles/');
/**
* Fetch all rules for the role
*/
const fetchRules = useCallback(async (): Promise<AccessRule[]> => {
setLoading(true);
setError(null);
try {
// Different endpoint structure for instance roles vs system roles
const endpoint = isInstanceApi
? `${apiBasePath}/rules`
: `${apiBasePath}/rules/by-role/${roleId}`;
const response = await api.get(endpoint);
const fetchedRules = response.data?.items || response.data || [];
setRules(fetchedRules);
return fetchedRules;
} catch (err: any) {
const errorMsg = err.response?.data?.detail || err.message || 'Fehler beim Laden der Regeln';
setError(errorMsg);
console.error('Error fetching rules:', err);
return [];
} finally {
setLoading(false);
}
}, [roleId, apiBasePath, isInstanceApi]);
/**
* Save all rules for the role
*/
const saveRules = useCallback(async (rulesToSave: AccessRule[]): Promise<SaveResult> => {
setSaving(true);
setError(null);
try {
// Different endpoint structure for instance roles vs system roles
const rulesEndpoint = isInstanceApi
? `${apiBasePath}/rules`
: `${apiBasePath}/rules/by-role/${roleId}`;
// Get current rules from server
const currentResponse = await api.get(rulesEndpoint);
const currentRules: AccessRule[] = currentResponse.data?.items || currentResponse.data || [];
const currentRuleIds = new Set(currentRules.map(r => r.id));
// Determine changes
const newRules = rulesToSave.filter(r => r.id.startsWith('temp-'));
const existingRules = rulesToSave.filter(r => !r.id.startsWith('temp-'));
const deletedRuleIds = [...currentRuleIds].filter(
id => !existingRules.some(r => r.id === id)
);
// Delete removed rules
for (const deletedId of deletedRuleIds) {
const deleteEndpoint = isInstanceApi
? `${apiBasePath}/rules/${deletedId}`
: `${apiBasePath}/rules/${deletedId}`;
await api.delete(deleteEndpoint);
}
// Create new rules
for (const rule of newRules) {
const createEndpoint = isInstanceApi
? `${apiBasePath}/rules`
: `${apiBasePath}/rules`;
await api.post(createEndpoint, {
roleId,
context: rule.context,
item: rule.item,
view: rule.view,
read: rule.read,
create: rule.create,
update: rule.update,
delete: rule.delete,
});
}
// Update existing rules
for (const rule of existingRules) {
const original = currentRules.find(r => r.id === rule.id);
if (original && JSON.stringify(original) !== JSON.stringify(rule)) {
const updateEndpoint = isInstanceApi
? `${apiBasePath}/rules/${rule.id}`
: `${apiBasePath}/rules/${rule.id}`;
await api.put(updateEndpoint, {
view: rule.view,
read: rule.read,
create: rule.create,
update: rule.update,
delete: rule.delete,
});
}
}
// Refresh rules
await fetchRules();
return { success: true };
} catch (err: any) {
const errorMsg = err.response?.data?.detail || err.message || 'Fehler beim Speichern';
setError(errorMsg);
console.error('Error saving rules:', err);
return { success: false, error: errorMsg };
} finally {
setSaving(false);
}
}, [roleId, apiBasePath, isInstanceApi, fetchRules]);
/**
* Get rules grouped by context
*/
const getGroupedRules = useCallback((): GroupedRules => {
return {
DATA: rules.filter(r => r.context === 'DATA'),
UI: rules.filter(r => r.context === 'UI'),
RESOURCE: rules.filter(r => r.context === 'RESOURCE'),
};
}, [rules]);
/**
* Update a rule locally (not saved until saveRules is called)
*/
const updateRuleLocally = useCallback((ruleId: string, updates: Partial<AccessRule>) => {
setRules(prev => prev.map(r =>
r.id === ruleId ? { ...r, ...updates } : r
));
}, []);
/**
* Add a rule locally (not saved until saveRules is called)
*/
const addRuleLocally = useCallback((rule: AccessRule) => {
setRules(prev => [...prev, rule]);
}, []);
/**
* Remove a rule locally (not saved until saveRules is called)
*/
const removeRuleLocally = useCallback((ruleId: string) => {
setRules(prev => prev.filter(r => r.id !== ruleId));
}, []);
return {
rules,
loading,
saving,
error,
fetchRules,
saveRules,
getGroupedRules,
updateRuleLocally,
addRuleLocally,
removeRuleLocally,
};
}
export default useAccessRules;

View file

@ -0,0 +1,254 @@
/**
* useAccessRules Hook
*
* Hook for managing RBAC access rules for a role.
* Supports both system admin (template roles) and feature admin (instance roles).
*/
import { useState, useCallback } from 'react';
import api from '../api';
// =============================================================================
// TYPES
// =============================================================================
export type RuleContext = 'DATA' | 'UI' | 'RESOURCE';
export type AccessLevel = 'n' | 'm' | 'g' | 'a' | null;
// =============================================================================
// ACCESS LEVEL LABELS
// =============================================================================
export const ACCESS_LEVEL_OPTIONS: { value: 'n' | 'm' | 'g' | 'a'; label: string; color: string }[] = [
{ value: 'n', label: 'Keine', color: '#e53e3e' },
{ value: 'm', label: 'Eigene', color: '#d69e2e' },
{ value: 'g', label: 'Gruppe', color: '#3182ce' },
{ value: 'a', label: 'Alle', color: '#38a169' },
];
export const getAccessLevelLabel = (level: AccessLevel | null): string => {
if (!level) return '-';
const option = ACCESS_LEVEL_OPTIONS.find(opt => opt.value === level);
return option?.label || level;
};
export const getAccessLevelColor = (level: AccessLevel | null): string => {
if (!level) return '#718096';
const option = ACCESS_LEVEL_OPTIONS.find(opt => opt.value === level);
return option?.color || '#718096';
};
export interface AccessRule {
id: string;
roleId: string;
context: RuleContext;
item: string | null;
view: boolean;
read: AccessLevel;
create: AccessLevel;
update: AccessLevel;
delete: AccessLevel;
}
export interface AccessRuleCreate {
context: RuleContext;
item?: string | null;
view?: boolean;
read?: AccessLevel;
create?: AccessLevel;
update?: AccessLevel;
delete?: AccessLevel;
}
interface GroupedRules {
DATA: AccessRule[];
UI: AccessRule[];
RESOURCE: AccessRule[];
}
interface SaveResult {
success: boolean;
error?: string;
}
// =============================================================================
// HOOK
// =============================================================================
export function useAccessRules(roleId: string, apiBasePath: string = '/api/rbac', mandateId?: string) {
const [rules, setRules] = useState<AccessRule[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Determine if this is a feature-instance API path
const isInstanceApi = apiBasePath.includes('/instance-roles/');
// Build headers with optional mandate ID
const getHeaders = useCallback(() => {
const headers: Record<string, string> = {};
if (mandateId) {
headers['X-Mandate-Id'] = mandateId;
}
return headers;
}, [mandateId]);
/**
* Fetch all rules for the role
*/
const fetchRules = useCallback(async (): Promise<AccessRule[]> => {
setLoading(true);
setError(null);
try {
// Different endpoint structure for instance roles vs system roles
const endpoint = isInstanceApi
? `${apiBasePath}/rules`
: `${apiBasePath}/rules/by-role/${roleId}`;
const response = await api.get(endpoint, { headers: getHeaders() });
const fetchedRules = response.data?.items || response.data || [];
setRules(fetchedRules);
return fetchedRules;
} catch (err: any) {
const errorMsg = err.response?.data?.detail || err.message || 'Fehler beim Laden der Regeln';
setError(errorMsg);
console.error('Error fetching rules:', err);
return [];
} finally {
setLoading(false);
}
}, [roleId, apiBasePath, isInstanceApi, getHeaders]);
/**
* Save all rules for the role
*/
const saveRules = useCallback(async (rulesToSave: AccessRule[]): Promise<SaveResult> => {
setSaving(true);
setError(null);
try {
const headers = getHeaders();
// Different endpoint structure for instance roles vs system roles
const rulesEndpoint = isInstanceApi
? `${apiBasePath}/rules`
: `${apiBasePath}/rules/by-role/${roleId}`;
// Get current rules from server
const currentResponse = await api.get(rulesEndpoint, { headers });
const currentRules: AccessRule[] = currentResponse.data?.items || currentResponse.data || [];
const currentRuleIds = new Set(currentRules.map(r => r.id));
// Determine changes
const newRules = rulesToSave.filter(r => r.id.startsWith('temp-'));
const existingRules = rulesToSave.filter(r => !r.id.startsWith('temp-'));
const deletedRuleIds = [...currentRuleIds].filter(
id => !existingRules.some(r => r.id === id)
);
// Delete removed rules
for (const deletedId of deletedRuleIds) {
const deleteEndpoint = isInstanceApi
? `${apiBasePath}/rules/${deletedId}`
: `${apiBasePath}/rules/${deletedId}`;
await api.delete(deleteEndpoint, { headers });
}
// Create new rules
for (const rule of newRules) {
const createEndpoint = isInstanceApi
? `${apiBasePath}/rules`
: `${apiBasePath}/rules`;
await api.post(createEndpoint, {
roleId,
context: rule.context,
item: rule.item,
view: rule.view,
read: rule.read,
create: rule.create,
update: rule.update,
delete: rule.delete,
}, { headers });
}
// Update existing rules
for (const rule of existingRules) {
const original = currentRules.find(r => r.id === rule.id);
if (original && JSON.stringify(original) !== JSON.stringify(rule)) {
const updateEndpoint = isInstanceApi
? `${apiBasePath}/rules/${rule.id}`
: `${apiBasePath}/rules/${rule.id}`;
await api.put(updateEndpoint, {
view: rule.view,
read: rule.read,
create: rule.create,
update: rule.update,
delete: rule.delete,
}, { headers });
}
}
// Refresh rules
await fetchRules();
return { success: true };
} catch (err: any) {
const errorMsg = err.response?.data?.detail || err.message || 'Fehler beim Speichern';
setError(errorMsg);
console.error('Error saving rules:', err);
return { success: false, error: errorMsg };
} finally {
setSaving(false);
}
}, [roleId, apiBasePath, isInstanceApi, fetchRules, getHeaders]);
/**
* Get rules grouped by context
*/
const getGroupedRules = useCallback((): GroupedRules => {
return {
DATA: rules.filter(r => r.context === 'DATA'),
UI: rules.filter(r => r.context === 'UI'),
RESOURCE: rules.filter(r => r.context === 'RESOURCE'),
};
}, [rules]);
/**
* Update a rule locally (not saved until saveRules is called)
*/
const updateRuleLocally = useCallback((ruleId: string, updates: Partial<AccessRule>) => {
setRules(prev => prev.map(r =>
r.id === ruleId ? { ...r, ...updates } : r
));
}, []);
/**
* Add a rule locally (not saved until saveRules is called)
*/
const addRuleLocally = useCallback((rule: AccessRule) => {
setRules(prev => [...prev, rule]);
}, []);
/**
* Remove a rule locally (not saved until saveRules is called)
*/
const removeRuleLocally = useCallback((ruleId: string) => {
setRules(prev => prev.filter(r => r.id !== ruleId));
}, []);
return {
rules,
loading,
saving,
error,
fetchRules,
saveRules,
getGroupedRules,
updateRuleLocally,
addRuleLocally,
removeRuleLocally,
};
}
export default useAccessRules;

448
src/hooks/useAutomations.ts Normal file
View file

@ -0,0 +1,448 @@
import { useState, useCallback } from 'react';
import { useApiRequest } from './useApi';
import api from '../api';
import { usePermissions, type UserPermissions } from './usePermissions';
import {
fetchAutomations as fetchAutomationsApi,
fetchAutomation as fetchAutomationApi,
createAutomationApi,
updateAutomationApi,
deleteAutomationApi,
executeAutomationApi,
fetchAutomationTemplates as fetchTemplatesApi,
type Automation,
type AutomationTemplate,
type CreateAutomationRequest,
type UpdateAutomationRequest
} from '../api/automationApi';
// Re-export types
export type { Automation, AutomationTemplate, CreateAutomationRequest, UpdateAutomationRequest };
// Attribute definition interface
export interface AttributeDefinition {
name: string;
type: 'text' | 'email' | 'date' | 'checkbox' | 'select' | 'multiselect' | 'number' | 'textarea';
label: string;
description?: string;
required?: boolean;
default?: any;
options?: Array<{ value: string | number; label: string | { [key: string]: string } }> | string;
validation?: any;
readonly?: boolean;
editable?: boolean;
visible?: boolean;
order?: number;
sortable?: boolean;
filterable?: boolean;
searchable?: boolean;
width?: number;
minWidth?: number;
maxWidth?: number;
filterOptions?: string[];
}
// Automations list hook
export function useAutomations() {
const [automations, setAutomations] = useState<Automation[]>([]);
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
const [pagination, setPagination] = useState<{
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
} | null>(null);
const { request, isLoading: loading, error } = useApiRequest<null, Automation[]>();
const { checkPermission } = usePermissions();
// Fetch attributes from backend - no fallback, errors should be visible
const fetchAttributes = useCallback(async () => {
try {
const response = await api.get('/api/automations/attributes');
let attrs: AttributeDefinition[] = [];
// Backend returns: { attributes: { model: "...", attributes: [...] } }
// So we need to access response.data.attributes.attributes
if (response.data?.attributes?.attributes && Array.isArray(response.data.attributes.attributes)) {
attrs = response.data.attributes.attributes;
} else if (response.data?.attributes && Array.isArray(response.data.attributes)) {
// Fallback: if attributes is directly an array
attrs = response.data.attributes;
} else if (Array.isArray(response.data)) {
attrs = response.data;
}
if (attrs.length === 0) {
console.warn('No attributes returned from backend for AutomationDefinition');
}
setAttributes(attrs);
return attrs;
} catch (error: any) {
console.error('Error fetching automation attributes:', error);
setAttributes([]);
return [];
}
}, []);
// Fetch permissions from backend
const fetchPermissions = useCallback(async () => {
try {
const perms = await checkPermission('DATA', 'AutomationDefinition');
setPermissions(perms);
return perms;
} catch (error: any) {
console.error('Error fetching automation permissions:', error);
const defaultPerms: UserPermissions = {
view: false,
read: 'n',
create: 'n',
update: 'n',
delete: 'n',
};
setPermissions(defaultPerms);
return defaultPerms;
}
}, [checkPermission]);
const fetchAutomations = useCallback(async () => {
try {
const data = await fetchAutomationsApi(request);
// Handle paginated response
if (data && typeof data === 'object' && 'items' in data) {
const items = Array.isArray((data as any).items) ? (data as any).items : [];
setAutomations(items);
if ((data as any).pagination) {
setPagination((data as any).pagination);
}
} else {
const items = Array.isArray(data) ? data : [];
setAutomations(items);
setPagination(null);
}
} catch (error: any) {
setAutomations([]);
setPagination(null);
}
}, [request]);
// Optimistically remove an automation from the local state
const removeOptimistically = (automationId: string) => {
setAutomations(prev => prev.filter(a => a.id !== automationId));
};
// Optimistically update an automation in the local state
const updateOptimistically = (automationId: string, updateData: Partial<Automation>) => {
setAutomations(prev =>
prev.map(a => a.id === automationId ? { ...a, ...updateData } : a)
);
};
// Fetch a single automation by ID
const fetchAutomationById = useCallback(async (automationId: string): Promise<Automation | null> => {
try {
return await fetchAutomationApi(request, automationId);
} catch (error) {
console.error('Error fetching automation by ID:', error);
return null;
}
}, [request]);
// Generate edit fields from attributes dynamically
const generateEditFieldsFromAttributes = useCallback((): Array<{
key: string;
label: string;
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
editable?: boolean;
required?: boolean;
minRows?: number;
maxRows?: number;
options?: Array<{ value: string | number; label: string }>;
}> => {
if (!attributes || attributes.length === 0) {
return [];
}
// Fields to show in edit form
const editableFields = ['label', 'schedule', 'template', 'placeholders', 'active'];
return attributes
.filter(attr => editableFields.includes(attr.name) && attr.editable !== false)
.map(attr => {
let fieldType: 'string' | 'boolean' | 'textarea' | 'enum' | 'readonly' = 'string';
if (attr.type === 'checkbox') {
fieldType = 'boolean';
} else if (attr.type === 'textarea' || attr.name === 'template' || attr.name === 'placeholders') {
fieldType = 'textarea';
} else if (attr.type === 'select' && attr.options) {
fieldType = 'enum';
}
const field: any = {
key: attr.name,
label: attr.label || attr.name,
type: fieldType,
editable: attr.editable !== false,
required: attr.required || false,
};
if (fieldType === 'textarea') {
field.minRows = 3;
field.maxRows = 15;
}
if (fieldType === 'enum' && attr.options) {
field.options = Array.isArray(attr.options)
? attr.options.map(opt => ({
value: typeof opt === 'object' ? opt.value : opt,
label: typeof opt === 'object'
? (typeof opt.label === 'object' ? opt.label['en'] || opt.label['de'] : opt.label)
: opt
}))
: [];
}
return field;
});
}, [attributes]);
// Generate create fields from attributes
const generateCreateFieldsFromAttributes = useCallback(() => {
return generateEditFieldsFromAttributes();
}, [generateEditFieldsFromAttributes]);
// Ensure attributes are loaded
const ensureAttributesLoaded = useCallback(async () => {
if (attributes.length === 0) {
await fetchAttributes();
}
}, [attributes.length, fetchAttributes]);
// Initial data fetch
const refetch = useCallback(async () => {
await Promise.all([
fetchAutomations(),
fetchAttributes(),
fetchPermissions()
]);
}, [fetchAutomations, fetchAttributes, fetchPermissions]);
return {
automations,
data: automations, // Alias for FormGenerator compatibility
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchAutomationById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
};
}
// Automation operations hook
export function useAutomationOperations() {
const { request } = useApiRequest();
const [deletingAutomations, setDeletingAutomations] = useState<Set<string>>(new Set());
const [creatingAutomation, setCreatingAutomation] = useState(false);
const [executingAutomations, setExecutingAutomations] = useState<Set<string>>(new Set());
const [deleteError, setDeleteError] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
const [updateError, setUpdateError] = useState<string | null>(null);
// Create a new automation
const handleAutomationCreate = useCallback(async (data: CreateAutomationRequest): Promise<Automation | null> => {
setCreatingAutomation(true);
setCreateError(null);
try {
// Validate required fields - mandateId and featureInstanceId must be provided
if (!data.mandateId || !data.featureInstanceId) {
throw new Error('mandateId and featureInstanceId are required');
}
// Convert placeholders to ensure all values are strings
if (data.placeholders) {
const convertedPlaceholders: Record<string, string> = {};
for (const [key, value] of Object.entries(data.placeholders)) {
if (value === null || value === undefined) {
convertedPlaceholders[key] = '';
} else if (typeof value === 'object') {
convertedPlaceholders[key] = JSON.stringify(value);
} else {
convertedPlaceholders[key] = String(value);
}
}
data.placeholders = convertedPlaceholders;
}
const newAutomation = await createAutomationApi(request, data);
return newAutomation;
} catch (error: any) {
console.error('Error creating automation:', error);
setCreateError(error.message || 'Failed to create automation');
return null;
} finally {
setCreatingAutomation(false);
}
}, [request]);
// Update an existing automation
const handleAutomationUpdate = useCallback(async (
automationId: string,
data: UpdateAutomationRequest
): Promise<boolean> => {
setUpdateError(null);
try {
await updateAutomationApi(request, automationId, data);
return true;
} catch (error: any) {
console.error('Error updating automation:', error);
setUpdateError(error.message || 'Failed to update automation');
return false;
}
}, [request]);
// Delete an automation
const handleAutomationDelete = useCallback(async (automationId: string): Promise<boolean> => {
setDeletingAutomations(prev => new Set(prev).add(automationId));
setDeleteError(null);
try {
await deleteAutomationApi(request, automationId);
return true;
} catch (error: any) {
console.error('Error deleting automation:', error);
setDeleteError(error.message || 'Failed to delete automation');
return false;
} finally {
setDeletingAutomations(prev => {
const newSet = new Set(prev);
newSet.delete(automationId);
return newSet;
});
}
}, [request]);
// Execute an automation
const handleAutomationExecute = useCallback(async (automationId: string): Promise<any> => {
setExecutingAutomations(prev => new Set(prev).add(automationId));
try {
const result = await executeAutomationApi(request, automationId);
return result;
} catch (error: any) {
console.error('Error executing automation:', error);
throw error;
} finally {
setExecutingAutomations(prev => {
const newSet = new Set(prev);
newSet.delete(automationId);
return newSet;
});
}
}, [request]);
// Toggle automation active status
// NOTE: Backend PUT expects full AutomationDefinition object including id
const handleAutomationToggleActive = useCallback(async (
automationId: string,
currentActive: boolean,
fullAutomation?: Automation
): Promise<boolean> => {
try {
// Build full update data - backend expects AutomationDefinition with all fields
const sourceAutomation = fullAutomation || await fetchAutomationApi(request, automationId);
// Backend requires id in body to match URL parameter
const updateData = {
id: automationId, // MUST match URL parameter
mandateId: sourceAutomation.mandateId,
featureInstanceId: sourceAutomation.featureInstanceId,
label: sourceAutomation.label,
schedule: sourceAutomation.schedule,
template: typeof sourceAutomation.template === 'object'
? JSON.stringify(sourceAutomation.template)
: sourceAutomation.template,
placeholders: sourceAutomation.placeholders || {},
active: !currentActive
};
await updateAutomationApi(request, automationId, updateData as any);
return true;
} catch (error: any) {
console.error('Error toggling automation active status:', error);
return false;
}
}, [request]);
// Generic inline update handler for FormGeneratorTable
// NOTE: Backend PUT requires full object, so we merge changes with existing row data
const handleInlineUpdate = useCallback(async (
automationId: string,
changes: Partial<Automation>,
existingRow?: Automation
) => {
if (!existingRow) {
throw new Error('Existing row data required for inline update');
}
try {
// Merge changes with existing row data and send all required fields
const updateData = {
id: automationId, // MUST match URL parameter
mandateId: existingRow.mandateId,
featureInstanceId: existingRow.featureInstanceId,
label: existingRow.label,
schedule: existingRow.schedule,
template: typeof existingRow.template === 'object'
? JSON.stringify(existingRow.template)
: existingRow.template,
placeholders: existingRow.placeholders || {},
// Apply the changes (e.g., active: true/false)
...changes
};
await updateAutomationApi(request, automationId, updateData as any);
return { success: true };
} catch (error: any) {
console.error('Error in inline update:', error);
throw new Error(error.message || 'Failed to update');
}
}, [request]);
// Fetch templates
const fetchTemplates = useCallback(async (): Promise<AutomationTemplate[]> => {
try {
return await fetchTemplatesApi(request);
} catch (error: any) {
console.error('Error fetching templates:', error);
return [];
}
}, [request]);
return {
handleAutomationCreate,
handleAutomationUpdate,
handleAutomationDelete,
handleAutomationExecute,
handleAutomationToggleActive,
handleInlineUpdate,
fetchTemplates,
deletingAutomations,
creatingAutomation,
executingAutomations,
deleteError,
createError,
updateError
};
}

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

@ -548,9 +548,48 @@ export function useConnections() {
fetchConnections();
}, [fetchConnections]);
// Optimistically update a connection in local state
const updateOptimistically = useCallback((connectionId: string, updateData: Partial<Connection>) => {
setConnections(prev =>
prev.map(conn => conn.id === connectionId ? { ...conn, ...updateData } : conn)
);
}, []);
// Generic inline update handler for FormGeneratorTable
const handleInlineUpdate = useCallback(async (connectionId: string, changes: Partial<Connection>, existingRow?: any) => {
if (!existingRow) {
throw new Error('Existing row data required for inline update');
}
try {
const result = await updateConnection(connectionId, changes);
return { success: true, data: result };
} catch (error: any) {
throw new Error(error.message || 'Failed to update');
}
}, [updateConnection]);
// Fetch connection by ID
const fetchConnectionById = useCallback(async (connectionId: string): Promise<Connection | null> => {
try {
// Since there's no individual connection endpoint, find from current list or fetch all
const existing = connections.find(c => c.id === connectionId);
if (existing) return existing;
const data = await fetchConnectionsApi(request);
const items = Array.isArray(data) ? data : (data?.items || []);
return items.find((c: Connection) => c.id === connectionId) || null;
} catch (error) {
console.error('Error fetching connection by ID:', error);
return null;
}
}, [connections, request]);
return {
connections,
data: connections, // Alias for FormGenerator compatibility
fetchConnections,
refetch: fetchConnections, // Alias for FormGenerator compatibility
createConnection,
updateConnection,
connectService,
@ -562,6 +601,7 @@ export function useConnections() {
createGoogleConnectionAndAuth,
createMicrosoftConnectionAndAuth,
isLoading,
loading: isLoading, // Alias for FormGenerator compatibility
isConnecting,
error: error || connectError,
// Attributes and permissions for dynamic column/button generation
@ -571,7 +611,11 @@ export function useConnections() {
generateEditFieldsFromAttributes,
ensureAttributesLoaded,
fetchAttributes,
fetchPermissions
fetchPermissions,
// Additional methods for FormGenerator
updateOptimistically,
handleInlineUpdate,
fetchConnectionById
};
}

View file

@ -0,0 +1,133 @@
/**
* useCurrentInstance Hook
*
* Liest die aktuelle Feature-Instanz aus den URL-Parametern.
* Die URL-Struktur ist: /mandates/:mandateId/:featureCode/:instanceId/...
*
* Dieser Hook ist die zentrale Stelle um den aktuellen Arbeitskontext zu ermitteln.
*/
import { useParams } from 'react-router-dom';
import { useFeatureStore } from '../stores/featureStore';
import type { FeatureInstance, Mandate, MandateFeature } from '../types/mandate';
// =============================================================================
// URL PARAMETER TYPES
// =============================================================================
// Route-Parameter werden als Record<string, string | undefined> erwartet
// Wir verwenden daher einen einfachen Typ-Alias
// =============================================================================
// RETURN TYPES
// =============================================================================
export interface CurrentInstanceContext {
// Aus URL
mandateId: string | undefined;
featureCode: string | undefined;
instanceId: string | undefined;
// Aufgelöste Objekte
mandate: Mandate | undefined;
feature: MandateFeature | undefined;
instance: FeatureInstance | undefined;
// Hilfsfunktionen
isValid: boolean;
isLoading: boolean;
}
// =============================================================================
// HOOKS
// =============================================================================
/**
* Haupthook für den aktuellen Instanz-Kontext
*
* Verwendung:
* ```tsx
* function ContractList() {
* const { instance, isValid } = useCurrentInstance();
*
* if (!isValid) {
* return <Navigate to="/" />;
* }
*
* // Arbeite mit instance.permissions, etc.
* }
* ```
*/
export function useCurrentInstance(): CurrentInstanceContext {
const params = useParams();
const { getMandateById, getFeatureByCode, getInstanceById, loading } = useFeatureStore();
const mandateId = params.mandateId;
const featureCode = params.featureCode;
const instanceId = params.instanceId;
// Objekte auflösen
const mandate = mandateId ? getMandateById(mandateId) : undefined;
const feature = mandateId && featureCode ? getFeatureByCode(mandateId, featureCode) : undefined;
const instance = instanceId ? getInstanceById(instanceId) : undefined;
// Validierung: Alle drei müssen vorhanden und konsistent sein
const isValid = !!(
mandate &&
feature &&
instance &&
instance.mandateId === mandateId &&
instance.featureCode === featureCode
);
return {
mandateId,
featureCode,
instanceId,
mandate,
feature,
instance,
isValid,
isLoading: loading,
};
}
/**
* Vereinfachter Hook - gibt nur die Instanz zurück
*/
export function useInstance(): FeatureInstance | undefined {
const { instance } = useCurrentInstance();
return instance;
}
/**
* Hook für die Instanz-ID aus der URL
*/
export function useInstanceId(): string | undefined {
const params = useParams();
return params.instanceId;
}
/**
* Hook für den Feature-Code aus der URL
*/
export function useFeatureCode(): string | undefined {
const params = useParams();
return params.featureCode;
}
/**
* Hook für die Mandate-ID aus der URL
*/
export function useMandateId(): string | undefined {
const params = useParams();
return params.mandateId;
}
/**
* Hook der prüft ob wir in einem Feature-Kontext sind
*/
export function useIsInFeatureContext(): boolean {
const { isValid } = useCurrentInstance();
return isValid;
}

View file

@ -0,0 +1,499 @@
/**
* useFeatureAccess Hook
*
* Hook for managing feature instance access (which users can access which feature instances with which roles).
* Uses the /api/features endpoints.
*/
import { useState, useCallback, useRef } from 'react';
import api from '../api';
// Types
export interface PaginationParams {
page?: number;
pageSize?: number;
search?: string;
filters?: Record<string, any>;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
}
export interface PaginationMetadata {
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
}
export interface Feature {
code: string;
label: string | { [key: string]: string };
icon?: string;
enabled?: boolean;
}
export interface FeatureInstance {
id: string;
featureCode: string;
mandateId: string;
label: string;
enabled: boolean;
}
export interface FeatureAccess {
id: string;
userId: string;
featureInstanceId: string;
enabled: boolean;
roleIds?: string[];
}
export interface FeatureAccessUser {
id: string; // FeatureAccess ID as primary key
userId: string;
username: string;
email?: string;
fullName?: string;
roleIds: string[];
roleLabels: string[];
enabled: boolean;
}
export interface FeatureInstanceRole {
id: string;
roleLabel: string;
description?: { [key: string]: string };
featureCode?: string;
isSystemRole?: boolean;
}
export interface AddUserToInstanceRequest {
userId: string;
roleIds: string[];
}
export interface FeatureInstanceCreate {
featureCode: string;
label: string;
enabled?: boolean;
copyTemplateRoles?: boolean;
}
/**
* Hook for managing feature access
*/
export function useFeatureAccess() {
const [features, setFeatures] = useState<Feature[]>([]);
const [instances, setInstances] = useState<FeatureInstance[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [instancesPagination, setInstancesPagination] = useState<PaginationMetadata | null>(null);
// Store current context for refetch
const currentMandateIdRef = useRef<string>('');
const currentFeatureCodeRef = useRef<string | undefined>(undefined);
/**
* Fetch all available features
*/
const fetchFeatures = useCallback(async (): Promise<Feature[]> => {
setLoading(true);
setError(null);
try {
const response = await api.get('/api/features/');
const data = Array.isArray(response.data) ? response.data : [];
setFeatures(data);
return data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to fetch features';
setError(errorMessage);
setFeatures([]);
return [];
} finally {
setLoading(false);
}
}, []);
/**
* Fetch feature instances for a mandate with optional pagination
*/
const fetchInstances = useCallback(async (
mandateIdOrPagination?: string | PaginationParams,
featureCode?: string
): Promise<FeatureInstance[]> => {
setLoading(true);
setError(null);
let mandateId: string;
let paginationParams: PaginationParams = {};
// Handle backward compatibility
if (typeof mandateIdOrPagination === 'string') {
mandateId = mandateIdOrPagination;
currentMandateIdRef.current = mandateId;
currentFeatureCodeRef.current = featureCode;
} else if (mandateIdOrPagination && typeof mandateIdOrPagination === 'object') {
paginationParams = mandateIdOrPagination;
mandateId = currentMandateIdRef.current;
featureCode = currentFeatureCodeRef.current;
} else {
mandateId = currentMandateIdRef.current;
featureCode = currentFeatureCodeRef.current;
}
if (!mandateId) {
setLoading(false);
return [];
}
try {
const params = new URLSearchParams();
if (featureCode) {
params.append('featureCode', featureCode);
}
if (Object.keys(paginationParams).length > 0) {
params.append('pagination', JSON.stringify(paginationParams));
}
const url = params.toString()
? `/api/features/instances?${params.toString()}`
: '/api/features/instances';
const response = await api.get(url, {
headers: {
'X-Mandate-Id': mandateId
}
});
let data: FeatureInstance[] = [];
if (response.data?.items && Array.isArray(response.data.items)) {
data = response.data.items;
if (response.data.pagination) {
setInstancesPagination(response.data.pagination);
}
} else {
data = Array.isArray(response.data) ? response.data : [];
}
setInstances(data);
return data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to fetch feature instances';
setError(errorMessage);
setInstances([]);
setInstancesPagination(null);
return [];
} finally {
setLoading(false);
}
}, []);
/**
* Create a new feature instance
*/
const createInstance = useCallback(async (
mandateId: string,
data: FeatureInstanceCreate
): Promise<{ success: boolean; data?: FeatureInstance; error?: string }> => {
setLoading(true);
setError(null);
try {
const response = await api.post('/api/features/instances', data, {
headers: {
'X-Mandate-Id': mandateId
}
});
return { success: true, data: response.data };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to create feature instance';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, []);
/**
* Update a feature instance (label, enabled)
*/
const updateInstance = useCallback(async (
mandateId: string,
instanceId: string,
data: { label?: string; enabled?: boolean }
): Promise<{ success: boolean; data?: FeatureInstance; error?: string }> => {
setLoading(true);
setError(null);
try {
const response = await api.put(`/api/features/instances/${instanceId}`, data, {
headers: {
'X-Mandate-Id': mandateId
}
});
// Update local state
setInstances(prev => prev.map(i => i.id === instanceId ? { ...i, ...response.data } : i));
return { success: true, data: response.data };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to update feature instance';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, []);
/**
* Delete a feature instance
*/
const deleteInstance = useCallback(async (
mandateId: string,
instanceId: string
): Promise<{ success: boolean; error?: string }> => {
setLoading(true);
setError(null);
try {
await api.delete(`/api/features/instances/${instanceId}`, {
headers: {
'X-Mandate-Id': mandateId
}
});
// Optimistically update the local state
setInstances(prev => prev.filter(i => i.id !== instanceId));
return { success: true };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to delete feature instance';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, []);
/**
* Sync roles for a feature instance from templates
*/
const syncInstanceRoles = useCallback(async (
mandateId: string,
instanceId: string,
addOnly: boolean = true
): Promise<{ success: boolean; data?: { added: number; removed: number; unchanged: number }; error?: string }> => {
setLoading(true);
setError(null);
try {
const response = await api.post(`/api/features/instances/${instanceId}/sync-roles?addOnly=${addOnly}`, {}, {
headers: {
'X-Mandate-Id': mandateId
}
});
return { success: true, data: response.data };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to sync instance roles';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, []);
/**
* Get current user's feature instances (grouped by mandate)
*/
const fetchMyFeatureInstances = useCallback(async (): Promise<{
mandates: Array<{
id: string;
name: string;
features: Array<{
code: string;
label: string | { [key: string]: string };
instances: Array<{
id: string;
featureCode: string;
mandateId: string;
instanceLabel: string;
}>;
}>;
}>;
}> => {
try {
const response = await api.get('/api/features/my');
return response.data || { mandates: [] };
} catch (err: any) {
console.error('Error fetching my feature instances:', err);
return { mandates: [] };
}
}, []);
/**
* Get template roles for features
*/
const fetchTemplateRoles = useCallback(async (featureCode?: string): Promise<any[]> => {
try {
let url = '/api/features/templates/roles';
if (featureCode) {
url += `?featureCode=${encodeURIComponent(featureCode)}`;
}
const response = await api.get(url);
return Array.isArray(response.data) ? response.data : [];
} catch (err: any) {
console.error('Error fetching template roles:', err);
return [];
}
}, []);
// ============================================
// Feature Instance Users Management
// ============================================
/**
* Fetch all users with access to a specific feature instance
*/
const fetchInstanceUsers = useCallback(async (
mandateId: string,
instanceId: string
): Promise<FeatureAccessUser[]> => {
setLoading(true);
setError(null);
try {
const response = await api.get(`/api/features/instances/${instanceId}/users`, {
headers: {
'X-Mandate-Id': mandateId
}
});
return Array.isArray(response.data) ? response.data : [];
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to fetch instance users';
setError(errorMessage);
return [];
} finally {
setLoading(false);
}
}, []);
/**
* Add a user to a feature instance with specified roles
*/
const addUserToInstance = useCallback(async (
mandateId: string,
instanceId: string,
data: AddUserToInstanceRequest
): Promise<{ success: boolean; data?: any; error?: string }> => {
setLoading(true);
setError(null);
try {
const response = await api.post(`/api/features/instances/${instanceId}/users`, data, {
headers: {
'X-Mandate-Id': mandateId
}
});
return { success: true, data: response.data };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to add user to instance';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, []);
/**
* Remove a user's access from a feature instance
*/
const removeUserFromInstance = useCallback(async (
mandateId: string,
instanceId: string,
userId: string
): Promise<{ success: boolean; error?: string }> => {
setLoading(true);
setError(null);
try {
await api.delete(`/api/features/instances/${instanceId}/users/${userId}`, {
headers: {
'X-Mandate-Id': mandateId
}
});
return { success: true };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to remove user from instance';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, []);
/**
* Update a user's roles and active flag in a feature instance
*/
const updateInstanceUserRoles = useCallback(async (
mandateId: string,
instanceId: string,
userId: string,
payload: { roleIds: string[]; enabled?: boolean }
): Promise<{ success: boolean; data?: any; error?: string }> => {
setLoading(true);
setError(null);
try {
const body = payload.enabled !== undefined
? { roleIds: payload.roleIds, enabled: payload.enabled }
: { roleIds: payload.roleIds };
const response = await api.put(
`/api/features/instances/${instanceId}/users/${userId}/roles`,
body,
{
headers: {
'X-Mandate-Id': mandateId
}
}
);
return { success: true, data: response.data };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to update user roles';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, []);
/**
* Get available roles for a feature instance
*/
const fetchInstanceRoles = useCallback(async (
mandateId: string,
instanceId: string
): Promise<FeatureInstanceRole[]> => {
try {
const response = await api.get(`/api/features/instances/${instanceId}/available-roles`, {
headers: {
'X-Mandate-Id': mandateId
}
});
return Array.isArray(response.data) ? response.data : [];
} catch (err: any) {
console.error('Error fetching instance roles:', err);
return [];
}
}, []);
return {
features,
instances,
instancesPagination,
loading,
error,
fetchFeatures,
fetchInstances,
createInstance,
updateInstance,
deleteInstance,
syncInstanceRoles,
fetchMyFeatureInstances,
fetchTemplateRoles,
// Instance users management
fetchInstanceUsers,
addUserToInstance,
removeUserFromInstance,
updateInstanceUserRoles,
fetchInstanceRoles,
};
}
export default useFeatureAccess;

View file

@ -1,7 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback } from 'react';
import api from '../api';
import { MessageOverlay } from '../components/UiComponents';
import type { MessageMode } from '../components/UiComponents';
import { useToast } from '../contexts/ToastContext';
import { useLanguage } from '../providers/language/LanguageContext';
import { getUserDataCache } from '../utils/userCache';
import { useApiRequest } from './useApi';
@ -290,6 +289,22 @@ export function useUserFiles() {
fetchPermissions();
}, [fetchAttributes, fetchPermissions]);
// Listen for file upload events and refresh the list
useEffect(() => {
const handleFileUploaded = (event: CustomEvent) => {
console.log('📁 File uploaded event received, refreshing list...', event.detail);
// Small delay to ensure backend has persisted the file
setTimeout(() => {
fetchFiles();
}, 100);
};
window.addEventListener('fileUploaded', handleFileUploaded as EventListener);
return () => {
window.removeEventListener('fileUploaded', handleFileUploaded as EventListener);
};
}, [fetchFiles]);
return {
data: files,
loading,
@ -319,9 +334,8 @@ export function useFileOperations() {
const [previewingFiles, setPreviewingFiles] = useState<Set<string>>(new Set());
const [previewError, setPreviewError] = useState<string | null>(null);
// Warning state
const [showWarning, setShowWarning] = useState(false);
const [warningData, setWarningData] = useState<{ header: string; message: string; mode: MessageMode } | null>(null);
// Toast for notifications
const { showWarning } = useToast();
// Language context
const { t } = useLanguage();
@ -502,29 +516,12 @@ export function useFileOperations() {
const fileName = fileData.originalFileName || file.name;
const messageTemplate = t('warning.duplicate_file.message');
const message = messageTemplate.replace('{fileName}', fileName);
// Close any existing warning first
if (showWarning) {
setShowWarning(false);
// Wait a moment before showing the new warning
setTimeout(() => {
setWarningData({
header: t('warning.duplicate_file.title'),
message: message,
mode: 'warning'
});
setShowWarning(true);
}, 600);
} else {
setWarningData({
header: t('warning.duplicate_file.title'),
message: message,
mode: 'warning'
});
setShowWarning(true);
}
showWarning(t('warning.duplicate_file.title'), message);
}
// Dispatch event to notify other components about the new file
window.dispatchEvent(new CustomEvent('fileUploaded', { detail: fileData }));
return { success: true, fileData };
} catch (error: any) {
console.error('Upload failed:', error);
@ -936,14 +933,18 @@ export function useFileOperations() {
}
};
// Function to close warning
const closeWarning = useCallback(() => {
setShowWarning(false);
// Delay clearing the data to allow exit animation to complete (matches CSS transition)
setTimeout(() => {
setWarningData(null);
}, 700);
}, []);
// Generic inline update handler for FormGeneratorTable
const handleInlineUpdate = async (fileId: string, changes: Partial<{ fileName: string }>, existingRow?: any) => {
if (!existingRow) {
throw new Error('Existing row data required for inline update');
}
const result = await handleFileUpdate(fileId, changes, existingRow);
if (!result.success) {
throw new Error(result.error || 'Failed to update');
}
return result;
};
return {
downloadingFiles,
@ -961,16 +962,7 @@ export function useFileOperations() {
handleFileUpload,
handleFileUpdate,
handleFilePreview,
isLoading,
// Message overlay component
MessageOverlayComponent: () => React.createElement(MessageOverlay, {
header: warningData?.header || '',
message: warningData?.message || '',
isVisible: showWarning,
mode: warningData?.mode || 'info',
onClose: closeWarning,
autoClose: true,
autoCloseDelay: 5000
})
handleInlineUpdate,
isLoading
};
}

View file

@ -0,0 +1,355 @@
/**
* Instance Permission Hooks
*
* Hooks für Berechtigungsprüfungen basierend auf der aktuellen Feature-Instanz.
* Die Berechtigungen werden summarisch pro Instanz geladen (kein einzelner API-Call pro Check).
*/
import { useMemo } from 'react';
import { useCurrentInstance } from './useCurrentInstance';
import type {
TablePermission,
FieldPermission,
AccessLevel,
InstancePermissions,
} from '../types/mandate';
import { canAccessRecord, hasAccess } from '../types/mandate';
// =============================================================================
// DEFAULT PERMISSIONS (Kein Zugriff)
// =============================================================================
const NO_ACCESS_TABLE: TablePermission = {
view: false,
read: 'n',
create: 'n',
update: 'n',
delete: 'n',
};
// =============================================================================
// TABLE PERMISSION HOOKS
// =============================================================================
/**
* Hook für Tabellen-Berechtigungen
*
* Verwendung:
* ```tsx
* function ContractList() {
* const { canCreate, canUpdate, canDelete, read } = useTablePermission('TrusteeContract');
*
* return (
* <div>
* {canCreate && <Button>Neu</Button>}
* {contracts.map(c => (
* <Row key={c.id}>
* {canUpdate(c) && <EditButton />}
* {canDelete(c) && <DeleteButton />}
* </Row>
* ))}
* </div>
* );
* }
* ```
*/
export function useTablePermission(tableName: string) {
const { instance } = useCurrentInstance();
const permission = useMemo((): TablePermission => {
if (!instance?.permissions?.tables) {
return NO_ACCESS_TABLE;
}
return instance.permissions.tables[tableName] ?? NO_ACCESS_TABLE;
}, [instance, tableName]);
// Kontext für Record-basierte Prüfungen
const userId = ''; // TODO: Aus Auth-Store holen
return {
// Raw permission levels
view: permission.view,
read: permission.read,
create: permission.create,
update: permission.update,
delete: permission.delete,
// Convenience Booleans
canView: permission.view,
canRead: hasAccess(permission.read),
canCreate: hasAccess(permission.create),
canUpdate: hasAccess(permission.update),
canDelete: hasAccess(permission.delete),
// Record-basierte Prüfungen
canReadRecord: (record: { _createdBy?: string }) =>
canAccessRecord(permission.read, record, userId),
canUpdateRecord: (record: { _createdBy?: string }) =>
canAccessRecord(permission.update, record, userId),
canDeleteRecord: (record: { _createdBy?: string }) =>
canAccessRecord(permission.delete, record, userId),
};
}
/**
* Vereinfachter Hook - prüft nur ob Tabelle sichtbar ist
*/
export function useCanViewTable(tableName: string): boolean {
const { canView } = useTablePermission(tableName);
return canView;
}
// =============================================================================
// VIEW PERMISSION HOOKS
// =============================================================================
/**
* Hook für View-Berechtigungen (Navigation)
*
* Verwendung:
* ```tsx
* function Navigation() {
* const canViewContracts = useCanViewFeatureView('trustee-contracts');
*
* return (
* <nav>
* {canViewContracts && <NavLink to="contracts">Verträge</NavLink>}
* </nav>
* );
* }
* ```
*
* 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, 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 (views["_all"]) {
return true;
}
// Check legacy format directly (e.g., "trustee-dashboard")
if (views[viewCode]) {
return true;
}
// Check fully qualified objectKey format (e.g., "ui.feature.trustee.dashboard")
// Convert viewCode "trustee-dashboard" to "ui.feature.trustee.dashboard"
const parts = viewCode.split('-');
if (parts.length >= 2 && featureCode) {
const viewName = parts.slice(1).join('-'); // e.g., "dashboard" or "position-documents"
const fullObjectKey = `ui.feature.${featureCode}.${viewName}`;
if (views[fullObjectKey]) {
return true;
}
}
return false;
}
/**
* Hook für mehrere View-Berechtigungen gleichzeitig
* Supports both legacy format and fully qualified objectKey format
*/
export function useViewPermissions(viewCodes: string[]): Record<string, boolean> {
const { instance, featureCode } = useCurrentInstance();
return useMemo(() => {
const result: Record<string, boolean> = {};
const views = instance?.permissions?.views;
if (!views) {
viewCodes.forEach(code => {
result[code] = false;
});
return result;
}
// Check for wildcard permission
const hasAllViews = views["_all"] ?? false;
viewCodes.forEach(code => {
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, featureCode, viewCodes]);
}
// =============================================================================
// FIELD PERMISSION HOOKS
// =============================================================================
/**
* Hook für Feld-Berechtigungen
*
* Verwendung:
* ```tsx
* function ContractForm() {
* const { canRead, canWrite } = useFieldPermission('TrusteeContract', 'salary');
*
* return (
* <form>
* {canRead && (
* <TextField
* name="salary"
* disabled={!canWrite}
* />
* )}
* </form>
* );
* }
* ```
*/
export function useFieldPermission(tableName: string, fieldName: string): FieldPermission {
const { instance } = useCurrentInstance();
return useMemo(() => {
const fieldPermissions = instance?.permissions?.fields?.[tableName];
if (!fieldPermissions) {
// Wenn keine Feld-Level Einschränkungen, erlaube alles
return { read: true, write: true };
}
return fieldPermissions[fieldName] ?? { read: true, write: true };
}, [instance, tableName, fieldName]);
}
// =============================================================================
// GENERIC PERMISSION CHECK
// =============================================================================
/**
* Generischer Hook für beliebige Berechtigungsprüfungen
*/
export function useInstancePermissions(): InstancePermissions | undefined {
const { instance } = useCurrentInstance();
return instance?.permissions;
}
/**
* Hook der prüft ob ein Record bearbeitet werden darf
* Kombiniert Tabellen-Permission mit Record-Owner-Check
*/
export function useCanEditRecord(
tableName: string,
record: { _createdBy?: string } | undefined,
userId: string
): boolean {
const { update } = useTablePermission(tableName);
if (!record) return false;
return canAccessRecord(update, record, userId);
}
/**
* Hook der prüft ob ein Record gelöscht werden darf
*/
export function useCanDeleteRecord(
tableName: string,
record: { _createdBy?: string } | undefined,
userId: string
): boolean {
const { delete: deleteLevel } = useTablePermission(tableName);
if (!record) return false;
return canAccessRecord(deleteLevel, record, userId);
}
// =============================================================================
// PERMISSION GATE COMPONENT
// =============================================================================
interface PermissionGateProps {
table?: string;
view?: string;
action?: 'view' | 'read' | 'create' | 'update' | 'delete';
record?: { _createdBy?: string };
children: React.ReactNode;
fallback?: React.ReactNode;
}
/**
* Komponente für bedingte Anzeige basierend auf Berechtigungen
*
* Verwendung:
* ```tsx
* <PermissionGate table="TrusteeContract" action="create">
* <Button>Neuer Vertrag</Button>
* </PermissionGate>
*
* <PermissionGate view="trustee-admin" fallback={<AccessDenied />}>
* <AdminPanel />
* </PermissionGate>
* ```
*/
export function PermissionGate({
table,
view,
action = 'view',
record,
children,
fallback = null,
}: PermissionGateProps): React.ReactElement | null {
const { instance } = useCurrentInstance();
const userId = ''; // TODO: Aus Auth-Store holen
let hasPermission = false;
if (view) {
// View-basierte Prüfung
hasPermission = instance?.permissions?.views?.[view] ?? false;
} else if (table) {
// Tabellen-basierte Prüfung
const tablePermission = instance?.permissions?.tables?.[table];
if (!tablePermission) {
hasPermission = false;
} else if (action === 'view') {
hasPermission = tablePermission.view;
} else {
const level = tablePermission[action] as AccessLevel;
if (record) {
hasPermission = canAccessRecord(level, record, userId);
} else {
hasPermission = hasAccess(level);
}
}
}
return hasPermission ? <>{children}</> : <>{fallback}</>;
}

258
src/hooks/useInvitations.ts Normal file
View file

@ -0,0 +1,258 @@
/**
* useInvitations Hook
*
* Hook for managing invitations (creating, listing, validating, accepting).
* Uses the /api/invitations endpoints.
*/
import { useState, useCallback, useRef } from 'react';
import api from '../api';
// Types
export interface PaginationParams {
page?: number;
pageSize?: number;
search?: string;
filters?: Record<string, any>;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
}
export interface PaginationMetadata {
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
}
export interface Invitation {
id: string;
token: string;
mandateId: string;
featureInstanceId?: string;
roleIds: string[];
targetUsername: string;
email?: string;
createdBy: string;
createdAt: number;
expiresAt: number;
usedBy?: string;
usedAt?: number;
revokedAt?: number;
maxUses: number;
currentUses: number;
inviteUrl: string;
emailSent?: boolean;
isExpired?: boolean;
isUsedUp?: boolean;
}
export interface InvitationCreate {
targetUsername: string;
email?: string;
roleIds: string[];
featureInstanceId?: string;
expiresInHours?: number;
maxUses?: number;
}
export interface InvitationValidation {
valid: boolean;
reason?: string;
mandateId?: string;
mandateName?: string;
featureInstanceId?: string;
roleIds: string[];
roleLabels?: string[];
targetUsername?: string;
}
/**
* Hook for managing invitations
*/
export function useInvitations() {
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pagination, setPagination] = useState<PaginationMetadata | null>(null);
// Store current context for refetch
const currentMandateIdRef = useRef<string>('');
const currentOptionsRef = useRef<{ includeUsed?: boolean; includeExpired?: boolean }>({});
/**
* Fetch all invitations for a mandate with optional pagination
*/
const fetchInvitations = useCallback(async (
mandateIdOrPagination?: string | PaginationParams,
options?: { includeUsed?: boolean; includeExpired?: boolean }
): Promise<Invitation[]> => {
setLoading(true);
setError(null);
let mandateId: string;
let paginationParams: PaginationParams = {};
// Handle backward compatibility
if (typeof mandateIdOrPagination === 'string') {
mandateId = mandateIdOrPagination;
currentMandateIdRef.current = mandateId;
if (options) {
currentOptionsRef.current = options;
}
} else if (mandateIdOrPagination && typeof mandateIdOrPagination === 'object') {
// Called with pagination params only (refetch from FormGeneratorTable)
paginationParams = mandateIdOrPagination;
mandateId = currentMandateIdRef.current;
} else {
mandateId = currentMandateIdRef.current;
}
if (!mandateId) {
setLoading(false);
return [];
}
const fetchOptions = options || currentOptionsRef.current;
try {
const params = new URLSearchParams();
if (fetchOptions?.includeUsed) params.append('includeUsed', 'true');
if (fetchOptions?.includeExpired) params.append('includeExpired', 'true');
if (Object.keys(paginationParams).length > 0) {
params.append('pagination', JSON.stringify(paginationParams));
}
const response = await api.get(`/api/invitations/?${params.toString()}`, {
headers: { 'X-Mandate-Id': mandateId }
});
let data: Invitation[] = [];
if (response.data?.items && Array.isArray(response.data.items)) {
data = response.data.items;
if (response.data.pagination) {
setPagination(response.data.pagination);
}
} else {
data = Array.isArray(response.data) ? response.data : [];
}
setInvitations(data);
return data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to fetch invitations';
setError(errorMessage);
setInvitations([]);
setPagination(null);
return [];
} finally {
setLoading(false);
}
}, []);
/**
* Create a new invitation
*/
const createInvitation = useCallback(async (
mandateId: string,
data: InvitationCreate
): Promise<{ success: boolean; data?: Invitation; error?: string }> => {
setLoading(true);
setError(null);
try {
const response = await api.post('/api/invitations/', data, {
headers: { 'X-Mandate-Id': mandateId }
});
return { success: true, data: response.data };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to create invitation';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, []);
/**
* Revoke an invitation
*/
const revokeInvitation = useCallback(async (
mandateId: string,
invitationId: string
): Promise<{ success: boolean; error?: string }> => {
setLoading(true);
setError(null);
try {
await api.delete(`/api/invitations/${invitationId}`, {
headers: { 'X-Mandate-Id': mandateId }
});
// Optimistically update local state
setInvitations(prev => prev.filter(inv => inv.id !== invitationId));
return { success: true };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to revoke invitation';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, []);
/**
* Validate an invitation token (public - no auth required)
*/
const validateInvitation = useCallback(async (
token: string
): Promise<InvitationValidation> => {
setLoading(true);
setError(null);
try {
const response = await api.get(`/api/invitations/validate/${token}`);
return response.data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to validate invitation';
setError(errorMessage);
return {
valid: false,
reason: errorMessage,
roleIds: []
};
} finally {
setLoading(false);
}
}, []);
/**
* Accept an invitation (requires authentication)
*/
const acceptInvitation = useCallback(async (
token: string
): Promise<{ success: boolean; data?: any; error?: string }> => {
setLoading(true);
setError(null);
try {
const response = await api.post(`/api/invitations/accept/${token}`);
return { success: true, data: response.data };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to accept invitation';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, []);
return {
invitations,
loading,
error,
pagination,
fetchInvitations,
createInvitation,
revokeInvitation,
validateInvitation,
acceptInvitation,
};
}
export default useInvitations;

View file

@ -0,0 +1,317 @@
/**
* useMandateRoles Hook
*
* Hook for managing roles within a specific mandate.
* Uses the /api/rbac/roles endpoints.
*/
import { useState, useCallback, useRef } from 'react';
import api from '../api';
// Types
export interface Role {
id: string;
roleLabel: string;
description?: string | { [key: string]: string };
mandateId?: string;
featureInstanceId?: string;
featureCode?: string;
isSystemRole?: boolean;
isTemplate?: boolean;
createdAt?: number;
updatedAt?: number;
}
export interface RoleCreate {
roleLabel: string;
description?: string | { [key: string]: string };
mandateId?: string;
featureInstanceId?: string;
featureCode?: string;
}
export interface RoleUpdate {
roleLabel?: string;
description?: string | { [key: string]: string };
mandateId?: string | null;
}
export interface PaginationParams {
page?: number;
pageSize?: number;
search?: string;
filters?: Record<string, any>;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
scopeFilter?: 'all' | 'mandate' | 'global'; // Backend filter for role scope
}
export interface PaginationMetadata {
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
}
/**
* Hook for managing mandate roles
*/
export function useMandateRoles() {
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pagination, setPagination] = useState<PaginationMetadata | null>(null);
// Store current mandateId for refetch
const currentMandateIdRef = useRef<string | undefined>(undefined);
/**
* Fetch all roles with pagination support
* @param mandateIdOrParams - Either a mandateId string (backward compatible) or pagination params
* @param additionalParams - Additional parameters like scopeFilter (when first param is mandateId)
*/
const fetchRoles = useCallback(async (
mandateIdOrParams?: string | PaginationParams,
additionalParams?: PaginationParams
): Promise<Role[]> => {
setLoading(true);
setError(null);
try {
const headers: Record<string, string> = {};
let paginationParams: PaginationParams = {};
let mandateId: string | undefined;
let scopeFilter: string | undefined;
// Handle backward compatibility: first param can be mandateId string or pagination object
if (typeof mandateIdOrParams === 'string') {
mandateId = mandateIdOrParams;
currentMandateIdRef.current = mandateId;
// If additional params provided, use them
if (additionalParams) {
paginationParams = additionalParams;
scopeFilter = additionalParams.scopeFilter;
}
} else if (mandateIdOrParams && typeof mandateIdOrParams === 'object') {
paginationParams = mandateIdOrParams;
mandateId = currentMandateIdRef.current;
scopeFilter = mandateIdOrParams.scopeFilter;
}
if (mandateId) {
headers['X-Mandate-Id'] = mandateId;
}
// Build query params for pagination (exclude scopeFilter from pagination JSON)
const { scopeFilter: _, ...paginationWithoutScope } = paginationParams;
const queryParams: Record<string, string> = {};
if (Object.keys(paginationWithoutScope).length > 0) {
queryParams.pagination = JSON.stringify(paginationWithoutScope);
}
// Do NOT include feature template roles - they belong to Feature-Rollen page
// According to admin_ui_concept.md: Filter: featureCode=null AND featureInstanceId=null
queryParams.includeTemplates = 'false';
// Include mandate-specific roles for the selected mandate
if (mandateId) {
queryParams.mandateId = mandateId;
}
// Include scopeFilter as separate query parameter
if (scopeFilter) {
queryParams.scopeFilter = scopeFilter;
}
const response = await api.get('/api/rbac/roles', {
headers,
params: queryParams
});
let data: Role[] = [];
let paginationMeta: PaginationMetadata | null = null;
if (response.data?.items && Array.isArray(response.data.items)) {
data = response.data.items;
if (response.data.pagination) {
paginationMeta = response.data.pagination;
}
} else if (Array.isArray(response.data)) {
data = response.data;
}
// No client-side filtering needed - backend already filters
setRoles(data);
setPagination(paginationMeta);
return data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to fetch roles';
setError(errorMessage);
setRoles([]);
setPagination(null);
return [];
} finally {
setLoading(false);
}
}, []);
/**
* Get a single role by ID
*/
const getRole = useCallback(async (roleId: string): Promise<Role | null> => {
try {
const response = await api.get(`/api/rbac/roles/${roleId}`);
return response.data;
} catch (err: any) {
console.error('Error fetching role:', err);
return null;
}
}, []);
/**
* Create a new role
*/
const createRole = useCallback(async (
data: RoleCreate,
mandateId?: string
): Promise<{ success: boolean; data?: Role; error?: string }> => {
setLoading(true);
setError(null);
try {
const headers: Record<string, string> = {};
if (mandateId) {
headers['X-Mandate-Id'] = mandateId;
}
const response = await api.post('/api/rbac/roles', data, { headers });
return { success: true, data: response.data };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to create role';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, []);
/**
* Update an existing role
*/
const updateRole = useCallback(async (
roleId: string,
data: RoleUpdate
): Promise<{ success: boolean; data?: Role; error?: string }> => {
setLoading(true);
setError(null);
try {
const response = await api.put(`/api/rbac/roles/${roleId}`, data);
// Optimistically update local state (convert null to undefined for mandateId)
const updateData = {
...data,
mandateId: data.mandateId === null ? undefined : data.mandateId
};
setRoles(prev => prev.map(r => r.id === roleId ? { ...r, ...updateData } : r));
return { success: true, data: response.data };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to update role';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, []);
/**
* Delete a role
*/
const deleteRole = useCallback(async (
roleId: string
): Promise<{ success: boolean; error?: string }> => {
setLoading(true);
setError(null);
try {
await api.delete(`/api/rbac/roles/${roleId}`);
// Optimistically update local state
setRoles(prev => prev.filter(r => r.id !== roleId));
return { success: true };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to delete role';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, []);
/**
* Get role options (for dropdowns)
*/
const fetchRoleOptions = useCallback(async (): Promise<Array<{ value: string; label: string }>> => {
try {
const response = await api.get('/api/rbac/roles/options');
if (Array.isArray(response.data)) {
return response.data.map((r: any) => ({
value: r.id || r.value,
label: r.roleLabel || r.label || r.id
}));
}
return [];
} catch (err: any) {
console.error('Error fetching role options:', err);
return [];
}
}, []);
/**
* Get users with a specific role
*/
const getUsersWithRole = useCallback(async (
roleLabel: string
): Promise<Array<{ userId: string; username: string; email?: string }>> => {
try {
const response = await api.get(`/api/rbac/roles/roles/${roleLabel}/users`);
return Array.isArray(response.data) ? response.data : [];
} catch (err: any) {
console.error('Error fetching users with role:', err);
return [];
}
}, []);
/**
* Filter roles by type
*/
const getMandateRoles = useCallback((mandateId: string) => {
return roles.filter(r =>
r.mandateId === mandateId && !r.featureInstanceId
);
}, [roles]);
const getFeatureRoles = useCallback((featureInstanceId: string) => {
return roles.filter(r => r.featureInstanceId === featureInstanceId);
}, [roles]);
const getGlobalRoles = useCallback(() => {
return roles.filter(r => !r.mandateId && !r.featureInstanceId);
}, [roles]);
const getTemplateRoles = useCallback(() => {
return roles.filter(r => r.isTemplate === true);
}, [roles]);
return {
roles,
loading,
error,
pagination,
fetchRoles,
getRole,
createRole,
updateRole,
deleteRole,
fetchRoleOptions,
getUsersWithRole,
getMandateRoles,
getFeatureRoles,
getGlobalRoles,
getTemplateRoles,
};
}
export default useMandateRoles;

237
src/hooks/useMandates.ts Normal file
View file

@ -0,0 +1,237 @@
/**
* useMandates Hook
*
* Hook für die Verwaltung von Mandanten (Mandates) im Admin-Bereich.
* Folgt dem gleichen Pattern wie useOrgUsers.
*/
import { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from './useApi';
import api from '../api';
import { usePermissions, type UserPermissions } from './usePermissions';
import {
fetchMandates as fetchMandatesApi,
fetchMandateById as fetchMandateByIdApi,
createMandate as createMandateApi,
updateMandate as updateMandateApi,
deleteMandate as deleteMandateApi,
type Mandate,
type MandateUpdateData,
type PaginationParams
} from '../api/mandateApi';
// Re-export types
export type { Mandate, MandateUpdateData, PaginationParams };
export interface AttributeDefinition {
name: string;
type: string;
label: string;
description?: string;
required?: boolean;
default?: any;
options?: Array<{ value: string | number; label: string | { [key: string]: string } }> | string;
sortable?: boolean;
filterable?: boolean;
searchable?: boolean;
width?: number;
minWidth?: number;
maxWidth?: number;
readonly?: boolean;
editable?: boolean;
}
/**
* Hook for managing mandates in admin panel
*/
export function useAdminMandates() {
const [mandates, setMandates] = useState<Mandate[]>([]);
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
const [pagination, setPagination] = useState<{
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
} | null>(null);
const { request, isLoading: loading, error } = useApiRequest<null, Mandate[]>();
const { checkPermission } = usePermissions();
// Fetch attributes from backend
const fetchAttributes = useCallback(async () => {
try {
const response = await api.get('/api/attributes/Mandate');
let attrs: AttributeDefinition[] = [];
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
attrs = response.data.attributes;
} else if (Array.isArray(response.data)) {
attrs = response.data;
} else if (response.data && typeof response.data === 'object') {
const keys = Object.keys(response.data);
for (const key of keys) {
if (Array.isArray(response.data[key])) {
attrs = response.data[key];
break;
}
}
}
setAttributes(attrs);
return attrs;
} catch (error: any) {
if (error.response?.status === 429) {
console.warn('Rate limit exceeded while fetching mandate attributes.');
} else if (error.response?.status !== 401) {
console.error('Error fetching mandate attributes:', error);
}
setAttributes([]);
return [];
}
}, []);
// Fetch permissions
const fetchPermissions = useCallback(async () => {
try {
const perms = await checkPermission('DATA', 'Mandate');
setPermissions(perms);
return perms;
} catch (error: any) {
console.error('Error fetching mandate permissions:', error);
const defaultPerms: UserPermissions = {
view: false,
read: 'n',
create: 'n',
update: 'n',
delete: 'n',
};
setPermissions(defaultPerms);
return defaultPerms;
}
}, [checkPermission]);
// Fetch mandates
const fetchMandates = useCallback(async (params?: PaginationParams) => {
try {
const data = await fetchMandatesApi(request, params);
if (data && typeof data === 'object' && 'items' in data) {
const items = Array.isArray(data.items) ? data.items : [];
setMandates(items);
if (data.pagination) {
setPagination(data.pagination);
}
} else {
const items = Array.isArray(data) ? data : [];
setMandates(items);
setPagination(null);
}
} catch (error: any) {
setMandates([]);
setPagination(null);
}
}, [request]);
// Optimistic updates
const removeOptimistically = (mandateId: string) => {
setMandates(prev => prev.filter(m => m.id !== mandateId));
};
const updateOptimistically = (mandateId: string, updateData: Partial<Mandate>) => {
setMandates(prev =>
prev.map(m => m.id === mandateId ? { ...m, ...updateData } : m)
);
};
// Fetch single mandate
const fetchMandateById = useCallback(async (mandateId: string): Promise<Mandate | null> => {
return await fetchMandateByIdApi(request, mandateId);
}, [request]);
// Generate columns from attributes (including fkSource/fkDisplayField for FK resolution)
const columns = 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,
fkSource: (attr as any).fkSource, // API endpoint for FK data
fkDisplayField: (attr as any).fkDisplayField, // Which field of FK target to display
}));
// Create mandate
const handleCreate = useCallback(async (mandateData: Partial<Mandate>): Promise<boolean> => {
try {
await createMandateApi(request, mandateData);
await fetchMandates();
return true;
} catch (error: any) {
console.error('Error creating mandate:', error);
return false;
}
}, [request, fetchMandates]);
// Update mandate
const handleUpdate = useCallback(async (mandateId: string, updateData: MandateUpdateData): Promise<boolean> => {
try {
updateOptimistically(mandateId, updateData);
await updateMandateApi(request, mandateId, updateData);
return true;
} catch (error: any) {
console.error('Error updating mandate:', error);
await fetchMandates();
return false;
}
}, [request, fetchMandates]);
// Delete mandate
const handleDelete = useCallback(async (mandateId: string): Promise<boolean> => {
try {
removeOptimistically(mandateId);
await deleteMandateApi(request, mandateId);
return true;
} catch (error: any) {
console.error('Error deleting mandate:', error);
await fetchMandates();
return false;
}
}, [request, fetchMandates]);
// Inline update
const handleInlineUpdate = useCallback(async (
mandateId: string,
updateData: Partial<Mandate>
): Promise<void> => {
await handleUpdate(mandateId, updateData);
}, [handleUpdate]);
// Load data on mount
useEffect(() => {
fetchAttributes();
fetchPermissions();
fetchMandates();
}, []);
return {
mandates,
attributes,
columns,
permissions,
pagination,
loading,
error,
refetch: fetchMandates,
fetchMandateById,
handleCreate,
handleUpdate,
handleDelete,
handleInlineUpdate,
updateOptimistically,
};
}
export default useAdminMandates;

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

@ -0,0 +1,277 @@
/**
* useNotifications Hook
*
* Hook for managing in-app notifications.
* Supports fetching, marking as read, and executing actions.
*/
import { useState, useCallback, useEffect, useRef } from 'react';
import api from '../api';
// Types
export interface NotificationAction {
actionId: string;
label: string;
style: 'primary' | 'danger' | 'default';
}
export interface UserNotification {
id: string;
userId: string;
type: 'invitation' | 'system' | 'workflow' | 'mention';
status: 'unread' | 'read' | 'actioned' | 'dismissed';
title: string;
message: string;
icon?: string;
referenceType?: string;
referenceId?: string;
actions?: NotificationAction[];
actionTaken?: string;
actionResult?: string;
createdAt: number;
readAt?: number;
actionedAt?: number;
expiresAt?: number;
}
export interface NotificationActionResult {
message: string;
action: string;
notificationId: string;
}
/**
* Hook for managing notifications
*/
export function useNotifications() {
const [notifications, setNotifications] = useState<UserNotification[]>([]);
const [unreadCount, setUnreadCount] = useState<number>(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Polling interval ref
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
/**
* Fetch all notifications for the current user
*/
const fetchNotifications = useCallback(async (
options?: { status?: string; type?: string; limit?: number }
): Promise<UserNotification[]> => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (options?.status) params.append('status', options.status);
if (options?.type) params.append('type', options.type);
if (options?.limit) params.append('limit', options.limit.toString());
const queryString = params.toString();
const url = `/api/notifications${queryString ? `?${queryString}` : ''}`;
const response = await api.get(url);
const data = response.data as UserNotification[];
setNotifications(data);
return data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || 'Fehler beim Laden der Benachrichtigungen';
setError(errorMessage);
return [];
} finally {
setLoading(false);
}
}, []);
/**
* Fetch unread count
*/
const fetchUnreadCount = useCallback(async (): Promise<number> => {
try {
const response = await api.get('/api/notifications/unread-count');
const count = response.data.count;
setUnreadCount(count);
return count;
} catch (err: any) {
console.error('Failed to fetch unread count:', err);
return 0;
}
}, []);
/**
* Mark a notification as read
*/
const markAsRead = useCallback(async (notificationId: string): Promise<boolean> => {
try {
await api.put(`/api/notifications/${notificationId}/read`);
// Update local state
setNotifications(prev =>
prev.map(n =>
n.id === notificationId
? { ...n, status: 'read' as const, readAt: Date.now() / 1000 }
: n
)
);
// Update unread count
setUnreadCount(prev => Math.max(0, prev - 1));
return true;
} catch (err: any) {
console.error('Failed to mark notification as read:', err);
return false;
}
}, []);
/**
* Mark all notifications as read
*/
const markAllAsRead = useCallback(async (): Promise<boolean> => {
try {
await api.put('/api/notifications/mark-all-read');
// Update local state
setNotifications(prev =>
prev.map(n =>
n.status === 'unread'
? { ...n, status: 'read' as const, readAt: Date.now() / 1000 }
: n
)
);
setUnreadCount(0);
return true;
} catch (err: any) {
console.error('Failed to mark all notifications as read:', err);
return false;
}
}, []);
/**
* Execute an action on a notification
*/
const executeAction = useCallback(async (
notificationId: string,
actionId: string
): Promise<NotificationActionResult | null> => {
setLoading(true);
setError(null);
try {
const response = await api.post(`/api/notifications/${notificationId}/action`, {
actionId
});
const result = response.data as NotificationActionResult;
// Update local state
setNotifications(prev =>
prev.map(n =>
n.id === notificationId
? {
...n,
status: 'actioned' as const,
actionTaken: actionId,
actionResult: result.message,
actionedAt: Date.now() / 1000
}
: n
)
);
// Update unread count if it was unread
const notification = notifications.find(n => n.id === notificationId);
if (notification?.status === 'unread') {
setUnreadCount(prev => Math.max(0, prev - 1));
}
return result;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || 'Fehler bei der Ausführung der Aktion';
setError(errorMessage);
return null;
} finally {
setLoading(false);
}
}, [notifications]);
/**
* Dismiss/delete a notification
*/
const dismissNotification = useCallback(async (notificationId: string): Promise<boolean> => {
try {
await api.delete(`/api/notifications/${notificationId}`);
// Update local state
const notification = notifications.find(n => n.id === notificationId);
setNotifications(prev => prev.filter(n => n.id !== notificationId));
// Update unread count if it was unread
if (notification?.status === 'unread') {
setUnreadCount(prev => Math.max(0, prev - 1));
}
return true;
} catch (err: any) {
console.error('Failed to dismiss notification:', err);
return false;
}
}, [notifications]);
/**
* Start polling for new notifications
*/
const startPolling = useCallback((intervalMs: number = 30000) => {
// Clear any existing interval
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
}
// Fetch immediately
fetchUnreadCount();
// Set up polling
pollingIntervalRef.current = setInterval(() => {
fetchUnreadCount();
}, intervalMs);
}, [fetchUnreadCount]);
/**
* Stop polling
*/
const stopPolling = useCallback(() => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
}
};
}, []);
return {
notifications,
unreadCount,
loading,
error,
fetchNotifications,
fetchUnreadCount,
markAsRead,
markAllAsRead,
executeAction,
dismissNotification,
startPolling,
stopPolling
};
}
export default useNotifications;

View file

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

View file

@ -1,6 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from './useApi';
import { getUserDataCache } from '../utils/userCache';
import api from '../api';
import { usePermissions, type UserPermissions } from './usePermissions';
import {
@ -507,13 +506,9 @@ export function usePromptOperations() {
setCreatingPrompt(true);
try {
// Get mandateId from currentUser in sessionStorage cache
const currentUserData = getUserDataCache();
const mandateId = currentUserData?.mandateId || '';
// Structure the request body as required by the API
// mandateId wird nicht mehr vom Client gesendet
// Das Backend bestimmt den Kontext über die instanceId im Request
const requestBody = {
mandateId: mandateId,
name: promptData.name,
content: promptData.content
};
@ -533,13 +528,8 @@ export function usePromptOperations() {
setUpdateError(null);
try {
// Get mandateId from currentUser in sessionStorage cache
const currentUserData = getUserDataCache();
const mandateId = currentUserData?.mandateId || '';
// Structure the request body as required by the API
// mandateId wird nicht mehr vom Client gesendet
const requestBody = {
mandateId: mandateId,
name: updateData.name,
content: updateData.content
};

View file

@ -0,0 +1,270 @@
/**
* useRbacExportImport Hook
*
* Hook for exporting and importing RBAC configurations.
* Supports mandate-level and global (template) exports.
*/
import { useState, useCallback } from 'react';
import api from '../api';
// =============================================================================
// TYPES
// =============================================================================
export type ImportMode = 'merge' | 'replace' | 'add_only';
export interface RbacExportScope {
type: 'global' | 'mandate' | 'instance';
mandateId?: string;
mandateName?: string;
featureInstanceId?: string;
featureCode?: string;
instanceLabel?: string;
}
export interface RbacExportRole {
roleLabel: string;
description?: { [key: string]: string };
featureCode?: string;
}
export interface RbacExportRule {
roleLabel: string;
context: 'DATA' | 'UI' | 'RESOURCE';
item: string | null;
view: boolean;
read?: string | null;
create?: string | null;
update?: string | null;
delete?: string | null;
}
export interface RbacExport {
version: string;
exportedAt: string;
exportedBy?: string;
scope: RbacExportScope;
roles: RbacExportRole[];
accessRules: RbacExportRule[];
}
export interface RbacImportResult {
status: 'success' | 'error';
mode: ImportMode;
rolesCreated: number;
rolesUpdated: number;
rulesCreated: number;
rulesUpdated: number;
errors?: string[];
}
// =============================================================================
// HOOK
// =============================================================================
export function useRbacExportImport() {
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastExport, setLastExport] = useState<RbacExport | null>(null);
const [lastImportResult, setLastImportResult] = useState<RbacImportResult | null>(null);
/**
* Export RBAC configuration for a mandate
*/
const exportMandateRbac = useCallback(async (
mandateId: string,
featureCode?: string
): Promise<{ success: boolean; data?: RbacExport; error?: string }> => {
setExporting(true);
setError(null);
try {
const params = new URLSearchParams();
if (featureCode) params.append('featureCode', featureCode);
const url = `/api/mandates/${mandateId}/rbac/export${params.toString() ? '?' + params.toString() : ''}`;
const response = await api.get(url);
setLastExport(response.data);
return { success: true, data: response.data };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to export RBAC';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setExporting(false);
}
}, []);
/**
* Export global RBAC templates (SysAdmin only)
*/
const exportGlobalRbac = useCallback(async (
featureCode?: string
): Promise<{ success: boolean; data?: RbacExport; error?: string }> => {
setExporting(true);
setError(null);
try {
const params = new URLSearchParams();
if (featureCode) params.append('featureCode', featureCode);
const url = `/api/admin/rbac/global/export${params.toString() ? '?' + params.toString() : ''}`;
const response = await api.get(url);
setLastExport(response.data);
return { success: true, data: response.data };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to export global RBAC';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setExporting(false);
}
}, []);
/**
* Export feature instance RBAC
*/
const exportInstanceRbac = useCallback(async (
instanceId: string
): Promise<{ success: boolean; data?: RbacExport; error?: string }> => {
setExporting(true);
setError(null);
try {
const response = await api.get(`/api/features/instances/${instanceId}/rbac/export`);
setLastExport(response.data);
return { success: true, data: response.data };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to export instance RBAC';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setExporting(false);
}
}, []);
/**
* Import RBAC configuration into a mandate
*/
const importMandateRbac = useCallback(async (
mandateId: string,
data: RbacExport,
mode: ImportMode = 'merge'
): Promise<{ success: boolean; result?: RbacImportResult; error?: string }> => {
setImporting(true);
setError(null);
try {
const response = await api.post(
`/api/mandates/${mandateId}/rbac/import?mode=${mode}`,
data
);
setLastImportResult(response.data);
return { success: true, result: response.data };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to import RBAC';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setImporting(false);
}
}, []);
/**
* Import global RBAC templates (SysAdmin only)
*/
const importGlobalRbac = useCallback(async (
data: RbacExport,
mode: ImportMode = 'merge'
): Promise<{ success: boolean; result?: RbacImportResult; error?: string }> => {
setImporting(true);
setError(null);
try {
const response = await api.post(
`/api/admin/rbac/global/import?mode=${mode}`,
data
);
setLastImportResult(response.data);
return { success: true, result: response.data };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to import global RBAC';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setImporting(false);
}
}, []);
/**
* Download export as JSON file
*/
const downloadExport = useCallback((data: RbacExport, filename?: string) => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || `rbac-export-${data.scope.type}-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, []);
/**
* Parse uploaded JSON file
*/
const parseImportFile = useCallback(async (file: File): Promise<{ success: boolean; data?: RbacExport; error?: string }> => {
try {
const text = await file.text();
const data = JSON.parse(text) as RbacExport;
// Basic validation
if (!data.version) {
return { success: false, error: 'Ungültiges Format: Fehlende Version' };
}
if (!data.scope) {
return { success: false, error: 'Ungültiges Format: Fehlender Scope' };
}
if (!Array.isArray(data.roles)) {
return { success: false, error: 'Ungültiges Format: Roles muss ein Array sein' };
}
if (!Array.isArray(data.accessRules)) {
return { success: false, error: 'Ungültiges Format: AccessRules muss ein Array sein' };
}
return { success: true, data };
} catch (err: any) {
return { success: false, error: `Fehler beim Parsen: ${err.message}` };
}
}, []);
/**
* Clear state
*/
const reset = useCallback(() => {
setError(null);
setLastExport(null);
setLastImportResult(null);
}, []);
return {
exporting,
importing,
error,
lastExport,
lastImportResult,
exportMandateRbac,
exportGlobalRbac,
exportInstanceRbac,
importMandateRbac,
importGlobalRbac,
downloadExport,
parseImportFile,
reset,
};
}
export default useRbacExportImport;

View file

@ -0,0 +1,185 @@
/**
* useResizablePanels
*
* Hook for creating resizable panel layouts with drag-divider.
* Supports LocalStorage persistence and min/max constraints.
*/
import { useState, useCallback, useEffect, useRef } from 'react';
interface UseResizablePanelsOptions {
/** Key for LocalStorage persistence */
storageKey: string;
/** Default width of left panel in percent (0-100) */
defaultLeftWidth: number;
/** Minimum width of left panel in percent */
minLeftWidth: number;
/** Maximum width of left panel in percent */
maxLeftWidth: number;
/** Direction of resize - horizontal or vertical */
direction?: 'horizontal' | 'vertical';
}
interface UseResizablePanelsReturn {
/** Current width/height of left/top panel in percent */
leftWidth: number;
/** Whether user is currently dragging the divider */
isDragging: boolean;
/** Handler for mouse down on divider */
handleMouseDown: (e: React.MouseEvent) => void;
/** Programmatically set the left width */
setLeftWidth: (width: number) => void;
/** Reset to default width */
resetToDefault: () => void;
/** Container ref to attach to the parent container */
containerRef: React.RefObject<HTMLDivElement>;
}
export function useResizablePanels({
storageKey,
defaultLeftWidth,
minLeftWidth,
maxLeftWidth,
direction = 'horizontal',
}: UseResizablePanelsOptions): UseResizablePanelsReturn {
// Initialize from LocalStorage or default
const [leftWidth, setLeftWidthState] = useState<number>(() => {
try {
const stored = localStorage.getItem(storageKey);
if (stored) {
const parsed = parseFloat(stored);
if (!isNaN(parsed) && parsed >= minLeftWidth && parsed <= maxLeftWidth) {
return parsed;
}
}
} catch {
// Ignore localStorage errors
}
return defaultLeftWidth;
});
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Store start position and width for drag calculation
const dragStartRef = useRef<{
startPos: number;
startWidth: number;
containerSize: number;
} | null>(null);
// Set width with clamping and persistence
const setLeftWidth = useCallback((width: number) => {
const clampedWidth = Math.max(minLeftWidth, Math.min(maxLeftWidth, width));
setLeftWidthState(clampedWidth);
// Persist to LocalStorage
try {
localStorage.setItem(storageKey, clampedWidth.toString());
} catch {
// Ignore localStorage errors
}
}, [storageKey, minLeftWidth, maxLeftWidth]);
// Reset to default
const resetToDefault = useCallback(() => {
setLeftWidth(defaultLeftWidth);
}, [defaultLeftWidth, setLeftWidth]);
// Mouse down handler for starting drag
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const container = containerRef.current;
if (!container) return;
const containerRect = container.getBoundingClientRect();
const containerSize = direction === 'horizontal'
? containerRect.width
: containerRect.height;
const startPos = direction === 'horizontal' ? e.clientX : e.clientY;
dragStartRef.current = {
startPos,
startWidth: leftWidth,
containerSize,
};
setIsDragging(true);
}, [leftWidth, direction]);
// Handle mouse move during drag
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!dragStartRef.current || !containerRef.current) return;
const { startPos, startWidth, containerSize } = dragStartRef.current;
const currentPos = direction === 'horizontal' ? e.clientX : e.clientY;
// Calculate delta in pixels and convert to percent
const deltaPixels = currentPos - startPos;
const deltaPercent = (deltaPixels / containerSize) * 100;
// Calculate new width
const newWidth = startWidth + deltaPercent;
// Clamp between min and max
const clampedWidth = Math.max(minLeftWidth, Math.min(maxLeftWidth, newWidth));
setLeftWidthState(clampedWidth);
};
const handleMouseUp = () => {
setIsDragging(false);
dragStartRef.current = null;
// Persist final width to LocalStorage
try {
localStorage.setItem(storageKey, leftWidth.toString());
} catch {
// Ignore localStorage errors
}
};
// Add event listeners to document for capturing mouse events outside container
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
// Add cursor style to body during drag
document.body.style.cursor = direction === 'horizontal' ? 'col-resize' : 'row-resize';
document.body.style.userSelect = 'none';
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, [isDragging, leftWidth, direction, storageKey, minLeftWidth, maxLeftWidth]);
// Save to localStorage when leftWidth changes (debounced by drag end)
useEffect(() => {
// Only save when not dragging (save happens on mouse up)
if (!isDragging) {
try {
localStorage.setItem(storageKey, leftWidth.toString());
} catch {
// Ignore localStorage errors
}
}
}, [leftWidth, isDragging, storageKey]);
return {
leftWidth,
isDragging,
handleMouseDown,
setLeftWidth,
resetToDefault,
containerRef,
};
}
export default useResizablePanels;

237
src/hooks/useRoles.ts Normal file
View file

@ -0,0 +1,237 @@
/**
* useRoles Hook
*
* Hook für die Verwaltung von globalen RBAC-Rollen im Admin-Bereich.
* Folgt dem gleichen Pattern wie useOrgUsers.
*/
import { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from './useApi';
import api from '../api';
import { usePermissions, type UserPermissions } from './usePermissions';
import {
fetchRoles as fetchRolesApi,
fetchRoleById as fetchRoleByIdApi,
createRole as createRoleApi,
updateRole as updateRoleApi,
deleteRole as deleteRoleApi,
type Role,
type RoleUpdateData,
type PaginationParams
} from '../api/roleApi';
// Re-export types
export type { Role, RoleUpdateData, PaginationParams };
export interface AttributeDefinition {
name: string;
type: string;
label: string;
description?: string;
required?: boolean;
default?: any;
options?: Array<{ value: string | number; label: string | { [key: string]: string } }> | string;
sortable?: boolean;
filterable?: boolean;
searchable?: boolean;
width?: number;
minWidth?: number;
maxWidth?: number;
readonly?: boolean;
editable?: boolean;
}
/**
* Hook for managing RBAC roles in admin panel
*/
export function useAdminRoles() {
const [roles, setRoles] = useState<Role[]>([]);
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
const [pagination, setPagination] = useState<{
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
} | null>(null);
const { request, isLoading: loading, error } = useApiRequest<null, Role[]>();
const { checkPermission } = usePermissions();
// Fetch attributes from backend
const fetchAttributes = useCallback(async () => {
try {
const response = await api.get('/api/attributes/Role');
let attrs: AttributeDefinition[] = [];
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
attrs = response.data.attributes;
} else if (Array.isArray(response.data)) {
attrs = response.data;
} else if (response.data && typeof response.data === 'object') {
const keys = Object.keys(response.data);
for (const key of keys) {
if (Array.isArray(response.data[key])) {
attrs = response.data[key];
break;
}
}
}
setAttributes(attrs);
return attrs;
} catch (error: any) {
if (error.response?.status === 429) {
console.warn('Rate limit exceeded while fetching role attributes.');
} else if (error.response?.status !== 401) {
console.error('Error fetching role attributes:', error);
}
setAttributes([]);
return [];
}
}, []);
// Fetch permissions
const fetchPermissions = useCallback(async () => {
try {
const perms = await checkPermission('DATA', 'Role');
setPermissions(perms);
return perms;
} catch (error: any) {
console.error('Error fetching role permissions:', error);
const defaultPerms: UserPermissions = {
view: false,
read: 'n',
create: 'n',
update: 'n',
delete: 'n',
};
setPermissions(defaultPerms);
return defaultPerms;
}
}, [checkPermission]);
// Fetch roles
const fetchRoles = useCallback(async (params?: PaginationParams) => {
try {
const data = await fetchRolesApi(request, params);
if (data && typeof data === 'object' && 'items' in data) {
const items = Array.isArray(data.items) ? data.items : [];
setRoles(items);
if (data.pagination) {
setPagination(data.pagination);
}
} else {
const items = Array.isArray(data) ? data : [];
setRoles(items);
setPagination(null);
}
} catch (error: any) {
setRoles([]);
setPagination(null);
}
}, [request]);
// Optimistic updates
const removeOptimistically = (roleId: string) => {
setRoles(prev => prev.filter(r => r.id !== roleId));
};
const updateOptimistically = (roleId: string, updateData: Partial<Role>) => {
setRoles(prev =>
prev.map(r => r.id === roleId ? { ...r, ...updateData } : r)
);
};
// Fetch single role
const fetchRoleById = useCallback(async (roleId: string): Promise<Role | null> => {
return await fetchRoleByIdApi(request, roleId);
}, [request]);
// Generate columns from attributes (including fkSource/fkDisplayField for FK resolution)
const columns = 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,
fkSource: (attr as any).fkSource, // API endpoint for FK data
fkDisplayField: (attr as any).fkDisplayField, // Which field of FK target to display
}));
// Create role
const handleCreate = useCallback(async (roleData: Partial<Role>): Promise<boolean> => {
try {
await createRoleApi(request, roleData);
await fetchRoles();
return true;
} catch (error: any) {
console.error('Error creating role:', error);
return false;
}
}, [request, fetchRoles]);
// Update role
const handleUpdate = useCallback(async (roleId: string, updateData: RoleUpdateData): Promise<boolean> => {
try {
updateOptimistically(roleId, updateData);
await updateRoleApi(request, roleId, updateData);
return true;
} catch (error: any) {
console.error('Error updating role:', error);
await fetchRoles();
return false;
}
}, [request, fetchRoles]);
// Delete role
const handleDelete = useCallback(async (roleId: string): Promise<boolean> => {
try {
removeOptimistically(roleId);
await deleteRoleApi(request, roleId);
return true;
} catch (error: any) {
console.error('Error deleting role:', error);
await fetchRoles();
return false;
}
}, [request, fetchRoles]);
// Inline update
const handleInlineUpdate = useCallback(async (
roleId: string,
updateData: Partial<Role>
): Promise<void> => {
await handleUpdate(roleId, updateData);
}, [handleUpdate]);
// Load data on mount
useEffect(() => {
fetchAttributes();
fetchPermissions();
fetchRoles();
}, []);
return {
roles,
attributes,
columns,
permissions,
pagination,
loading,
error,
refetch: fetchRoles,
fetchRoleById,
handleCreate,
handleUpdate,
handleDelete,
handleInlineUpdate,
updateOptimistically,
};
}
export default useAdminRoles;

View file

@ -1,7 +1,15 @@
/**
* Trustee Hooks
*
* Hooks für das Trustee-Feature mit Instanz-Kontext.
* Die instanceId wird automatisch aus der URL gelesen.
*/
import { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from './useApi';
import api from '../api';
import { usePermissions, type UserPermissions } from './usePermissions';
import { useInstanceId } from './useCurrentInstance';
import {
// Types
type TrusteeOrganisation,
@ -51,6 +59,7 @@ import {
// Position-Document API
fetchPositionDocuments as fetchPositionDocumentsApi,
createPositionDocument as createPositionDocumentApi,
updatePositionDocument as updatePositionDocumentApi,
deletePositionDocument as deletePositionDocumentApi,
} from '../api/trusteeApi';
@ -94,15 +103,18 @@ export interface AttributeDefinition {
interface TrusteeEntityConfig<T> {
entityName: string;
fetchAll: (request: any, params?: PaginationParams) => Promise<any>;
fetchById: (request: any, id: string) => Promise<T | null>;
create: (request: any, data: Partial<T>) => Promise<T>;
update: (request: any, id: string, data: Partial<T>) => Promise<T>;
deleteItem: (request: any, id: string) => Promise<void>;
fetchAll: (request: any, instanceId: string, params?: PaginationParams) => Promise<any>;
fetchById: (request: any, instanceId: string, id: string) => Promise<T | null>;
create: (request: any, instanceId: string, data: Partial<T>) => Promise<T>;
update: (request: any, instanceId: string, id: string, data: Partial<T>) => Promise<T>;
deleteItem: (request: any, instanceId: string, id: string) => Promise<void>;
}
function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntityConfig<T>) {
return function useTrusteeEntity() {
// Hole instanceId aus URL-Kontext
const instanceId = useInstanceId();
const [items, setItems] = useState<T[]>([]);
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
@ -116,8 +128,10 @@ function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntit
const { checkPermission } = usePermissions();
const fetchAttributes = useCallback(async () => {
if (!instanceId) return [];
try {
const response = await api.get(`/api/attributes/${config.entityName}`);
const response = await api.get(`/api/trustee/${instanceId}/attributes/${config.entityName}`);
let attrs: AttributeDefinition[] = [];
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
attrs = response.data.attributes;
@ -131,11 +145,13 @@ function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntit
setAttributes([]);
return [];
}
}, []);
}, [instanceId]);
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) {
@ -153,8 +169,13 @@ function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntit
}, [checkPermission]);
const fetchItems = useCallback(async (params?: PaginationParams) => {
if (!instanceId) {
setItems([]);
return;
}
try {
const data = await config.fetchAll(request, params);
const data = await config.fetchAll(request, instanceId, params);
if (data && typeof data === 'object' && 'items' in data) {
const fetchedItems = Array.isArray(data.items) ? data.items : [];
@ -171,7 +192,7 @@ function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntit
setItems([]);
setPagination(null);
}
}, [request]);
}, [request, instanceId]);
const removeOptimistically = (itemId: string) => {
setItems(prev => prev.filter(item => item.id !== itemId));
@ -188,8 +209,9 @@ function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntit
};
const fetchById = useCallback(async (itemId: string): Promise<T | null> => {
return await config.fetchById(request, itemId);
}, [request]);
if (!instanceId) return null;
return await config.fetchById(request, instanceId, itemId);
}, [request, instanceId]);
const generateEditFieldsFromAttributes = useCallback(() => {
if (!attributes || attributes.length === 0) {
@ -198,11 +220,9 @@ function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntit
return attributes
.filter(attr => {
// For EDIT mode: filter out readonly fields and system fields
if (attr.readonly === true || attr.editable === false) {
return false;
}
// Also filter out 'id' for edit mode (id cannot be changed)
if (attr.name === 'id') {
return false;
}
@ -265,7 +285,6 @@ function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntit
});
}, [attributes]);
// Generate fields for CREATE forms - includes all required fields like 'id'
const generateCreateFieldsFromAttributes = useCallback(() => {
if (!attributes || attributes.length === 0) {
return [];
@ -273,8 +292,6 @@ function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntit
return attributes
.filter(attr => {
// For CREATE mode: include all user-editable fields including 'id'
// Only filter out system-generated fields
const systemFields = ['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt', 'mandateId'];
return !systemFields.includes(attr.name);
})
@ -325,7 +342,7 @@ function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntit
key: attr.name,
label: attr.label || attr.name,
type: fieldType,
editable: true, // All fields are editable in create mode
editable: true,
required: attr.required === true,
options,
optionsReference,
@ -341,14 +358,14 @@ function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntit
return await fetchAttributes();
}, [attributes, fetchAttributes]);
// Lade Daten wenn instanceId verfügbar
useEffect(() => {
fetchAttributes();
fetchPermissions();
}, [fetchAttributes, fetchPermissions]);
useEffect(() => {
fetchItems();
}, [fetchItems]);
if (instanceId) {
fetchAttributes();
fetchPermissions();
fetchItems();
}
}, [instanceId, fetchAttributes, fetchPermissions, fetchItems]);
return {
items,
@ -363,13 +380,17 @@ function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntit
fetchById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
ensureAttributesLoaded,
instanceId // Auch instanceId zurückgeben für Operations-Hook
};
};
}
function _createTrusteeOperationsHook<T extends { id: string }>(config: TrusteeEntityConfig<T>) {
return function useTrusteeEntityOperations() {
// Hole instanceId aus URL-Kontext
const instanceId = useInstanceId();
const [deletingItems, setDeletingItems] = useState<Set<string>>(new Set());
const [creatingItem, setCreatingItem] = useState(false);
const { request, isLoading } = useApiRequest();
@ -377,12 +398,17 @@ function _createTrusteeOperationsHook<T extends { id: string }>(config: TrusteeE
const [createError, setCreateError] = useState<string | null>(null);
const [updateError, setUpdateError] = useState<string | null>(null);
const handleDelete = async (itemId: string) => {
const handleDelete = useCallback(async (itemId: string) => {
if (!instanceId) {
setDeleteError('No instance context');
return false;
}
setDeleteError(null);
setDeletingItems(prev => new Set(prev).add(itemId));
try {
await config.deleteItem(request, itemId);
await config.deleteItem(request, instanceId, itemId);
await new Promise(resolve => setTimeout(resolve, 300));
return true;
} catch (error: any) {
@ -395,20 +421,23 @@ function _createTrusteeOperationsHook<T extends { id: string }>(config: TrusteeE
return newSet;
});
}
};
}, [request, instanceId]);
const handleCreate = async (itemData: Partial<T>) => {
const handleCreate = useCallback(async (itemData: Partial<T>) => {
if (!instanceId) {
setCreateError('No instance context');
return { success: false, error: 'No instance context' };
}
setCreateError(null);
setCreatingItem(true);
// Debug: Log what data is being sent to the backend
console.warn('🔧 handleCreate called with itemData:', itemData);
try {
const newItem = await config.create(request, itemData);
const newItem = await config.create(request, instanceId, itemData);
return { success: true, data: newItem };
} catch (error: any) {
// Debug: Log full error details
console.error('🔧 handleCreate error:', {
message: error.message,
response: error.response?.data,
@ -420,13 +449,18 @@ function _createTrusteeOperationsHook<T extends { id: string }>(config: TrusteeE
} finally {
setCreatingItem(false);
}
};
}, [request, instanceId]);
const handleUpdate = async (itemId: string, updateData: Partial<T>) => {
const handleUpdate = useCallback(async (itemId: string, updateData: Partial<T>) => {
if (!instanceId) {
setUpdateError('No instance context');
return { success: false, error: 'No instance context' };
}
setUpdateError(null);
try {
const updatedItem = await config.update(request, itemId, updateData);
const updatedItem = await config.update(request, instanceId, itemId, updateData);
return { success: true, data: updatedItem };
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || 'Failed to update';
@ -439,7 +473,7 @@ function _createTrusteeOperationsHook<T extends { id: string }>(config: TrusteeE
isValidationError: error.response?.status === 400
};
}
};
}, [request, instanceId]);
return {
deletingItems,
@ -450,7 +484,8 @@ function _createTrusteeOperationsHook<T extends { id: string }>(config: TrusteeE
handleDelete,
handleCreate,
handleUpdate,
isLoading
isLoading,
instanceId
};
};
}
@ -558,9 +593,9 @@ export const useTrusteePositionOperations = _createTrusteeOperationsHook(positio
const positionDocumentConfig: TrusteeEntityConfig<TrusteePositionDocument> = {
entityName: 'TrusteePositionDocument',
fetchAll: fetchPositionDocumentsApi,
fetchById: async () => null, // Not typically needed
fetchById: async () => null,
create: createPositionDocumentApi,
update: async () => { throw new Error('Update not supported for position-document links'); },
update: updatePositionDocumentApi,
deleteItem: deletePositionDocumentApi
};

View file

@ -0,0 +1,287 @@
/**
* useTrusteeOptions Hook
*
* Zentraler Hook für Trustee-Options (Dropdowns, Label-Auflösung).
* Lädt Options von den entsprechenden /options Endpoints und cached sie.
* Unterstützt dynamische Filterung (z.B. Contracts nach Organisation).
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import api from '../api';
import { useInstanceId } from './useCurrentInstance';
// ============================================================================
// TYPES
// ============================================================================
export interface TrusteeOption {
value: string;
label: string;
}
export interface TrusteeOptionsMap {
users: TrusteeOption[];
organisations: TrusteeOption[];
roles: TrusteeOption[];
contracts: TrusteeOption[];
documents: TrusteeOption[];
positions: TrusteeOption[];
}
export type TrusteeOptionEntity = keyof TrusteeOptionsMap;
interface LoadOptionsParams {
organisationId?: string;
contractId?: string;
}
// ============================================================================
// HOOK
// ============================================================================
/**
* Hook für Trustee-Options.
*
* @param autoLoad - Array von Entity-Namen, die automatisch beim Mount geladen werden sollen
* @returns Options-Map, Lade-Funktion, Label-Getter
*
* @example
* ```tsx
* // Auto-load users, organisations und roles
* const { options, getLabel, loading } = useTrusteeOptions(['users', 'organisations', 'roles']);
*
* // Label für eine userId auflösen
* const userName = getLabel('users', access.userId);
*
* // Contracts für spezifische Organisation nachladen
* await loadOptions(['contracts'], { organisationId: 'org-123' });
* ```
*/
export function useTrusteeOptions(autoLoad: TrusteeOptionEntity[] = []) {
const instanceId = useInstanceId();
const [options, setOptions] = useState<Partial<TrusteeOptionsMap>>({
users: [],
organisations: [],
roles: [],
contracts: [],
documents: [],
positions: [],
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loadedEntities, setLoadedEntities] = useState<Set<string>>(new Set());
/**
* Lädt Options für angegebene Entities.
*
* @param entities - Array von Entity-Namen
* @param filters - Optionale Filter (z.B. organisationId für Contracts)
*/
const loadOptions = useCallback(async (
entities: TrusteeOptionEntity[],
filters?: LoadOptionsParams
): Promise<void> => {
if (!instanceId && entities.some(e => e !== 'users')) {
console.warn('useTrusteeOptions: No instanceId available, skipping load for trustee entities');
return;
}
setLoading(true);
setError(null);
try {
const promises = entities.map(async (entity) => {
let url: string;
if (entity === 'users') {
// Users kommen aus dem globalen API-Endpoint
url = '/api/users/options';
} else {
// Trustee-Entities kommen aus dem Feature-API mit instanceId
url = `/api/trustee/${instanceId}/${entity}/options`;
// Dynamische Filterung für Contracts nach Organisation
if (filters?.organisationId && entity === 'contracts') {
url += `?organisationId=${encodeURIComponent(filters.organisationId)}`;
}
// Dynamische Filterung für Documents/Positions nach Contract
if (filters?.contractId && (entity === 'documents' || entity === 'positions')) {
url += `?contractId=${encodeURIComponent(filters.contractId)}`;
}
}
const response = await api.get(url);
return { entity, data: response.data as TrusteeOption[] };
});
const results = await Promise.all(promises);
const newOptions: Partial<TrusteeOptionsMap> = {};
results.forEach(({ entity, data }) => {
newOptions[entity] = Array.isArray(data) ? data : [];
});
setOptions(prev => ({ ...prev, ...newOptions }));
// Merke geladene Entities (nur ohne Filter)
if (!filters) {
setLoadedEntities(prev => {
const newSet = new Set(prev);
entities.forEach(e => newSet.add(e));
return newSet;
});
}
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to load options';
setError(errorMessage);
console.error('useTrusteeOptions: Error loading options:', err);
} finally {
setLoading(false);
}
}, [instanceId]);
/**
* Gibt das Label für einen Wert zurück.
* Falls nicht gefunden, wird der Wert selbst zurückgegeben.
*/
const getLabel = useCallback((entity: TrusteeOptionEntity, value: string | null | undefined): string => {
if (value === null || value === undefined || value === '') {
return '-';
}
const entityOptions = options[entity];
if (!entityOptions || entityOptions.length === 0) {
return value;
}
const found = entityOptions.find(o => o.value === value);
return found?.label || value;
}, [options]);
/**
* Gibt Options für eine Entity zurück.
*/
const getOptions = useCallback((entity: TrusteeOptionEntity): TrusteeOption[] => {
return options[entity] || [];
}, [options]);
/**
* Prüft ob Options für eine Entity geladen wurden.
*/
const isLoaded = useCallback((entity: TrusteeOptionEntity): boolean => {
return loadedEntities.has(entity);
}, [loadedEntities]);
/**
* Lädt Options für Contracts einer spezifischen Organisation.
* Nützlich für abhängige Dropdowns.
*/
const loadContractsForOrganisation = useCallback(async (organisationId: string): Promise<TrusteeOption[]> => {
if (!instanceId || !organisationId) {
return [];
}
try {
const url = `/api/trustee/${instanceId}/contracts/options?organisationId=${encodeURIComponent(organisationId)}`;
const response = await api.get(url);
const contractOptions = Array.isArray(response.data) ? response.data : [];
// Update Options-State
setOptions(prev => ({ ...prev, contracts: contractOptions }));
return contractOptions;
} catch (err) {
console.error('useTrusteeOptions: Error loading contracts for organisation:', err);
return [];
}
}, [instanceId]);
/**
* Erstellt eine Lookup-Map für schnelle Label-Auflösung.
*/
const createLookupMap = useCallback((entity: TrusteeOptionEntity): Map<string, string> => {
const map = new Map<string, string>();
const entityOptions = options[entity] || [];
entityOptions.forEach(opt => {
map.set(opt.value, opt.label);
});
return map;
}, [options]);
// Memoized Lookup-Maps für Performance
const lookupMaps = useMemo(() => ({
users: createLookupMap('users'),
organisations: createLookupMap('organisations'),
roles: createLookupMap('roles'),
contracts: createLookupMap('contracts'),
documents: createLookupMap('documents'),
positions: createLookupMap('positions'),
}), [createLookupMap]);
/**
* Schnelle Label-Auflösung via Lookup-Map.
*/
const getLabelFast = useCallback((entity: TrusteeOptionEntity, value: string | null | undefined): string => {
if (value === null || value === undefined || value === '') {
return '-';
}
return lookupMaps[entity].get(value) || value;
}, [lookupMaps]);
// Auto-Load beim Mount
useEffect(() => {
if (autoLoad.length > 0) {
// Nur laden wenn instanceId verfügbar (oder nur 'users' geladen werden soll)
const needsInstance = autoLoad.some(e => e !== 'users');
if (!needsInstance || instanceId) {
loadOptions(autoLoad);
}
}
}, [instanceId, autoLoad.join(',')]); // autoLoad als String-Join für Dependency-Vergleich
return {
// State
options,
loading,
error,
// Actions
loadOptions,
loadContractsForOrganisation,
// Getters
getLabel,
getLabelFast,
getOptions,
isLoaded,
createLookupMap,
// Context
instanceId,
};
}
// ============================================================================
// CONVENIENCE EXPORTS
// ============================================================================
/**
* Hook speziell für TrusteeAccessView.
* Lädt automatisch users, organisations und roles.
*/
export function useTrusteeAccessOptions() {
return useTrusteeOptions(['users', 'organisations', 'roles']);
}
/**
* Hook speziell für Views mit Organisation+Contract Dropdowns.
* Lädt automatisch organisations und contracts.
*/
export function useTrusteeOrgContractOptions() {
return useTrusteeOptions(['organisations', 'contracts']);
}
export default useTrusteeOptions;

View file

@ -0,0 +1,285 @@
/**
* useUserMandates Hook
*
* Hook for managing user-mandate memberships (which users belong to which mandates with which roles).
* Uses the /api/mandates/{mandateId}/users endpoints.
*/
import { useState, useCallback, useRef } from 'react';
import api from '../api';
// Types
export interface PaginationParams {
page?: number;
pageSize?: number;
search?: string;
filters?: Record<string, any>;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
}
export interface PaginationMetadata {
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
}
export interface MandateUser {
id: string; // UserMandate ID as primary key
userId: string;
username: string;
email: string | null;
firstname: string | null;
lastname: string | null;
roleIds: string[];
enabled: boolean;
}
export interface UserMandateCreate {
targetUserId: string;
roleIds: string[];
}
export interface UserMandateResponse {
id: string; // UserMandate ID as primary key
userId: string;
mandateId: string;
roleIds: string[];
enabled: boolean;
}
export interface Role {
id: string;
roleLabel: string;
description?: string | { [key: string]: string };
mandateId?: string;
featureInstanceId?: string;
isSystemRole?: boolean;
}
export interface Mandate {
id: string;
name: string | { [key: string]: string };
code?: string;
language?: string;
}
/**
* Hook for managing user-mandate memberships
*/
export function useUserMandates() {
const [users, setUsers] = useState<MandateUser[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pagination, setPagination] = useState<PaginationMetadata | null>(null);
// Store current mandate for refetch
const currentMandateIdRef = useRef<string>('');
/**
* Fetch all users in a specific mandate with optional pagination
*/
const fetchMandateUsers = useCallback(async (
mandateIdOrPagination?: string | PaginationParams
): Promise<MandateUser[]> => {
setLoading(true);
setError(null);
let mandateId: string;
let paginationParams: PaginationParams = {};
// Handle backward compatibility
if (typeof mandateIdOrPagination === 'string') {
mandateId = mandateIdOrPagination;
currentMandateIdRef.current = mandateId;
} else if (mandateIdOrPagination && typeof mandateIdOrPagination === 'object') {
paginationParams = mandateIdOrPagination;
mandateId = currentMandateIdRef.current;
} else {
mandateId = currentMandateIdRef.current;
}
if (!mandateId) {
setLoading(false);
return [];
}
try {
const params = new URLSearchParams();
if (Object.keys(paginationParams).length > 0) {
params.append('pagination', JSON.stringify(paginationParams));
}
const url = params.toString()
? `/api/mandates/${mandateId}/users?${params.toString()}`
: `/api/mandates/${mandateId}/users`;
const response = await api.get(url);
let data: MandateUser[] = [];
if (response.data?.items && Array.isArray(response.data.items)) {
data = response.data.items;
if (response.data.pagination) {
setPagination(response.data.pagination);
}
} else {
data = Array.isArray(response.data) ? response.data : [];
}
setUsers(data);
return data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to fetch mandate users';
setError(errorMessage);
setUsers([]);
setPagination(null);
return [];
} finally {
setLoading(false);
}
}, []);
/**
* Add a user to a mandate with specified roles
*/
const addUserToMandate = useCallback(async (
mandateId: string,
data: UserMandateCreate
): Promise<{ success: boolean; data?: UserMandateResponse; error?: string }> => {
setLoading(true);
setError(null);
try {
const response = await api.post(`/api/mandates/${mandateId}/users`, data);
return { success: true, data: response.data };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to add user to mandate';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, []);
/**
* Remove a user from a mandate
*/
const removeUserFromMandate = useCallback(async (
mandateId: string,
userId: string
): Promise<{ success: boolean; error?: string }> => {
setLoading(true);
setError(null);
try {
await api.delete(`/api/mandates/${mandateId}/users/${userId}`);
// Optimistically update the local state
setUsers(prev => prev.filter(u => u.userId !== userId));
return { success: true };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to remove user from mandate';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, []);
/**
* Update a user's roles within a mandate
*/
const updateUserRoles = useCallback(async (
mandateId: string,
userId: string,
roleIds: string[]
): Promise<{ success: boolean; data?: UserMandateResponse; error?: string }> => {
setLoading(true);
setError(null);
try {
const response = await api.put(`/api/mandates/${mandateId}/users/${userId}/roles`, roleIds);
// Optimistically update the local state
setUsers(prev => prev.map(u =>
u.userId === userId ? { ...u, roleIds } : u
));
return { success: true, data: response.data };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to update user roles';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, []);
/**
* Fetch all available mandates (for selection)
*/
const fetchMandates = useCallback(async (): Promise<Mandate[]> => {
try {
const response = await api.get('/api/mandates/');
if (response.data?.items && Array.isArray(response.data.items)) {
return response.data.items;
}
return Array.isArray(response.data) ? response.data : [];
} catch (err: any) {
console.error('Error fetching mandates:', err);
return [];
}
}, []);
/**
* Fetch all available roles (global and mandate-specific)
*/
const fetchRoles = useCallback(async (mandateId?: string): Promise<Role[]> => {
try {
const response = await api.get('/api/rbac/roles');
let roles: Role[] = [];
if (response.data?.items && Array.isArray(response.data.items)) {
roles = response.data.items;
} else if (Array.isArray(response.data)) {
roles = response.data;
}
// Filter to global roles and roles for this mandate
if (mandateId) {
return roles.filter(r =>
!r.mandateId || r.mandateId === mandateId
);
}
return roles;
} catch (err: any) {
console.error('Error fetching roles:', err);
return [];
}
}, []);
/**
* Fetch all users (for selection when adding to mandate)
*/
const fetchAllUsers = useCallback(async (): Promise<Array<{id: string; username: string; email?: string; fullName?: string}>> => {
try {
const response = await api.get('/api/users/');
if (response.data?.items && Array.isArray(response.data.items)) {
return response.data.items;
}
return Array.isArray(response.data) ? response.data : [];
} catch (err: any) {
console.error('Error fetching users:', err);
return [];
}
}, []);
return {
users,
loading,
error,
pagination,
fetchMandateUsers,
addUserToMandate,
removeUserFromMandate,
updateUserRoles,
fetchMandates,
fetchRoles,
fetchAllUsers,
};
}
export default useUserMandates;

Some files were not shown because too many files have changed in this diff Show more