Compare commits

..

24 commits

Author SHA1 Message Date
Ida
60ff00802c continued testing and improvement 2026-05-14 19:25:44 +02:00
Ida
b5084c028e fix: handover fix, if/else node extended comparison mode 2026-05-14 18:38:44 +02:00
Ida
587dad5cf9 feat: extract content node angepasst für mehr optionen 2026-05-14 13:06:31 +02:00
Ida
0fd05f638f feat: seperated accordion list component to be seperate and reusable ui component 2026-05-14 12:08:05 +02:00
Ida
aa61e00af6 fix: moved cron schedule calculator to utils for better reusability 2026-05-14 11:57:45 +02:00
Ida
7e2ffb42fe fix: schedule node to be more user friendly 2026-05-14 11:52:17 +02:00
Ida
dd26ea132d fix: formular trigger 2026-05-14 11:15:16 +02:00
Ida
50a3df5c18 feat: added trigger nodes such that they are not hidden anymore 2026-05-14 10:57:29 +02:00
Ida
e7f2272c30 fix: formular node aufgeräumt und files besser sortiert 2026-05-13 16:58:02 +02:00
Ida
ef9955257e fix: pfeile zeichnen nicht mehr verbugged 2026-05-13 16:46:10 +02:00
Ida
6890a38546 fix: arrow beginning und ending 2026-05-13 16:41:03 +02:00
Ida
590178b8f2 feat: ctrl c shortcut und pfeil zeichnen 2026-05-13 16:24:38 +02:00
Ida
e3c93dc220 fix: anordnen knopf wieder hinzugefügt mit verschachtelten rangpfaden 2026-05-13 16:16:41 +02:00
Ida
600e0c87dc fix: leerer select in node im config panel leading to white screen 2026-05-13 15:48:30 +02:00
Ida
9e36075f0e fix: resized canvas size and fixed delete comments 2026-05-13 15:42:00 +02:00
Ida
3a7a34a4f3 feat: added edit button bar und kommentar-funktion 2026-05-13 15:36:16 +02:00
Ida
c13489e232 fix: removed legacy code 2026-05-13 15:03:37 +02:00
Ida
4bf6677bc5 fix: clean header 2026-05-13 15:00:06 +02:00
Ida
8860f49714 fix: removed duplicate keepAlive Files for pages and views and instead implemented single source of truth for mounted pages, removed unused legacy core page manager 2026-05-13 14:25:20 +02:00
Ida
74dc7b85f8 continous work on grafischer editor, loop verbessert 2026-05-13 13:30:45 +02:00
Ida
66a7a6fa56 neue context nodes hinzugefügt, muss noch debuggt werden 2026-05-12 06:34:32 +02:00
Ida
294803e66c node handover standartisiert, kein hardcoden mehr, inhalt extraktion node verbessert, output ports vereinheitlicht mit user im blick 2026-05-12 06:34:32 +02:00
Ida
ae630201ba finished file tree folder selection in file create node 2026-05-12 06:34:30 +02:00
Ida
7fb96451a5 workign on folder location in file create node 2026-05-12 06:34:10 +02:00
501 changed files with 26695 additions and 13420 deletions

View file

@ -0,0 +1,144 @@
# Implement RBAC Roles Page
## Overview
Implement the RBAC roles admin page following the exact pattern used in `mandates.ts`. This includes creating the API file, custom hook for state management, updating the page configuration with CreateButton header button, and adding translations in all three languages (German, English, French).
## Files to Create/Modify
### 1. Create API File: `frontend_nyla/src/api/roleApi.ts`
- Follow the pattern from `mandateApi.ts`
- Implement all required endpoints:
- `fetchRoles()` - GET /api/rbac/roles (with pagination support)
- `fetchRoleById()` - GET /api/rbac/roles/{roleId}
- `fetchRoleOptions()` - GET /api/rbac/roles/options
- `createRole()` - POST /api/rbac/roles
- `updateRole()` - PUT /api/rbac/roles/{roleId}
- `deleteRole()` - DELETE /api/rbac/roles/{roleId}
- Include TypeScript types: `Role`, `RoleUpdateData`, `PaginationParams`, `PaginatedResponse`
### 2. Create Hook: `frontend_nyla/src/hooks/useAdminRbacRoles.ts`
- Follow the exact pattern from `useAdminMandates.ts`
- Create two hooks:
- `useRbacRoles()` - Main hook for data fetching and state management
- Fetch roles with pagination support
- Fetch attributes from `/api/attributes/Role` using `fetchAttributes(request, 'Role')`
- Fetch permissions using `checkPermission('DATA', 'Role')`
- Implement `generateEditFieldsFromAttributes()` using `attributeTypeMapper` utilities
- Implement `generateCreateFieldsFromAttributes()` using `attributeTypeMapper` utilities
- Implement `ensureAttributesLoaded()` for EditActionButton
- Implement optimistic updates (`removeOptimistically`, `updateOptimistically`)
- Return pagination info, attributes, permissions, and all required functions
- `useRbacRoleOperations()` - Operations hook for CRUD
- `handleRoleDelete()` - Delete with loading state tracking
- `handleRoleCreate()` - Create with error handling
- `handleRoleUpdate()` - Update with error handling
- Track loading states in Sets (deletingRoles, editingRoles, creatingRole)
- Return error states (deleteError, createError, updateError)
### 3. Update Page Configuration: `frontend_nyla/src/core/PageManager/data/pages/admin/rbac-role.ts`
- Follow the exact structure from `mandates.ts`
- Import `FaPlus` from `react-icons/fa` for the create button icon
- Create `createRbacRolesHook()` factory function that:
- Uses `useRbacRoles()` and `useRbacRoleOperations()`
- Converts attributes to columns using `attributesToColumns()` helper
- Implements `handleDeleteSingle` and `handleDeleteMultiple` callbacks
- Returns all required data for FormGeneratorTable
- Update `rbacRolePageData`:
- Add header button with `FaPlus` icon for creating roles (following mandates.ts pattern):
```typescript
headerButtons: [
{
id: 'add-role',
label: 'admin.rbac-role.new_button',
variant: 'primary',
size: 'md',
icon: FaPlus,
formConfig: {
fields: [], // Empty array - fields will be generated dynamically from attributes
popupTitle: 'admin.rbac-role.modal.create.title',
popupSize: 'medium',
createOperationName: 'handleRoleCreate',
successMessage: 'admin.rbac-role.create.success',
errorMessage: 'admin.rbac-role.create.error'
}
}
]
```
- Add table content section with:
- `hookFactory: createRbacRolesHook`
- Action buttons: edit and delete (following mandates pattern)
- Configure edit button with `fetchItemFunctionName: 'fetchRoleById'`
- Configure delete button with proper operation names
- Add permission-based disabled logic
- Keep existing privilege checker (sysadmin only)
### 4. Update Translations: All three locale files
- **German (`frontend_nyla/src/locales/de.ts`)**: Add missing translations after line 756:
- `'admin.rbac-role.new_button': 'Rolle hinzufügen'`
- `'admin.rbac-role.action.edit': 'Bearbeiten'`
- `'admin.rbac-role.action.delete': 'Löschen'`
- `'admin.rbac-role.modal.create.title': 'Neue Rolle erstellen'`
- `'admin.rbac-role.create.success': 'Rolle erfolgreich erstellt'`
- `'admin.rbac-role.create.error': 'Fehler beim Erstellen der Rolle'`
- **English (`frontend_nyla/src/locales/en.ts`)**: Add missing translations after line 756:
- `'admin.rbac-role.new_button': 'Add Role'`
- `'admin.rbac-role.action.edit': 'Edit'`
- `'admin.rbac-role.action.delete': 'Delete'`
- `'admin.rbac-role.modal.create.title': 'Create New Role'`
- `'admin.rbac-role.create.success': 'Role created successfully'`
- `'admin.rbac-role.create.error': 'Error creating role'`
- **French (`frontend_nyla/src/locales/fr.ts`)**: Add missing translations after line 756:
- `'admin.rbac-role.new_button': 'Ajouter un rôle'`
- `'admin.rbac-role.action.edit': 'Modifier'`
- `'admin.rbac-role.action.delete': 'Supprimer'`
- `'admin.rbac-role.modal.create.title': 'Créer un nouveau rôle'`
- `'admin.rbac-role.create.success': 'Rôle créé avec succès'`
- `'admin.rbac-role.create.error': 'Erreur lors de la création du rôle'`
## Implementation Details
### API File Structure
- Use `ApiRequestFunction` type from `useApi`
- Support pagination parameters (page, pageSize, sort, filters, search)
- Handle both paginated and non-paginated responses
- Use `/api/rbac/roles` as base URL
- Use `/api/attributes/Role` for attributes endpoint
### Hook Pattern
- Use `useApiRequest` hook for API calls
- Use `usePermissions` hook for permission checking
- Use `getUserDataCache()` to check authentication before fetching
- Implement attribute type mapping using utilities from `attributeTypeMapper.ts`:
- `isCheckboxType()`, `isSelectType()`, `isMultiselectType()`, `isDateTimeType()`, `isTextareaType()`
- Filter out non-editable fields (id, readonly fields, etc.)
- Handle options arrays and option references
### Page Configuration Pattern
- Use `attributesToColumns()` helper to convert attributes to column config
- Disable filtering for date/timestamp fields using `isDateTimeType()`
- Configure action buttons with proper field mappings and operation names
- Use permission-based disabled logic for buttons
- Set `entityType: 'Role'` for EditActionButton
- Add header button using CreateButton component pattern (via formConfig in headerButtons)
## Key Dependencies
- `useApiRequest` from `hooks/useApi`
- `usePermissions` from `hooks/usePermissions`
- `fetchAttributes` from `api/attributesApi`
- `attributeTypeMapper` utilities from `utils/attributeTypeMapper`
- `FormGeneratorTable` component
- `EditActionButton` and `DeleteActionButton` components
- `CreateButton` component (rendered via PageRenderer from headerButtons formConfig)
- `FaPlus` icon from `react-icons/fa`
## Testing Considerations
- Verify all API endpoints are called correctly
- Ensure attributes are fetched from `/api/attributes/Role`
- Verify permission checks work correctly
- Test create, edit, delete operations
- Verify optimistic updates work
- Check that date/timestamp fields are not filterable
- Verify CreateButton appears in header and opens create modal
- Verify translations work in all three languages

View file

@ -1,53 +0,0 @@
name: Deploy Nyla Frontend to Integration
on:
push:
branches:
- int
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
SERVER_HOST: porta-int.poweron.swiss
SERVER_USER: ubuntu
APP_DIR: /srv/nyla/current
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Setup SSH
run: |
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
- name: Deploy
run: |
ssh -i ~/.ssh/deploy_key ${{ env.SERVER_USER }}@${{ env.SERVER_HOST }} "
set -e
cd ${{ env.APP_DIR }}
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/ui-nyla.git
git fetch origin int
git reset --hard origin/int
cp config/env-int.env .env
rm -f config/env-*.env
npm ci
npm run build:int
"
- name: Health Check
run: |
HTTP_STATUS=$(curl -sk -o /dev/null -w "%{http_code}" \
https://${{ env.SERVER_HOST }}/ || echo "000")
if [ "$HTTP_STATUS" = "200" ]; then
echo "Health check passed! (HTTP $HTTP_STATUS)"
else
echo "Health check returned HTTP $HTTP_STATUS"
fi

View file

@ -1,53 +0,0 @@
name: Deploy Nyla Frontend to Production
on:
push:
branches:
- main
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
SERVER_HOST: porta.poweron.swiss
SERVER_USER: ubuntu
APP_DIR: /srv/nyla/current
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Setup SSH
run: |
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
- name: Deploy
run: |
ssh -i ~/.ssh/deploy_key ${{ env.SERVER_USER }}@${{ env.SERVER_HOST }} "
set -e
cd ${{ env.APP_DIR }}
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/ui-nyla.git
git fetch origin main
git reset --hard origin/main
cp config/env-prod.env .env
rm -f config/env-*.env
npm ci
npm run build:prod
"
- name: Health Check
run: |
HTTP_STATUS=$(curl -sk -o /dev/null -w "%{http_code}" \
https://${{ env.SERVER_HOST }}/ || echo "000")
if [ "$HTTP_STATUS" = "200" ]; then
echo "Health check passed! (HTTP $HTTP_STATUS)"
else
echo "Health check returned HTTP $HTTP_STATUS"
fi

71
.github/workflows/poweron_nyla_int.yml vendored Normal file
View file

@ -0,0 +1,71 @@
name: Deploy Nyla Frontend to Integration
on:
push:
branches:
- int
workflow_dispatch:
# Cancel in-progress runs when a new run is triggered (saves logs/storage)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Copy integration environment file
run: |
cp config/env-poweron-nyla-int.env .env
- name: Install dependencies
run: |
npm ci
npm install express
- name: Build React app for integration
run: npm run build:int
- name: Prepare deployment package
run: |
# Create deployment package with build files and necessary configs
mkdir deploy
cp -r dist/* deploy/
# Create a simple server.js for serving the app
echo "const express = require('express');" > deploy/server.js
echo "const path = require('path');" >> deploy/server.js
echo "const app = express();" >> deploy/server.js
echo "app.use(express.static(path.join(__dirname)));" >> deploy/server.js
echo "app.get('/*', function(req, res) { res.sendFile(path.join(__dirname, 'index.html')); });" >> deploy/server.js
echo "const port = process.env.PORT || 8080;" >> deploy/server.js
echo "app.listen(port, () => console.log('Server running on port', port));" >> deploy/server.js
# Create a new package.json for deployment
echo '{
"name": "frontend-int",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2"
}
}' > deploy/package.json
- name: 'Deploy to Azure Web App'
uses: azure/webapps-deploy@v3
with:
app-name: 'poweron-nyla-int'
publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_POWERON_NYLA_INT }}
package: ./deploy

71
.github/workflows/poweron_nyla_main.yml vendored Normal file
View file

@ -0,0 +1,71 @@
name: Deploy Nyla Frontend to Production
on:
push:
branches:
- main
workflow_dispatch:
# Cancel in-progress runs when a new run is triggered (saves logs/storage)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Copy production environment file
run: |
cp config/env-poweron-nyla-prod.env .env
- name: Install dependencies
run: |
npm ci
npm install express
- name: Build React app for production
run: npm run build:prod
- name: Prepare deployment package
run: |
# Create deployment package with build files and necessary configs
mkdir deploy
cp -r dist/* deploy/
# Create a simple server.js for serving the app
echo "const express = require('express');" > deploy/server.js
echo "const path = require('path');" >> deploy/server.js
echo "const app = express();" >> deploy/server.js
echo "app.use(express.static(path.join(__dirname)));" >> deploy/server.js
echo "app.get('/*', function(req, res) { res.sendFile(path.join(__dirname, 'index.html')); });" >> deploy/server.js
echo "const port = process.env.PORT || 8080;" >> deploy/server.js
echo "app.listen(port, () => console.log('Server running on port', port));" >> deploy/server.js
# Create a new package.json for deployment
echo '{
"name": "frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2"
}
}' > deploy/package.json
- name: 'Deploy to Azure Web App'
uses: azure/webapps-deploy@v3
with:
app-name: 'poweron-nyla'
publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_POWERON_NYLA }}
package: ./deploy

5
.gitignore vendored
View file

@ -31,7 +31,4 @@ dist-ssr
.cursorignore
# Keep environment files in config/ (naming: env-<workflow>.env)
!config/env-*.env
tsc-errors.txt
scripts/i18n_missing_report.md
!config/env-*.env

View file

@ -1,24 +1,178 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* Configuration reads mandatory env vars set by .env (copied from config/env-*.env by CI).
*
* NO silent fallbacks for critical values.
* If VITE_API_BASE_URL is missing the app fails loudly at startup.
*
* Vite replaces import.meta.env.VITE_* statically at build time.
* Dynamic access via import.meta.env[key] does NOT work in production builds.
* Therefore each variable must be accessed with its literal property name.
* Simple Configuration Service
* Centralized access to environment variables with fallbacks
*/
const _apiBaseUrl: string = import.meta.env.VITE_API_BASE_URL;
// API Configuration
export const getApiBaseUrl = (): string => {
return import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
};
if (!_apiBaseUrl) {
throw new Error(
'Missing required env variable: VITE_API_BASE_URL. Ensure .env is present (cp config/env-<env>.env .env).'
);
}
export const getApiTimeout = (): number => {
return parseInt(import.meta.env.VITE_API_TIMEOUT || '10000');
};
export const getApiBaseUrl = (): string => _apiBaseUrl;
// App Configuration
export const getAppName = (): string => {
return import.meta.env.VITE_APP_NAME || 'PowerOn';
};
export const getAppName = (): string => import.meta.env.VITE_APP_NAME || 'PowerOn';
export const getAppVersion = (): string => {
return import.meta.env.VITE_APP_VERSION || '0.0.0';
};
export const getAppEnvironment = (): string => {
return import.meta.env.VITE_APP_ENVIRONMENT || 'dev';
};
// Environment Detection
export const isDevelopment = (): boolean => {
return import.meta.env.MODE === 'development' || getAppEnvironment() === 'dev';
};
export const isProduction = (): boolean => {
return import.meta.env.MODE === 'production' || getAppEnvironment() === 'prod';
};
export const isIntegration = (): boolean => {
return getAppEnvironment() === 'int';
};
// Debug Configuration
export const isDebugMode = (): boolean => {
return import.meta.env.VITE_DEBUG === 'true';
};
export const getLogLevel = (): string => {
return import.meta.env.VITE_LOG_LEVEL || 'info';
};
export const isConsoleLogsEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_CONSOLE_LOGS === 'true';
};
// Microsoft Authentication
export const getMicrosoftClientId = (): string | undefined => {
return import.meta.env.VITE_MICROSOFT_CLIENT_ID;
};
export const getMicrosoftTenantId = (): string | undefined => {
return import.meta.env.VITE_MICROSOFT_TENANT_ID;
};
export const getEntraClientSecret = (): string | undefined => {
return import.meta.env.VITE_ENTRA_CLIENT_SECRET;
};
export const getEntraAuthority = (): string | undefined => {
return import.meta.env.VITE_ENTRA_AUTHORITY;
};
export const getEntraRedirectPath = (): string | undefined => {
return import.meta.env.VITE_ENTRA_REDIRECT_PATH;
};
export const getEntraRedirectUri = (): string | undefined => {
return import.meta.env.VITE_ENTRA_REDIRECT_URI;
};
// Feature Flags (if needed in the future)
export const isFeatureEnabled = (feature: string): boolean => {
const envKey = `VITE_ENABLE_${feature.toUpperCase()}`;
return import.meta.env[envKey] === 'true';
};
// Analytics and Monitoring
export const isAnalyticsEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_ANALYTICS === 'true';
};
export const isErrorReportingEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_ERROR_REPORTING === 'true';
};
export const isPerformanceMonitoringEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_PERFORMANCE_MONITORING === 'true';
};
// Development Server (for dev environment)
export const getDevServerPort = (): number => {
return parseInt(import.meta.env.VITE_DEV_SERVER_PORT || '5176');
};
export const getDevServerHost = (): string => {
return import.meta.env.VITE_DEV_SERVER_HOST || 'localhost';
};
export const isDevServerHttps = (): boolean => {
return import.meta.env.VITE_DEV_SERVER_HTTPS === 'true';
};
// Security Configuration
export const isHttpsEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_HTTPS === 'true';
};
export const isCspEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_CSP === 'true';
};
// Test Configuration
export const isMockDataEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_MOCK_DATA === 'true';
};
export const isTestMode = (): boolean => {
return import.meta.env.VITE_ENABLE_TEST_MODE === 'true';
};
// Convenience object for easy destructuring
export const config = {
// API
getApiBaseUrl,
getApiTimeout,
// App
getAppName,
getAppVersion,
getAppEnvironment,
// Environment
isDevelopment,
isProduction,
isIntegration,
// Debug
isDebugMode,
getLogLevel,
isConsoleLogsEnabled,
// Microsoft Auth
getMicrosoftClientId,
getMicrosoftTenantId,
getEntraClientSecret,
getEntraAuthority,
getEntraRedirectPath,
getEntraRedirectUri,
// Features
isFeatureEnabled,
// Analytics
isAnalyticsEnabled,
isErrorReportingEnabled,
isPerformanceMonitoringEnabled,
// Dev Server
getDevServerPort,
getDevServerHost,
isDevServerHttps,
// Security
isHttpsEnabled,
isCspEnabled,
// Test
isMockDataEnabled,
isTestMode,
};

View file

@ -2,5 +2,5 @@
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
# Auth and secrets live on the gateway — never in frontend env.
VITE_API_BASE_URL="http://localhost:8000"
VITE_API_BASE_URL="http://localhost:8000/"
VITE_APP_NAME=PowerOn Nyla dev

View file

@ -2,5 +2,5 @@
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
# Auth and secrets live on the gateway — never in frontend env.
VITE_API_BASE_URL=https://api-int.poweron.swiss
VITE_API_BASE_URL=https://gateway-int.poweron.swiss
VITE_APP_NAME=Poweron Nyla int

View file

@ -2,5 +2,5 @@
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
# Auth and secrets live on the gateway — never in frontend env.
VITE_API_BASE_URL=https://api.poweron.swiss
VITE_API_BASE_URL=https://gateway-prod.poweron.swiss
VITE_APP_NAME=PowerOn Nyla

View file

@ -1,3 +1,12 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
export { getApiBaseUrl, getAppName } from './config';
// Export simple configuration service
export * from './config';
// Re-export commonly used functions
export {
getApiBaseUrl,
getAppName,
isDevelopment,
isProduction,
isDebugMode,
config
} from './config';

2
env.d.ts vendored
View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/// <reference types="vite/client" />
interface ImportMetaEnv {

View file

@ -185,10 +185,7 @@
</div>
<div class="footer">
<p class="legal-meta" style="margin-bottom: 0.75rem; color: #64748b; font-size: 0.85rem;">Company &amp; legal details &middot; May 2026</p>
<p><strong>PowerOn AG</strong> · Birmensdorferstrasse 94 · CH-8003 Zürich · Switzerland</p>
<p><a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
<p style="margin-top: 1rem;">&copy; 2026 PowerOn. All rights reserved.</p>
<p>&copy; 2025 PowerOn AI Platform. All rights reserved.</p>
</div>
</div>
</body>

View file

@ -140,7 +140,7 @@
</div>
<div class="last-updated">
<strong>Last Updated:</strong> May 2026
<strong>Last Updated:</strong> August 2025
</div>
<div class="content-section">
@ -272,13 +272,8 @@
<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> <a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
<p><strong>Address:</strong><br>
PowerOn AG<br>
Birmensdorferstrasse 94<br>
CH-8003 Zürich<br>
Switzerland
</p>
<p><strong>Email:</strong> privacy@poweron-ai.com</p>
<p><strong>Address:</strong> PowerOn AI Platform, Privacy Team</p>
</div>
</div>
@ -288,7 +283,7 @@
</div>
<div class="footer">
<p>&copy; 2026 PowerOn. All rights reserved.</p>
<p>&copy; 2025 PowerOn AI Platform. All rights reserved.</p>
</div>
</div>
</body>

View file

@ -153,7 +153,7 @@
</div>
<div class="last-updated">
<strong>Last Updated:</strong> May 2026
<strong>Last Updated:</strong> August 2025
</div>
<div class="content-section">
@ -315,13 +315,8 @@
<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> <a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
<p><strong>Address:</strong><br>
PowerOn AG<br>
Birmensdorferstrasse 94<br>
CH-8003 Zürich<br>
Switzerland
</p>
<p><strong>Email:</strong> legal@poweron-ai.com</p>
<p><strong>Address:</strong> PowerOn AI Platform, Legal Department</p>
</div>
</div>
@ -331,7 +326,7 @@
</div>
<div class="footer">
<p>&copy; 2026 PowerOn. All rights reserved.</p>
<p>&copy; 2025 PowerOn AI Platform. All rights reserved.</p>
</div>
</div>
</body>

15027
scripts/i18n_missing_report.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* App.tsx
*
@ -41,12 +39,11 @@ import { GDPRPage } from './pages/GDPR';
import StorePage from './pages/Store';
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
import { FeatureViewPage } from './pages/FeatureView';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage } from './pages/admin';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage, AdminDatabaseHealthPage } from './pages/admin';
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
import { WorkflowAutomationPage } from './pages/workflowAutomation/WorkflowAutomationHubPage';
import { RagInventoryPage } from './pages/RagInventoryPage';
import { AutomationsDashboardPage } from './pages/AutomationsDashboardPage';
import { ComplianceAuditPage } from './pages/ComplianceAuditPage';
function App() {
// Load saved theme preference and set app name on app mount
@ -126,20 +123,15 @@ function App() {
</Route>
{/* ============================================== */}
{/* WORKFLOW AUTOMATION (System-Komponente) */}
{/* AUTOMATIONS DASHBOARD */}
{/* ============================================== */}
<Route path="workflow-automation" element={<WorkflowAutomationPage />} />
{/* ============================================== */}
{/* RAG INVENTORY */}
{/* ============================================== */}
<Route path="rag-inventory" element={<RagInventoryPage />} />
<Route path="automations" element={<AutomationsDashboardPage />} />
{/* Legacy top-level routes redirect to dashboard (migrated to feature-instance routes) */}
<Route path="chatbot" element={<Navigate to="/" replace />} />
<Route path="pek" element={<Navigate to="/" replace />} />
<Route path="speech" element={<Navigate to="/" replace />} />
{/* ============================================== */}
{/* FEATURE-INSTANZ ROUTES */}
{/* /mandates/:mandateId/:featureCode/:instanceId */}
@ -173,8 +165,13 @@ function App() {
<Route path="templates" element={<FeatureViewPage view="templates" />} />
<Route path="logs" element={<FeatureViewPage view="logs" />} />
{/* Workspace Editor */}
{/* Workspace + Automation2 Editor */}
<Route path="editor" element={<FeatureViewPage view="editor" />} />
<Route path="rag-insights" element={<FeatureViewPage view="rag-insights" />} />
{/* Automation2: legacy workflows URL → editor */}
<Route path="workflows" element={<Navigate to="../editor" replace />} />
<Route path="workflows-tasks" element={<FeatureViewPage view="workflows-tasks" />} />
{/* Teams Bot Feature Views */}
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />
@ -221,7 +218,7 @@ function App() {
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
<Route path="logs" element={<AdminLogsPage />} />
<Route path="languages" element={null} />
<Route path="database-health" element={null} />
<Route path="database-health" element={<AdminDatabaseHealthPage />} />
<Route path="demo-config" element={<AdminDemoConfigPage />} />
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />

View file

@ -1,10 +1,25 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
// api.ts
import axios from 'axios';
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from './utils/csrfUtils';
import { clearUserDataCache, getUserDataCache } from './utils/userCache';
// Utility function to resolve hostname to IP address
const resolveHostnameToIP = async (hostname: string): Promise<string | null> => {
try {
// For localhost, return as is
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return hostname;
}
// For production domains, we can't directly resolve IP due to CORS
// But we can show the hostname which is more useful anyway
return hostname;
} catch (error) {
console.warn('Could not resolve hostname to IP:', error);
return hostname;
}
};
/**
* Extract mandate/instance context from current URL.
* URL pattern: /mandates/:mandateId/:featureCode/:instanceId/...
@ -29,25 +44,52 @@ const getContextFromUrl = (): { mandateId?: string; instanceId?: string } => {
import { getApiBaseUrl } from '../config/config';
const _baseUrl = getApiBaseUrl();
if (import.meta.env.DEV) {
console.log(`[api] Backend: ${_baseUrl} | env: ${import.meta.env.MODE}`);
}
const api = axios.create({
baseURL: _baseUrl,
baseURL: getApiBaseUrl(),
withCredentials: true,
// FastAPI expects repeat-style array query params (``?ids=1&ids=2``).
// Axios v1.x default would render ``?ids[]=1&ids[]=2``, which FastAPI
// silently drops -- e.g. ``trackerIds`` filters on the Redmine stats
// endpoint never reach the route. Setting ``indexes: null`` switches
// the URLSearchParams visitor to repeat format. Applies globally so
// every endpoint with array query params gets it for free.
paramsSerializer: { indexes: null },
});
// Add a request interceptor to add the auth token, context headers
// Add a request interceptor to add the auth token, context headers, and log backend IP
api.interceptors.request.use(
async (config) => {
// Add auth token if available (otherwise httpOnly cookies are used automatically)
// Log backend information
const backendUrl = config.baseURL || getApiBaseUrl();
console.log(`🌐 Communicating with backend: ${backendUrl}`);
// Try to resolve and log the IP address
if (backendUrl) {
try {
const url = new URL(backendUrl);
const hostname = url.hostname;
const resolvedIP = await resolveHostnameToIP(hostname);
console.log(`📍 Backend hostname: ${hostname}`);
console.log(`🔗 Full backend URL: ${backendUrl}`);
console.log(`🌍 Resolved address: ${resolvedIP}`);
// Log environment info
console.log(`🏗️ Environment: ${import.meta.env.MODE}`);
console.log(`⚙️ API Base URL: ${getApiBaseUrl()}`);
} catch (error) {
console.warn('Could not parse backend URL:', error);
}
}
// Check for auth token in localStorage and add to headers
const authToken = localStorage.getItem('authToken');
if (authToken && config.headers) {
config.headers.Authorization = `Bearer ${authToken}`;
console.log('🔑 Using Bearer token for authentication');
} else {
// Fallback: httpOnly cookies
console.log('🍪 Using httpOnly cookies for authentication (automatic)');
}
// Send app language to backend so i18n labels match the UI

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi';
import type { AttributeType } from '../utils/attributeTypeMapper';

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi';
import api from '../api';
import { addCSRFTokenToHeaders } from '../utils/csrfUtils';
@ -14,30 +12,14 @@ export interface LoginRequest {
}
export interface LoginResponse {
type: 'local_auth_success' | 'mfa_required' | 'mfa_setup_required';
type: 'local_auth_success';
accessToken?: string;
tokenType?: string;
authenticationAuthority?: string;
mfaToken?: string;
provisioningUri?: string;
label?: any;
fieldLabels?: any;
}
export interface MfaVerifyRequest {
token: string;
code: string;
}
export interface MfaSetupResponse {
provisioningUri: string;
}
export interface MfaStatusResponse {
mfaEnabled: boolean;
mfaRequired: boolean;
}
export interface RegisterData {
username: string;
email: string;
@ -334,36 +316,3 @@ export async function logoutApi(): Promise<void> {
await api.post('/api/local/logout');
}
// ============================================================================
// MFA API FUNCTIONS
// ============================================================================
export async function mfaVerifyApi(data: MfaVerifyRequest): Promise<LoginResponse> {
const response = await api.post<LoginResponse>('/api/mfa/verify', data);
return response.data;
}
export async function mfaSetupApi(): Promise<MfaSetupResponse> {
const response = await api.post<MfaSetupResponse>('/api/mfa/setup');
return response.data;
}
export async function mfaConfirmApi(code: string, token?: string): Promise<{ mfaEnabled: boolean }> {
if (token) {
const response = await api.post<{ mfaEnabled: boolean }>('/api/mfa/confirm', { code, token });
return response.data;
}
const response = await api.post<{ mfaEnabled: boolean }>('/api/mfa/confirm-authenticated', { code });
return response.data;
}
export async function mfaStatusApi(): Promise<MfaStatusResponse> {
const response = await api.get<MfaStatusResponse>('/api/mfa/status');
return response.data;
}
export async function mfaDisableApi(code: string): Promise<{ mfaEnabled: boolean }> {
const response = await api.post<{ mfaEnabled: boolean }>('/api/mfa/disable', { code });
return response.data;
}

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================

329
src/api/chatbotApi.ts Normal file
View file

@ -0,0 +1,329 @@
import { ApiRequestOptions } from '../hooks/useApi';
import api from '../api';
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from '../utils/csrfUtils';
import { Message } from '../components/UiComponents/Messages/MessagesTypes';
// ============================================================================
// TYPES & INTERFACES
// ============================================================================
export interface UserInputRequest {
input: string;
workflowId?: string;
files?: Array<{ id: string; name: string }>;
metadata?: Record<string, any>;
}
export interface ChatbotWorkflow {
id: string;
mandateId?: string; // Optional - not in ChatbotConversation
featureInstanceId?: string; // From ChatbotConversation
status: string;
name?: string;
currentRound?: number;
currentTask?: number;
currentAction?: number;
startedAt?: number;
lastActivity?: number;
[key: string]: any;
}
export interface StartChatbotRequest {
prompt: string;
listFileId?: string[];
userLanguage?: string;
workflowId?: string;
metadata?: Record<string, any>;
}
export interface StartChatbotResponse extends ChatbotWorkflow {
// Workflow object returned from start endpoint
}
export interface ChatDataItem {
type: 'message' | 'log' | 'stat' | 'document' | 'stopped' | 'status' | 'chunk';
createdAt?: number;
item?: Message | any;
label?: string; // For status events
content?: string; // For chunk events (token-by-token streaming)
}
// Type for the request function passed to API functions
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
// Type for SSE event handler
export type SSEEventHandler = (item: ChatDataItem) => void;
// ============================================================================
// API REQUEST FUNCTIONS
// ============================================================================
/**
* Start a new chatbot workflow or continue an existing one with SSE streaming
* Endpoint: POST /api/chatbot/{instanceId}/start/stream
*
* @param instanceId - Feature Instance ID
* @param requestBody - Request body with prompt and optional workflowId
* @param onEvent - Callback function called for each SSE event
* @param onError - Optional error callback
* @param onComplete - Optional completion callback
* @returns Promise that resolves when stream completes
*/
export async function startChatbotStreamApi(
instanceId: string,
requestBody: StartChatbotRequest,
onEvent: SSEEventHandler,
onError?: (error: Error) => void,
onComplete?: () => void
): Promise<void> {
try {
// Prepare request body
console.log('[startChatbotStreamApi] instanceId:', instanceId);
console.log('[startChatbotStreamApi] requestBody received:', JSON.stringify(requestBody, null, 2));
const body: any = {
prompt: requestBody.prompt,
...(requestBody.listFileId && requestBody.listFileId.length > 0 && { listFileId: requestBody.listFileId }),
...(requestBody.userLanguage && { userLanguage: requestBody.userLanguage }),
...(requestBody.metadata && { metadata: requestBody.metadata })
};
console.log('[startChatbotStreamApi] body being sent:', JSON.stringify(body, null, 2));
// Add workflowId to query params if provided
const url = requestBody.workflowId
? `/api/chatbot/${instanceId}/start/stream?workflowId=${encodeURIComponent(requestBody.workflowId)}`
: `/api/chatbot/${instanceId}/start/stream`;
// Get base URL from api instance
const baseURL = api.defaults.baseURL || '';
const fullURL = baseURL + url;
// Prepare headers with authentication and CSRF token
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
// Add auth token if available
const authToken = localStorage.getItem('authToken');
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
// Add CSRF token for POST requests
if (!getCSRFToken()) {
generateAndStoreCSRFToken();
}
addCSRFTokenToHeaders(headers);
// Use fetch for SSE streaming (POST with body)
const response = await fetch(fullURL, {
method: 'POST',
headers,
body: JSON.stringify(body),
credentials: 'include' // Include cookies for authentication
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`);
}
if (!response.body) {
throw new Error('Response body is null');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
// Decode chunk and add to buffer
buffer += decoder.decode(value, { stream: true });
// Process complete SSE messages
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line in buffer
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const jsonStr = line.slice(6); // Remove 'data: ' prefix
if (jsonStr.trim()) {
const item: ChatDataItem = JSON.parse(jsonStr);
console.log('[SSE] Received event:', item.type, item);
onEvent(item);
}
} catch (parseError) {
console.warn('Failed to parse SSE event:', line, parseError);
}
} else if (line.startsWith(':')) {
// Comment/keepalive line, ignore
continue;
}
}
}
// Process any remaining buffer content
if (buffer.trim()) {
const lines = buffer.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const jsonStr = line.slice(6);
if (jsonStr.trim()) {
const item: ChatDataItem = JSON.parse(jsonStr);
onEvent(item);
}
} catch (parseError) {
console.warn('Failed to parse SSE event:', line, parseError);
}
}
}
}
if (onComplete) {
onComplete();
}
} finally {
reader.releaseLock();
}
} catch (error: any) {
console.error('Error in startChatbotStreamApi:', error);
if (onError) {
onError(error instanceof Error ? error : new Error(String(error)));
} else {
throw error;
}
}
}
/**
* Stop a running chatbot workflow
* Endpoint: POST /api/chatbot/{instanceId}/stop/{workflowId}
*/
export async function stopChatbotApi(
request: ApiRequestFunction,
instanceId: string,
workflowId: string
): Promise<ChatbotWorkflow> {
console.log('[stopChatbotApi] Calling stop endpoint:', `/api/chatbot/${instanceId}/stop/${workflowId}`, { instanceId, workflowId });
const data = await request({
url: `/api/chatbot/${instanceId}/stop/${workflowId}`,
method: 'post'
});
console.log('[stopChatbotApi] Stop response:', data);
return data as ChatbotWorkflow;
}
/**
* Get chatbot threads/workflows
* Endpoint: GET /api/chatbot/{instanceId}/threads
*/
export async function getChatbotThreadsApi(
request: ApiRequestFunction,
instanceId: string,
pagination?: { page?: number; pageSize?: number }
): Promise<{ items: ChatbotWorkflow[]; metadata: any }> {
const paginationParam = pagination ? JSON.stringify(pagination) : undefined;
const requestParams = paginationParam
? { pagination: paginationParam }
: undefined;
console.log(`[getChatbotThreadsApi] instanceId: ${instanceId}, params:`, requestParams);
const data = await request({
url: `/api/chatbot/${instanceId}/threads`,
method: 'get',
params: requestParams
}) as any;
console.log(`[getChatbotThreadsApi] Full response:`, JSON.stringify(data, null, 2));
console.log(`[getChatbotThreadsApi] Response structure:`, {
hasItems: !!data.items,
itemsLength: Array.isArray(data.items) ? data.items.length : 'not an array',
hasMetadata: !!data.metadata,
metadataKeys: data.metadata ? Object.keys(data.metadata) : []
});
return {
items: Array.isArray(data.items) ? data.items : [],
metadata: data.pagination ?? data.metadata ?? {}
};
}
/**
* Get a specific chatbot thread/workflow with its chat data
* Endpoint: GET /api/chatbot/{instanceId}/threads?workflowId={id}
*
* Backend returns: { workflow: ChatbotWorkflow, chatData: { items: ChatDataItem[] } }
*
* @param request - API request function
* @param instanceId - Feature Instance ID
* @param workflowId - ID of the workflow to fetch
* @returns Object containing workflow details and chatData with items array
*/
export async function getChatbotThreadApi(
request: ApiRequestFunction,
instanceId: string,
workflowId: string
): Promise<{ workflow: ChatbotWorkflow; chatData: { items: ChatDataItem[] } }> {
console.log(`[getChatbotThreadApi] instanceId: ${instanceId}, workflowId: ${workflowId}`);
const data = await request({
url: `/api/chatbot/${instanceId}/threads`,
method: 'get',
params: { workflowId }
}) as { workflow: ChatbotWorkflow; chatData: { items: ChatDataItem[] } };
console.log(`[getChatbotThreadApi] Full response for workflowId ${workflowId}:`, JSON.stringify(data, null, 2));
console.log(`[getChatbotThreadApi] Response structure:`, {
hasWorkflow: !!data.workflow,
workflowKeys: data.workflow ? Object.keys(data.workflow) : [],
hasChatData: !!data.chatData,
hasItems: !!data.chatData?.items,
chatDataKeys: data.chatData ? Object.keys(data.chatData) : [],
itemsLength: Array.isArray(data.chatData?.items) ? data.chatData.items.length : 'not an array',
chatDataTypes: Array.isArray(data.chatData?.items) ? data.chatData.items.map((item: ChatDataItem) => item?.type).filter(Boolean) : []
});
return {
workflow: data.workflow,
chatData: data.chatData || { items: [] }
};
}
/**
* Delete a chatbot workflow
* Endpoint: DELETE /api/chatbot/{instanceId}/{workflowId}
*
* @param request - API request function
* @param instanceId - Feature Instance ID
* @param workflowId - ID of the workflow to delete
* @returns Success status
*/
export async function deleteChatbotWorkflowApi(
request: ApiRequestFunction,
instanceId: string,
workflowId: string
): Promise<boolean> {
try {
await request({
url: `/api/chatbot/${instanceId}/${workflowId}`,
method: 'delete'
});
return true;
} catch (error: any) {
console.error('Error deleting chatbot workflow:', error);
throw error;
}
}

View file

@ -1,135 +0,0 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* ClickUp API ClickUp-specific functions for the workflow automation flow editor.
*
* Extracted from the legacy workflowApi.ts re-export shim so each integration
* lives in its own module.
*/
import type { ApiRequestFunction } from './workflowAutomationApi';
function _encodedConnectionId(connectionId: string): string {
return encodeURIComponent(connectionId);
}
/** ClickUp GET /task/{taskId} — list.id for resolving list-scoped fields when only task id is known. */
export async function fetchClickupTask(
request: ApiRequestFunction,
connectionId: string,
taskId: string
): Promise<Record<string, unknown>> {
const data = await request({
url: `/api/clickup/${_encodedConnectionId(connectionId)}/tasks/${encodeURIComponent(taskId)}`,
method: 'get',
});
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
}
/** ClickUp list metadata (statuses, etc.) — GET /api/clickup/{connectionId}/lists/{listId}. */
export async function fetchClickupList(
request: ApiRequestFunction,
connectionId: string,
listId: string
): Promise<Record<string, unknown>> {
const data = await request({
url: `/api/clickup/${_encodedConnectionId(connectionId)}/lists/${encodeURIComponent(listId)}`,
method: 'get',
});
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
}
/** ClickUp workspace/team (members for assignees) — GET /api/clickup/{connectionId}/teams/{teamId}. */
export async function fetchClickupTeam(
request: ApiRequestFunction,
connectionId: string,
teamId: string
): Promise<Record<string, unknown>> {
const data = await request({
url: `/api/clickup/${_encodedConnectionId(connectionId)}/teams/${encodeURIComponent(teamId)}`,
method: 'get',
});
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
}
/** ClickUp list custom fields (GET /api/clickup/{connectionId}/lists/{listId}/fields). */
export async function fetchClickupListFields(
request: ApiRequestFunction,
connectionId: string,
listId: string
): Promise<{ fields?: unknown[] } & Record<string, unknown>> {
const data = await request({
url: `/api/clickup/${_encodedConnectionId(connectionId)}/lists/${encodeURIComponent(listId)}/fields`,
method: 'get',
});
return (data && typeof data === 'object' ? data : {}) as { fields?: unknown[] } & Record<string, unknown>;
}
/** ClickUp GET /list/{id}/task page (tasks in a list for relationship dropdowns). */
export interface ClickupListTaskItem {
id?: string;
name?: string;
}
export async function fetchClickupListTasks(
request: ApiRequestFunction,
connectionId: string,
listId: string,
options?: { page?: number; includeClosed?: boolean }
): Promise<
{ tasks?: ClickupListTaskItem[]; last_page?: boolean } & Record<string, unknown>
> {
const data = await request({
url: `/api/clickup/${_encodedConnectionId(connectionId)}/lists/${encodeURIComponent(listId)}/tasks`,
method: 'get',
params: {
page: options?.page ?? 0,
include_closed: options?.includeClosed ?? false,
},
});
return (data && typeof data === 'object' ? data : {}) as {
tasks?: ClickupListTaskItem[];
last_page?: boolean;
} & Record<string, unknown>;
}
/** Paginated tasks in a list — for ClickUp relationship dropdowns and input.form „ClickUp-Aufgabe". */
export async function loadClickupListTasksForDropdown(
request: ApiRequestFunction,
connectionId: string,
listId: string
): Promise<Array<{ id: string; name: string }>> {
const acc: Array<{ id: string; name: string }> = [];
const seen = new Set<string>();
const maxPages = 12;
const pageSizeHint = 100;
for (let page = 0; page < maxPages; page++) {
const data = await fetchClickupListTasks(request, connectionId, listId, {
page,
includeClosed: false,
});
if (data && typeof data === 'object' && 'error' in data && (data as { error?: unknown }).error) {
const err = (data as { error?: unknown }).error;
const body = (data as { body?: string }).body;
throw new Error(
typeof err === 'string' ? err + (body ? `: ${body.slice(0, 200)}` : '') : 'ClickUp API error'
);
}
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
for (const t of tasks) {
const id = t?.id != null ? String(t.id) : '';
if (!id || seen.has(id)) continue;
seen.add(id);
acc.push({ id, name: String(t.name ?? id) });
}
const rawLast = (data as Record<string, unknown>).last_page;
const last =
rawLast === true ||
rawLast === 'true' ||
tasks.length === 0 ||
tasks.length < pageSizeHint;
if (last) break;
}
acc.sort((a, b) => a.name.localeCompare(b.name, 'de'));
return acc;
}

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import api from '../api';
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from '../utils/csrfUtils';
import { ApiRequestOptions } from '../hooks/useApi';

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================
@ -8,11 +6,17 @@ import { ApiRequestOptions } from '../hooks/useApi';
export interface KnowledgePreferences {
schemaVersion?: number;
neutralizeBeforeEmbed?: boolean;
mailContentDepth?: 'metadata' | 'snippet' | 'full';
mailIndexAttachments?: boolean;
filesIndexBinaries?: boolean;
mimeAllowlist?: string[];
clickupScope?: 'titles' | 'title_description' | 'with_comments';
clickupIndexAttachments?: boolean;
surfaceToggles?: {
google?: { gmail?: boolean; drive?: boolean };
msft?: { sharepoint?: boolean; outlook?: boolean };
};
maxAgeDays?: number;
}
@ -288,210 +292,3 @@ export async function submitInfomaniakToken(
});
}
// ============================================================================
// RAG KNOWLEDGE CONSENT & CONTROL
// ============================================================================
export async function patchKnowledgeConsent(
request: ApiRequestFunction,
connectionId: string,
enabled: boolean
): Promise<{ connectionId: string; knowledgeIngestionEnabled: boolean; purged?: any; cancelledJobs?: number; bootstrapEnqueued?: boolean }> {
return await request({
url: `/api/connections/${connectionId}/knowledge-consent`,
method: 'patch',
data: { enabled }
});
}
export async function patchKnowledgePreferences(
request: ApiRequestFunction,
connectionId: string,
preferences: KnowledgePreferences
): Promise<{ connectionId: string; knowledgePreferences: KnowledgePreferences; updated: boolean }> {
return await request({
url: `/api/connections/${connectionId}/knowledge-preferences`,
method: 'patch',
data: { preferences }
});
}
export async function postKnowledgeStop(
request: ApiRequestFunction,
connectionId: string
): Promise<{ connectionId: string; cancelled: number }> {
return await request({
url: `/api/connections/${connectionId}/knowledge-stop`,
method: 'post'
});
}
export interface RagLimits {
maxItems?: number;
maxBytes?: number;
maxFileSize?: number;
maxDepth?: number;
// ClickUp variant
maxTasks?: number;
maxWorkspaces?: number;
maxListsPerWorkspace?: number;
}
export interface DataSourceSettings {
ragLimits?: RagLimits;
}
export interface CostEstimate {
estimatedTokens: number;
estimatedChf: number;
basis: {
kind: string;
limits: Record<string, number>;
assumptions: Record<string, any>;
notes: string;
};
sourceId?: string;
}
export async function patchDataSourceSettings(
request: ApiRequestFunction,
dataSourceId: string,
settings: DataSourceSettings
): Promise<{ sourceId: string; settings: DataSourceSettings; updated: boolean }> {
return await request({
url: `/api/datasources/${dataSourceId}/settings`,
method: 'patch',
data: { settings }
});
}
export async function getDataSourceCostEstimate(
request: ApiRequestFunction,
dataSourceId: string
): Promise<CostEstimate> {
return await request({
url: `/api/datasources/${dataSourceId}/cost-estimate`,
method: 'get'
});
}
// Flag toggles (neutralize / scope / ragIndexEnabled) now go through the
// generic UDB endpoint POST /api/udb/node/{key}/flag/{flag}; see
// `UdbSourcesProvider` and the wiki UDB reference page.
// ============================================================================
// RAG INVENTORY
// ============================================================================
export interface RagDataSourceDto {
id: string;
label: string;
path: string;
sourceType: string;
/** Three-state inherit semantics on backend; UI reads as effective boolean from RAG inventory aggregator. */
ragIndexEnabled: boolean | null;
neutralize: boolean | null;
lastIndexed: number | null;
/** Distinct files indexed for this DataSource (one row per source document). */
fileCount: number;
/** Embedding-sized text fragments (one per ContentChunk row, ~400 tokens each). */
chunkCount: number;
}
export interface RagConnectionDto {
id: string;
authority: string;
externalEmail: string;
knowledgeIngestionEnabled: boolean;
preferences: KnowledgePreferences;
dataSources: RagDataSourceDto[];
totalFiles: number;
totalChunks: number;
runningJobs: {
jobId: string;
progress: number;
/** Already translated server-side. */
progressMessage: string;
}[];
lastError?: { jobId: string; errorMessage: string; finishedAt: number | null } | null;
lastSuccess?: {
jobId: string;
finishedAt: number | null;
indexed: number;
skippedDuplicate: number;
skippedPolicy: number;
failed: number;
durationMs: number;
/** Name of the first budget that bit (e.g. "maxBytes", "maxItems", "maxTasks"); null if walk completed naturally. */
stoppedAtLimit?: string | null;
/** Effective limits used by the walker, for showing the value next to the limit name. */
limits?: Record<string, number>;
bytesProcessed?: number;
} | null;
}
export interface RagFeatureDataSourceDto {
id: string;
label: string;
tableName: string;
featureCode: string;
ragIndexEnabled: boolean;
}
export interface RagFeatureInstanceDto {
featureInstanceId: string;
featureCode: string;
label: string;
mandateId: string;
fileCount: number;
chunkCount: number;
statusCounts: Record<string, number>;
dataSources: RagFeatureDataSourceDto[];
ragEnabled: boolean;
runningJobs?: {
jobId: string;
progress: number;
progressMessage: string;
}[];
lastError?: { jobId: string; errorMessage: string; finishedAt: number | null } | null;
lastSuccess?: {
jobId: string;
finishedAt: number | null;
indexed: number;
skippedDuplicate: number;
failed: number;
} | null;
}
export interface RagInventoryDto {
connections: RagConnectionDto[];
featureInstances?: RagFeatureInstanceDto[];
totals: { files: number; chunks: number; bytes?: number };
}
export interface RagActiveJobDto {
jobId: string;
connectionId: string;
connectionLabel?: string;
jobType: string;
progress: number | null;
/** Already translated server-side. */
progressMessage: string;
}
export async function getRagInventoryMe(request: ApiRequestFunction): Promise<RagInventoryDto> {
return await request({ url: '/api/rag/inventory/me', method: 'get' });
}
export async function getRagInventoryMandate(request: ApiRequestFunction): Promise<RagInventoryDto> {
return await request({ url: '/api/rag/inventory/mandate', method: 'get' });
}
export async function getRagInventoryPlatform(request: ApiRequestFunction): Promise<any> {
return await request({ url: '/api/rag/inventory/platform', method: 'get' });
}
export async function getRagActiveJobs(request: ApiRequestFunction): Promise<RagActiveJobDto[]> {
return await request({ url: '/api/rag/inventory/jobs', method: 'get' });
}

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* Features API
*
@ -16,6 +14,8 @@ import type {
InstancePermissions,
AccessLevel,
} from '../types/mandate';
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
// =============================================================================
// MOCK DATA (Temporär bis Backend bereit)
// =============================================================================
@ -172,11 +172,56 @@ export async function fetchMyFeatures(): Promise<FeaturesMyResponse> {
}
try {
console.log('📡 featuresApi: Fetching /api/features/my');
const response = await api.get<FeaturesMyResponse>('/api/features/my');
// Get the actual data (response.data contains the FeaturesMyResponse)
const data = response.data;
// DEBUG: Log all chatbot instances and their permissions
console.log('🔍 [DEBUG] featuresApi: Full response received', {
response,
data,
hasMandates: !!data?.mandates,
mandateCount: data?.mandates?.length || 0,
});
if (data?.mandates) {
data.mandates.forEach(mandate => {
mandate.features.forEach(feature => {
if (feature.code === 'chatbot') {
console.log('🔍 [DEBUG] featuresApi: Found chatbot feature', {
mandateId: mandate.id,
mandateName: mandateDisplayLabel(mandate),
featureCode: feature.code,
instanceCount: feature.instances.length,
});
feature.instances.forEach(instance => {
console.log('🔍 [DEBUG] featuresApi: Chatbot Instance Details:', {
instanceId: instance.id,
instanceLabel: instance.instanceLabel,
featureCode: instance.featureCode,
userRoles: instance.userRoles,
permissions: instance.permissions,
views: instance.permissions?.views,
viewKeys: instance.permissions?.views ? Object.keys(instance.permissions.views) : [],
hasConversationsView: instance.permissions?.views?.['chatbot-conversations'] ||
instance.permissions?.views?.['ui.feature.chatbot.conversations'] ||
instance.permissions?.views?.['_all'],
});
});
}
});
});
}
console.log('✅ featuresApi: Loaded features:', {
mandateCount: data?.mandates?.length || 0,
totalInstances: data?.mandates
?.flatMap(m => m.features)
?.flatMap(f => f.instances)
?.length || 0,
});
return data;
} catch (error) {
console.error('❌ featuresApi: Error fetching features:', error);
@ -194,6 +239,7 @@ export async function fetchAvailableFeatures(): Promise<MandateFeature[]> {
return [
{ code: 'trustee', label: 'Treuhand', icon: 'briefcase', instances: [] },
{ code: 'chatworkflow', label: 'Workflow', icon: 'play_circle', instances: [] },
{ code: 'chatbot', label: 'Chatbot', icon: 'chat', instances: [] },
];
}

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================
@ -38,7 +36,6 @@ export interface PaginationParams {
search?: string;
viewKey?: string;
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
owner?: 'all' | 'me' | 'shared';
}
export interface PaginatedResponse<T> {
@ -112,7 +109,6 @@ export async function fetchFiles(
if (params.search) paginationObj.search = params.search;
if (params.viewKey) paginationObj.viewKey = params.viewKey;
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
if (params.owner) requestParams.owner = params.owner;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* Neutralization API
*

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import api from '../api';
import type { ApiRequestOptions } from '../hooks/useApi';

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* Redmine API
*

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* Store API
*

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import api from '../api';
export interface TableListViewRow {

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import api from '../api';
import type { VoiceOption } from './voiceCatalogApi';
@ -73,7 +71,6 @@ export interface TeamsbotConfig {
triggerCooldownSeconds: number;
contextWindowSegments: number;
debugMode?: boolean;
avatarFileId?: string;
}
export interface TeamsbotSessionStats {
@ -87,7 +84,6 @@ export interface TeamsbotSessionStats {
export interface StartSessionRequest {
meetingLink: string;
botName?: string;
moduleId?: string;
connectionId?: string;
joinMode?: TeamsbotJoinMode;
sessionContext?: string;
@ -106,7 +102,6 @@ export interface ConfigUpdateRequest {
triggerCooldownSeconds?: number;
contextWindowSegments?: number;
debugMode?: boolean;
avatarFileId?: string;
}
// Voice option type re-exported from the central voice catalog API
@ -467,13 +462,6 @@ export function createSessionStream(instanceId: string, sessionId: string): Even
return new EventSource(url, { withCredentials: true });
}
/** SSE dashboard stream: periodic { type: 'dashboardState', sessions, modules } */
export function createDashboardStream(instanceId: string): EventSource {
const baseUrl = api.defaults.baseURL || '';
const url = `${baseUrl}/api/teamsbot/${instanceId}/dashboard/stream`;
return new EventSource(url, { withCredentials: true });
}
// =========================================================================
// Debug Screenshots (SysAdmin only)
// =========================================================================
@ -604,9 +592,6 @@ export interface MeetingModule {
defaultDirectorPrompts?: string;
goals?: string;
kpiTargets?: string;
defaultMeetingLink?: string;
defaultBotName?: string;
defaultAvatarFileId?: string;
status: string;
}
@ -617,7 +602,6 @@ export async function listModules(instanceId: string): Promise<MeetingModule[]>
export async function createModule(instanceId: string, body: {
title: string; seriesType?: string; defaultBotId?: string; goals?: string; kpiTargets?: string;
defaultMeetingLink?: string; defaultBotName?: string; defaultAvatarFileId?: string;
}): Promise<MeetingModule> {
const response = await api.post(`/api/teamsbot/${instanceId}/modules`, body);
return response.data?.module;
@ -636,31 +620,3 @@ export async function updateModule(instanceId: string, moduleId: string, body: P
export async function deleteModule(instanceId: string, moduleId: string): Promise<void> {
await api.delete(`/api/teamsbot/${instanceId}/modules/${moduleId}`);
}
export interface MediaFileInfo {
id: string;
fileName: string;
mimeType: string;
}
export async function listMediaFiles(): Promise<MediaFileInfo[]> {
const response = await api.get('/api/files/list', {
params: { pagination: JSON.stringify({ pageSize: 500 }) },
});
const data = response.data;
let items: any[];
if (Array.isArray(data)) {
items = data;
} else if (Array.isArray(data?.items)) {
items = data.items;
} else {
console.warn('[listMediaFiles] unexpected response shape:', Object.keys(data || {}));
items = [];
}
const filtered = items.filter((f: any) => {
const mime = (f.mimeType || '').toLowerCase();
return mime.startsWith('image/') || mime.startsWith('video/');
});
console.log(`[listMediaFiles] ${items.length} total files, ${filtered.length} media files`);
return filtered.map((f: any) => ({ id: f.id, fileName: f.fileName, mimeType: f.mimeType }));
}

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* Trustee API
*
@ -866,14 +864,7 @@ export async function syncPositionsToAccounting(
request: ApiRequestFunction,
instanceId: string,
positionIds: string[],
opts?: {
pollMs?: number;
/**
* `message` is already translated server-side by the job route handler
* (`resolveJobMessage`). Render it 1:1; never feed it through `t()`.
*/
onProgress?: (progress: number, message?: string | null) => void;
}
opts?: { pollMs?: number; onProgress?: (progress: number, message?: string | null) => void }
): Promise<{ total: number; success: number; skipped?: number; errors: number; results: any[] }> {
const submission = await request({
url: `${_getTrusteeBaseUrl(instanceId)}/accounting/sync`,

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* Voice / Language Catalog API.
*

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* AccessLevelSelect
*

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* AccessRulesEditor
*

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* AccessRulesTable
*

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* AccessRules Components
*

View file

@ -73,12 +73,13 @@
/* Connector grid (Step 0) */
.connectorGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.connectorCard {
flex: 1 1 140px;
display: flex;
flex-direction: column;
align-items: center;
@ -446,22 +447,6 @@
cursor: not-allowed;
}
.patInput {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 0.9rem;
font-family: monospace;
margin: 12px 0 16px;
}
.patInput:focus {
outline: none;
border-color: var(--primary, #2563eb);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
}
/* Dark theme */
:global(.dark-theme) .connectorCard {
background: var(--surface-color);

View file

@ -1,55 +1,153 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* AddConnectionWizard
*
* Streamlined multi-step modal for adding a new connector.
* Steps are connector-type-aware:
* Base: Connector Consent Connect
* Microsoft: Connector Consent Admin Consent (optional) Connect
* Infomaniak: Connector Consent PAT Input (done)
* Multi-step modal for adding a new connector with optional knowledge
* ingestion consent and per-connection preferences (§2.6).
*
* Steps:
* 0 Connector wählen
* 1 Consent (Wissensdatenbank Ja/Nein)
* 2 Präferenzen (nur wenn Ja)
* 3 Zusammenfassung + OAuth starten
*/
import React, { useState } from 'react';
import { Modal } from '../UiComponents/Modal/Modal';
import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaCheck, FaArrowRight, FaShieldAlt } from 'react-icons/fa';
import { useLanguage } from '../../providers/language/LanguageContext';
import { FaGoogle, FaMicrosoft, FaTasks, FaDatabase, FaShieldAlt, FaCheck, FaArrowRight, FaInfoCircle } from 'react-icons/fa';
import type { KnowledgePreferences } from '../../api/connectionApi';
import styles from './AddConnectionWizard.module.css';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ConnectorType = 'google' | 'msft' | 'clickup' | 'infomaniak';
type StepId = 'connector' | 'consent' | 'msftAdminConsent' | 'infomaniakPat' | 'connect';
export type ConnectorType = 'google' | 'msft' | 'clickup';
interface WizardState {
currentStep: StepId;
step: 0 | 1 | 2 | 3;
connector: ConnectorType | null;
knowledgeEnabled: boolean;
infomaniakToken: string;
adminConsentDone: boolean;
prefs: KnowledgePreferences;
}
const DEFAULT_PREFS: KnowledgePreferences = {
schemaVersion: 1,
neutralizeBeforeEmbed: false,
mailContentDepth: 'full',
mailIndexAttachments: false,
filesIndexBinaries: true,
clickupScope: 'title_description',
clickupIndexAttachments: false,
maxAgeDays: 90,
};
const CONNECTOR_LABELS: Record<ConnectorType, string> = {
google: 'Google',
msft: 'Microsoft 365',
clickup: 'ClickUp',
infomaniak: 'Infomaniak',
};
const CONNECTOR_ICONS: Record<ConnectorType, React.ReactNode> = {
google: <FaGoogle style={{ color: '#4285f4' }} />,
msft: <FaMicrosoft style={{ color: '#00a4ef' }} />,
clickup: <FaTasks style={{ color: '#7b68ee' }} />,
infomaniak: <FaCloud style={{ color: '#0098db' }} />,
};
function _getSteps(connector: ConnectorType | null): StepId[] {
if (connector === 'msft') return ['connector', 'consent', 'msftAdminConsent', 'connect'];
if (connector === 'infomaniak') return ['connector', 'consent', 'infomaniakPat'];
return ['connector', 'consent', 'connect'];
// ---------------------------------------------------------------------------
// Cost estimate helper
// ---------------------------------------------------------------------------
/**
* Returns a cost estimate broken into two lines:
*
* 1. Embedding (OpenAI text-embedding-3-small, $0.02 / 1M tokens) always tiny.
* 2. Neutralization (Private LLM / qwen2.5 on-premise, CHF 0.01 per LLM call)
* this is the DOMINANT cost when enabled. One call per email/task for
* short content; several calls for long threads or files.
*
* Numbers are conservative ranges. Subsequent syncs are cheaper because
* unchanged content is deduplicated before any LLM/embedding call.
*/
function computeCostEstimate(
connector: ConnectorType | null,
prefs: KnowledgePreferences,
): {
embeddingLow: string;
embeddingHigh: string;
neutralizationLow: string | null;
neutralizationHigh: string | null;
note: string;
} | null {
if (!connector) return null;
// ---- Embedding (OpenAI, USD) ----
const EMBED_USD_PER_M = 0.02;
const tokensPerMail: Record<string, number> = { metadata: 30, snippet: 120, full: 500 };
const depth = prefs.mailContentDepth ?? 'full';
const maxAge = prefs.maxAgeDays ?? 90;
const mailCount = Math.min(500, Math.round((maxAge / 90) * 500));
const taskCount = Math.min(500, Math.round((maxAge / 90) * 300));
let embedLowTokens = 0;
let embedHighTokens = 0;
if (connector === 'google' || connector === 'msft') {
const mailTokens = mailCount * tokensPerMail[depth];
embedLowTokens += mailTokens * 0.6;
embedHighTokens += mailTokens * 1.5 + 500_000; // Drive/SharePoint
if (prefs.mailIndexAttachments) embedHighTokens += 200_000;
} else if (connector === 'clickup') {
const scope = prefs.clickupScope ?? 'title_description';
const tpt = scope === 'titles' ? 30 : scope === 'title_description' ? 200 : 400;
embedLowTokens += taskCount * tpt * 0.6;
embedHighTokens += taskCount * tpt * 1.5;
}
const fmtUsd = (tokens: number) => {
const usd = (tokens / 1_000_000) * EMBED_USD_PER_M;
if (usd < 0.001) return '< 0.01 $';
if (usd < 0.10) return `~${usd.toFixed(3)} $`;
return `~${usd.toFixed(2)} $`;
};
// ---- Neutralization (Private LLM, CHF 0.01/call) ----
// Each item (email / task / file) = 1 LLM call for short content,
// 2-4 for long threads/documents.
const NEUT_CHF_PER_CALL = 0.01;
let neutLow: string | null = null;
let neutHigh: string | null = null;
if (prefs.neutralizeBeforeEmbed) {
let lowCalls = 0;
let highCalls = 0;
if (connector === 'google' || connector === 'msft') {
lowCalls += mailCount * 1; // 1 call / short email
highCalls += mailCount * 3; // up to 3 calls / long thread
lowCalls += 20; // Drive/SharePoint files (low)
highCalls += 200; // Drive/SharePoint files (high, large PDFs)
} else if (connector === 'clickup') {
lowCalls += taskCount * 1;
highCalls += taskCount * 2;
}
const fmtChf = (calls: number) => {
const chf = calls * NEUT_CHF_PER_CALL;
if (chf < 0.01) return '< 0.01 CHF';
return `~${chf.toFixed(2)} CHF`;
};
neutLow = fmtChf(lowCalls);
neutHigh = fmtChf(highCalls);
}
return {
embeddingLow: fmtUsd(embedLowTokens),
embeddingHigh: fmtUsd(embedHighTokens),
neutralizationLow: neutLow,
neutralizationHigh: neutHigh,
note: 'Einmalig beim ersten Sync. Folge-Syncs kosten weniger (nur neue Inhalte).',
};
}
// ---------------------------------------------------------------------------
@ -59,9 +157,11 @@ function _getSteps(connector: ConnectorType | null): StepId[] {
interface AddConnectionWizardProps {
open: boolean;
onClose: () => void;
onConnect: (type: ConnectorType, knowledgeEnabled: boolean) => Promise<void>;
onInfomaniakConnect?: (token: string, knowledgeEnabled: boolean) => Promise<void>;
onMsftAdminConsent?: () => void;
onConnect: (
type: ConnectorType,
knowledgeEnabled: boolean,
prefs: KnowledgePreferences | null,
) => Promise<void>;
isConnecting?: boolean;
}
@ -73,93 +173,84 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
open,
onClose,
onConnect,
onInfomaniakConnect,
onMsftAdminConsent,
isConnecting = false,
}) => {
const { t } = useLanguage();
const [state, setState] = useState<WizardState>({
currentStep: 'connector',
step: 0,
connector: null,
knowledgeEnabled: false,
infomaniakToken: '',
adminConsentDone: false,
prefs: { ...DEFAULT_PREFS },
});
const reset = () =>
setState({ currentStep: 'connector', connector: null, knowledgeEnabled: false, infomaniakToken: '', adminConsentDone: false });
setState({ step: 0, connector: null, knowledgeEnabled: false, prefs: { ...DEFAULT_PREFS } });
const handleClose = () => { reset(); onClose(); };
const steps = _getSteps(state.connector);
const stepIndex = steps.indexOf(state.currentStep);
const goNext = () => {
const nextIdx = stepIndex + 1;
if (nextIdx < steps.length) {
setState(s => ({ ...s, currentStep: steps[nextIdx] }));
}
};
const goBack = () => {
const prevIdx = stepIndex - 1;
if (prevIdx >= 0) {
setState(s => ({ ...s, currentStep: steps[prevIdx] }));
}
};
const selectConnector = (c: ConnectorType) => {
setState(s => ({ ...s, connector: c, currentStep: 'consent' }));
};
const setConsent = (enabled: boolean) => {
setState(s => ({ ...s, knowledgeEnabled: enabled }));
goNext();
};
const handleFinalConnect = async () => {
if (!state.connector) return;
if (state.connector === 'infomaniak' && onInfomaniakConnect) {
await onInfomaniakConnect(state.infomaniakToken, state.knowledgeEnabled);
} else {
await onConnect(state.connector, state.knowledgeEnabled);
}
const handleClose = () => {
reset();
onClose();
};
const setStep = (step: WizardState['step']) => setState(s => ({ ...s, step }));
const setConnector = (connector: ConnectorType) =>
setState(s => ({ ...s, connector, step: 1 }));
const setKnowledgeEnabled = (v: boolean) =>
setState(s => ({ ...s, knowledgeEnabled: v, step: v ? 2 : 3 }));
const updatePref = <K extends keyof KnowledgePreferences>(key: K, value: KnowledgePreferences[K]) =>
setState(s => ({ ...s, prefs: { ...s.prefs, [key]: value } }));
const handleConnect = async () => {
if (!state.connector) return;
await onConnect(
state.connector,
state.knowledgeEnabled,
state.knowledgeEnabled ? state.prefs : null,
);
reset();
onClose();
};
const visibleSteps = state.knowledgeEnabled
? [0, 1, 2, 3]
: [0, 1, 3];
return (
<Modal open={open} onClose={handleClose} title={t('Verbindung hinzufügen')} size="md" closeOnEscape>
<Modal
open={open}
onClose={handleClose}
title="Verbindung hinzufügen"
size="md"
closeOnEscape
>
{/* Stepper */}
<div className={styles.stepper}>
{steps.map((s, i) => (
{[0, 1, 2, 3].map(i => (
<div
key={s}
key={i}
className={[
styles.stepDot,
stepIndex === i ? styles.stepDotActive : '',
stepIndex > i ? styles.stepDotDone : '',
state.step === i ? styles.stepDotActive : '',
state.step > i ? styles.stepDotDone : '',
!visibleSteps.includes(i) ? styles.stepDotHidden : '',
].join(' ')}
>
{stepIndex > i ? <FaCheck size={10} /> : i + 1}
{state.step > i ? <FaCheck size={10} /> : i + 1}
</div>
))}
</div>
<div className={styles.body}>
{/* ---- Step: Connector ---- */}
{state.currentStep === 'connector' && (
{/* ---- Step 0: Connector ---- */}
{state.step === 0 && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>{t('Anbieter wählen')}</h3>
<p className={styles.stepHint}>{t('Welchen Dienst möchtest du verbinden?')}</p>
<h3 className={styles.stepTitle}>Anbieter wählen</h3>
<p className={styles.stepHint}>Welchen Dienst möchtest du verbinden?</p>
<div className={styles.connectorGrid}>
{(['google', 'msft', 'clickup', 'infomaniak'] as ConnectorType[]).map(type => (
{(['google', 'msft', 'clickup'] as ConnectorType[]).map(type => (
<button
key={type}
type="button"
className={styles.connectorCard}
onClick={() => selectConnector(type)}
onClick={() => setConnector(type)}
>
<span className={styles.connectorIcon}>{CONNECTOR_ICONS[type]}</span>
<span className={styles.connectorLabel}>{CONNECTOR_LABELS[type]}</span>
@ -169,119 +260,253 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
</div>
)}
{/* ---- Step: Consent ---- */}
{state.currentStep === 'consent' && (
{/* ---- Step 1: Consent ---- */}
{state.step === 1 && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>{t('Wissensdatenbank')}</h3>
<div className={styles.consentIcon}><FaDatabase size={32} /></div>
<h3 className={styles.stepTitle}>Wissensdatenbank</h3>
<p className={styles.stepBody}>
{t('Möchtest du Inhalte aus dieser Verbindung in deine persönliche Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen aus {provider} zurückgreifen kann?', { provider: state.connector ? CONNECTOR_LABELS[state.connector] : t('diesem Dienst') })}
Möchtest du Inhalte aus dieser Verbindung in deine persönliche
Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen
aus{' '}
{state.connector ? CONNECTOR_LABELS[state.connector] : 'diesem Dienst'}{' '}
zurückgreifen kann?
</p>
<p className={styles.stepHint}>
{t('Du kannst dies später jederzeit in der UDB pro Datenquelle steuern.')}
</p>
<div className={styles.consentButtons}>
<button type="button" className={styles.consentButtonYes} onClick={() => setConsent(true)}>
<FaCheck /> {t('Ja, aktivieren')}
</button>
<button type="button" className={styles.consentButtonNo} onClick={() => setConsent(false)}>
{t('Nein, überspringen')}
</button>
</div>
<div className={styles.stepNavLeft}>
<button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
</div>
</div>
)}
{/* ---- Step: MSFT Admin Consent ---- */}
{state.currentStep === 'msftAdminConsent' && (
<div className={styles.stepContent}>
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<FaShieldAlt size={32} style={{ color: '#00a4ef' }} />
</div>
<h3 className={styles.stepTitle}>{t('Organisations-Zustimmung (optional)')}</h3>
<p className={styles.stepBody}>
{t('Falls du Mandant-Administrator bist, kannst du jetzt für deine ganze Organisation zustimmen. So müssen andere Benutzer nicht einzeln bestätigen.')}
</p>
<p className={styles.stepHint}>
{t('Wenn du kein Admin bist oder dies später tun möchtest, überspringe diesen Schritt.')}
Du kannst diese Einstellung später in den Verbindungsdetails ändern.
</p>
<div className={styles.consentButtons}>
<button
type="button"
className={styles.consentButtonYes}
onClick={() => { onMsftAdminConsent?.(); setState(s => ({ ...s, adminConsentDone: true })); goNext(); }}
onClick={() => setKnowledgeEnabled(true)}
>
<FaShieldAlt /> {t('Admin-Zustimmung erteilen')}
<FaCheck /> Ja, aufnehmen
</button>
<button type="button" className={styles.consentButtonNo} onClick={goNext}>
{t('Überspringen')}
<button
type="button"
className={styles.consentButtonNo}
onClick={() => setKnowledgeEnabled(false)}
>
Nein, überspringen
</button>
</div>
<div className={styles.stepNavLeft}>
<button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
</div>
</div>
)}
{/* ---- Step: Infomaniak PAT ---- */}
{state.currentStep === 'infomaniakPat' && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>{t('Infomaniak Personal Access Token')}</h3>
<p className={styles.stepBody}>
{t('Erstelle einen Personal Access Token in deinem Infomaniak-Konto und füge ihn hier ein.')}
</p>
<input
type="password"
placeholder="pat_..."
value={state.infomaniakToken}
onChange={e => setState(s => ({ ...s, infomaniakToken: e.target.value }))}
className={styles.patInput}
autoFocus
/>
<div className={styles.stepNav}>
<button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
<button
type="button"
className={styles.navConnect}
onClick={handleFinalConnect}
disabled={isConnecting || !state.infomaniakToken.trim()}
>
{isConnecting ? t('Verbinden…') : t('Verbinden')}
{!isConnecting && <FaArrowRight size={12} />}
<button type="button" className={styles.navBack} onClick={() => setStep(0)}>
Zurück
</button>
</div>
</div>
)}
{/* ---- Step: Connect ---- */}
{state.currentStep === 'connect' && (
{/* ---- Step 2: Preferences ---- */}
{state.step === 2 && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>{t('Verbindung herstellen')}</h3>
<h3 className={styles.stepTitle}>Einstellungen</h3>
<p className={styles.stepHint}>
Steuere, welche Inhalte und in welcher Form sie indexiert werden.
</p>
<div className={styles.prefGroup}>
<label className={styles.prefLabel}>
<FaShieldAlt className={styles.prefIcon} />
Anonymisierung vor dem Indexieren
<input
type="checkbox"
checked={!!state.prefs.neutralizeBeforeEmbed}
onChange={e => updatePref('neutralizeBeforeEmbed', e.target.checked)}
className={styles.prefCheck}
/>
</label>
<p className={styles.prefHint}>
Persönliche Daten (Namen, E-Mail-Adressen) werden vor dem Speichern ersetzt.
</p>
</div>
{(state.connector === 'google' || state.connector === 'msft') && (
<>
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
E-Mail-Inhalt
<select
value={state.prefs.mailContentDepth ?? 'full'}
onChange={e => updatePref('mailContentDepth', e.target.value as any)}
className={styles.prefSelect}
>
<option value="metadata">Nur Metadaten (Betreff, Absender, Datum)</option>
<option value="snippet">Vorschautext (ca. 250 Zeichen)</option>
<option value="full">Vollständiger Text</option>
</select>
</label>
</div>
<div className={styles.prefGroup}>
<label className={styles.prefLabel}>
E-Mail-Anhänge indexieren
<input
type="checkbox"
checked={!!state.prefs.mailIndexAttachments}
onChange={e => updatePref('mailIndexAttachments', e.target.checked)}
className={styles.prefCheck}
/>
</label>
</div>
</>
)}
{state.connector === 'clickup' && (
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
Aufgaben-Inhalt
<select
value={state.prefs.clickupScope ?? 'title_description'}
onChange={e => updatePref('clickupScope', e.target.value as any)}
className={styles.prefSelect}
>
<option value="titles">Nur Aufgabentitel</option>
<option value="title_description">Titel + Beschreibung</option>
<option value="with_comments">Titel + Beschreibung + Kommentare</option>
</select>
</label>
</div>
)}
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
Zeitfenster (Tage)
<input
type="number"
min={0}
max={3650}
value={state.prefs.maxAgeDays ?? 90}
onChange={e => updatePref('maxAgeDays', parseInt(e.target.value, 10) || 0)}
className={styles.prefNumber}
/>
</label>
<p className={styles.prefHint}>0 = kein Limit</p>
</div>
<div className={styles.stepNav}>
<button type="button" className={styles.navBack} onClick={() => setStep(1)}>
Zurück
</button>
<button type="button" className={styles.navNext} onClick={() => setStep(3)}>
Weiter <FaArrowRight size={12} />
</button>
</div>
</div>
)}
{/* ---- Step 3: Summary ---- */}
{state.step === 3 && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Zusammenfassung</h3>
<div className={styles.summary}>
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>{t('Anbieter')}</span>
<span className={styles.summaryKey}>Anbieter</span>
<span className={styles.summaryVal}>
{state.connector && CONNECTOR_ICONS[state.connector]}&nbsp;
{CONNECTOR_ICONS[state.connector!]}&nbsp;
{state.connector ? CONNECTOR_LABELS[state.connector] : '—'}
</span>
</div>
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>{t('Wissensdatenbank')}</span>
<span className={styles.summaryKey}>Wissensdatenbank</span>
<span className={styles.summaryVal}>
{state.knowledgeEnabled ? t('Aktiv') : t('Nicht aktiv')}
{state.knowledgeEnabled ? 'Aktiv' : 'Nicht aktiv'}
</span>
</div>
{state.knowledgeEnabled && (
<>
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Anonymisierung</span>
<span className={styles.summaryVal}>
{state.prefs.neutralizeBeforeEmbed ? 'Ja' : 'Nein'}
</span>
</div>
{(state.connector === 'google' || state.connector === 'msft') && (
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>E-Mail-Tiefe</span>
<span className={styles.summaryVal}>
{{ metadata: 'Nur Metadaten', snippet: 'Vorschautext', full: 'Volltext' }[
state.prefs.mailContentDepth ?? 'full'
] ?? state.prefs.mailContentDepth}
</span>
</div>
)}
{state.connector === 'clickup' && (
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Aufgaben-Inhalt</span>
<span className={styles.summaryVal}>
{{
titles: 'Nur Titel',
title_description: 'Titel + Beschreibung',
with_comments: 'Titel + Beschreibung + Kommentare',
}[state.prefs.clickupScope ?? 'title_description'] ?? state.prefs.clickupScope}
</span>
</div>
)}
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Zeitfenster</span>
<span className={styles.summaryVal}>
{state.prefs.maxAgeDays ? `${state.prefs.maxAgeDays} Tage` : 'Unbegrenzt'}
</span>
</div>
</>
)}
</div>
{/* Cost estimate — only shown when knowledge ingestion is enabled */}
{state.knowledgeEnabled && (() => {
const est = computeCostEstimate(state.connector, state.prefs);
if (!est) return null;
return (
<div className={styles.costHint}>
<FaInfoCircle className={styles.costHintIcon} />
<div>
<span className={styles.costHintTitle}>Geschätzte Kosten (erster Sync)</span>
<table className={styles.costTable}>
<tbody>
<tr>
<td className={styles.costLabel}>Embedding</td>
<td className={styles.costVal}>
{est.embeddingLow} {est.embeddingHigh}
</td>
</tr>
{est.neutralizationLow && (
<tr className={styles.costRowNeut}>
<td className={styles.costLabel}>Anonymisierung (Private LLM)</td>
<td className={styles.costVal}>
{est.neutralizationLow} {est.neutralizationHigh}
</td>
</tr>
)}
</tbody>
</table>
{est.neutralizationLow && (
<span className={styles.costHintWarn}>
Anonymisierung ist der Hauptkostentreiber (CHF 0.01 pro LLM-Aufruf, on-premise).
</span>
)}
<span className={styles.costHintNote}>{est.note}</span>
</div>
</div>
);
})()}
<div className={styles.stepNav}>
<button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
<button
type="button"
className={styles.navBack}
onClick={() => setStep(state.knowledgeEnabled ? 2 : 1)}
>
Zurück
</button>
<button
type="button"
className={styles.navConnect}
onClick={handleFinalConnect}
onClick={handleConnect}
disabled={isConnecting}
>
{isConnecting ? t('Verbinden…') : t('Mit {provider} verbinden', { provider: state.connector ? CONNECTOR_LABELS[state.connector] : '…' })}
{isConnecting ? 'Verbinden…' : `Mit ${state.connector ? CONNECTOR_LABELS[state.connector] : '…'} verbinden`}
{!isConnecting && <FaArrowRight size={12} />}
</button>
</div>

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* ChatInput -- Shared chat input component.
*

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* ChatMessageList -- Shared chat message display component.
*

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
export { ChatMessageList } from './ChatMessageList';
export type { ChatMessage } from './ChatMessageList';
export { ChatInput } from './ChatInput';

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useCallback, useEffect, useState } from 'react';
import { IoIosDownload, IoIosCopy } from 'react-icons/io';

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useState, useEffect } from 'react';
import { IoIosDownload } from 'react-icons/io';
import { Popup, PopupAction } from '../UiComponents/Popup/Popup';

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
export { ContentPreview } from './ContentPreview';
export type { ContentPreviewProps } from './ContentPreview';
export { UrlContentPreview } from './UrlContentPreview';

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import styles from '../ContentPreview.module.css';
interface ApplicationRendererProps {

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../ContentPreview.module.css';

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useEffect, useMemo, useState } from 'react';
import * as XLSX from 'xlsx';
import { useLanguage } from '../../../providers/language/LanguageContext';

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import styles from '../ContentPreview.module.css';
interface HtmlRendererProps {

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import styles from '../ContentPreview.module.css';
interface ImageRendererProps {

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useState } from 'react';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../ContentPreview.module.css';

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../ContentPreview.module.css';

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useEffect, useRef, useState } from 'react';
// @ts-ignore
import * as pdfjsLib from 'pdfjs-dist';

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { IoIosWarning } from 'react-icons/io';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../ContentPreview.module.css';

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import styles from '../ContentPreview.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../ContentPreview.module.css';

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useEffect, useMemo, useRef, useState } from 'react';
import { renderAsync } from 'docx-preview';
import { useLanguage } from '../../../providers/language/LanguageContext';

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
export { JsonRenderer } from './JsonRenderer';
export { ImageRenderer } from './ImageRenderer';
export { TextRenderer } from './TextRenderer';

View file

@ -1,16 +1,14 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* Workflow Flow Editor - Data flow context for Data Picker and DynamicValueField.
* Automation2 Flow Editor - Data flow context for Data Picker and DynamicValueField.
* Extended with portTypeCatalog and systemVariables for the Typed Port System.
*/
import React, { createContext, useContext, useMemo } from 'react';
import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
import { getAvailableSources } from '../nodes/shared/dataFlowGraph';
import type { ApiRequestFunction, ConditionOperatorDef, FormFieldType, NodeType, PortField, PortSchema, SystemVariable } from '../../../api/workflowAutomationApi';
import type { ApiRequestFunction, ConditionOperatorDef, FormFieldType, NodeType, PortField, PortSchema, SystemVariable } from '../../../api/workflowApi';
export interface WorkflowDataFlowContextValue {
export interface Automation2DataFlowContextValue {
currentNodeId: string;
nodes: CanvasNode[];
connections: CanvasConnection[];
@ -32,13 +30,13 @@ export interface WorkflowDataFlowContextValue {
parseGraphDefinedSchema: (parameterKey: string) => PortSchema | null;
}
const WorkflowDataFlowContext = createContext<WorkflowDataFlowContextValue | null>(null);
const Automation2DataFlowContext = createContext<Automation2DataFlowContextValue | null>(null);
export function useWorkflowDataFlow(): WorkflowDataFlowContextValue | null {
return useContext(WorkflowDataFlowContext);
export function useAutomation2DataFlow(): Automation2DataFlowContextValue | null {
return useContext(Automation2DataFlowContext);
}
interface WorkflowDataFlowProviderProps {
interface Automation2DataFlowProviderProps {
node: CanvasNode | null;
nodes: CanvasNode[];
connections: CanvasConnection[];
@ -54,7 +52,7 @@ interface WorkflowDataFlowProviderProps {
children: React.ReactNode;
}
export const WorkflowDataFlowProvider: React.FC<WorkflowDataFlowProviderProps> = ({
export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderProps> = ({
node,
nodes,
connections,
@ -69,7 +67,7 @@ export const WorkflowDataFlowProvider: React.FC<WorkflowDataFlowProviderProps> =
request,
children,
}) => {
const value = useMemo((): WorkflowDataFlowContextValue | null => {
const value = useMemo((): Automation2DataFlowContextValue | null => {
if (!node) return null;
const formTypeToPort: Record<string, string> = Object.fromEntries(
formFieldTypes.map((f) => [f.id, f.portType])
@ -137,8 +135,8 @@ export const WorkflowDataFlowProvider: React.FC<WorkflowDataFlowProviderProps> =
}, [node, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables, formFieldTypes, conditionOperatorCatalog, instanceId, request]);
return (
<WorkflowDataFlowContext.Provider value={value}>
<Automation2DataFlowContext.Provider value={value}>
{children}
</WorkflowDataFlowContext.Provider>
</Automation2DataFlowContext.Provider>
);
};

View file

@ -1,5 +1,5 @@
/**
* Workflow Flow Editor Styles
* Automation2 Flow Editor Styles
* Sidebar with node list + canvas area.
*/
@ -255,7 +255,6 @@
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-primary, #fff);
overflow: visible;
}
.canvasHeaderToolbar {
@ -389,8 +388,8 @@
/* Closed <select> width must not follow the longest option label. */
.canvasHeaderWorkflowSelect {
flex: 0 1 12.5rem;
min-width: 8rem;
flex: 0 0 auto;
width: 12.5rem;
max-width: 100%;
padding: 0.31rem 0.45rem;
min-height: 30px;
@ -402,61 +401,11 @@
color: var(--text-primary, #333);
}
.canvasHeaderTitleBlock {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: 0.25rem;
}
.canvasHeaderTitle,
.canvasHeaderTitle input {
margin: 0;
min-width: 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
}
.canvasHeaderTitle {
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.canvasHeaderTitleMuted {
font-style: italic;
font-weight: 500;
opacity: 0.65;
color: var(--text-secondary, #666);
}
.canvasHeaderTitle input {
width: 100%;
max-width: 100%;
padding: 0.25rem 0.4rem;
border: 1px solid var(--primary-color, #007bff);
border-radius: 4px;
outline: none;
background: var(--bg-primary, #fff);
box-sizing: border-box;
}
.canvasHeaderActionPanel {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.4rem;
padding: 0.35rem 0.5rem;
border-radius: 8px;
border: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-secondary, #f8f9fa);
flex: 0 1 auto;
max-width: 100%;
margin-left: auto;
.canvasHeaderIconBtn {
padding: 6px !important;
min-width: 30px !important;
min-height: 30px !important;
box-sizing: border-box !important;
}
.canvasHeaderSplitPair :global(.button + .button) {

View file

@ -1,7 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* WorkflowFlowEditor
* Automation2FlowEditor
*
* n8n-style flow builder with backend-driven node list and categories.
* Start nodes come from the API (category `start`); invocations are synced on the server from the graph.
@ -25,16 +23,15 @@ import {
createTemplateFromWorkflow,
copyTemplate,
importWorkflowFromFile,
WORKFLOW_FILE_EXTENSION,
type NodeType,
type NodeTypeCategory,
type WorkflowGraph,
type WorkflowDefinition,
type Automation2Graph,
type Automation2Workflow,
type ExecuteGraphResponse,
type WorkflowEntryPoint,
type AutoVersion,
type AutoTemplateScope,
} from '../../../api/workflowAutomationApi';
} from '../../../api/workflowApi';
import {
FlowCanvas,
type CanvasNode,
@ -52,7 +49,7 @@ import { fromApiGraph, toApiGraph, switchOutputCountFromCases, trimConnectionsFo
import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry';
import { findGraphErrors } from '../nodes/shared/paramValidation';
import { getLabel as getParamLabel } from '../nodes/shared/utils';
import { WorkflowDataFlowProvider } from '../context/WorkflowDataFlowContext';
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
import { usePrompt } from '../../../hooks/usePrompt';
import { EditorChatPanel } from './EditorChatPanel';
import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel';
@ -60,12 +57,12 @@ import { EditorWorkflowChatList } from './EditorWorkflowChatList';
import { RunTracingPanel } from './RunTracingPanel';
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
import styles from './WorkflowFlowEditor.module.css';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
const LOG = '[WorkflowEditor]';
const LOG = '[Automation2]';
const CANVAS_HISTORY_MAX = 50;
@ -81,7 +78,7 @@ function cloneCanvasSnapshot(nodes: CanvasNode[], connections: CanvasConnection[
};
}
interface WorkflowFlowEditorProps {
interface Automation2FlowEditorProps {
instanceId: string;
mandateId?: string;
language?: string;
@ -95,7 +92,7 @@ interface WorkflowFlowEditorProps {
onSourcesChanged?: () => void;
}
export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instanceId,
export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ instanceId,
mandateId,
language = 'de',
initialWorkflowId,
@ -113,9 +110,9 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
const [portTypeCatalog, setPortTypeCatalog] = useState<Record<string, unknown>>({});
const [systemVariables, setSystemVariables] = useState<Record<string, unknown>>({});
const [formFieldTypes, setFormFieldTypes] = useState<import('../../../api/workflowAutomationApi').FormFieldType[]>([]);
const [formFieldTypes, setFormFieldTypes] = useState<import('../../../api/workflowApi').FormFieldType[]>([]);
const [conditionOperatorCatalog, setConditionOperatorCatalog] = useState<
Record<string, import('../../../api/workflowAutomationApi').ConditionOperatorDef[]>
Record<string, import('../../../api/workflowApi').ConditionOperatorDef[]>
>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -140,7 +137,7 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
const [canvasStickyNotes, setCanvasStickyNotes] = useState<CanvasStickyNote[]>([]);
const [executing, setExecuting] = useState(false);
const [executeResult, setExecuteResult] = useState<ExecuteGraphResponse | null>(null);
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null);
const [saving, setSaving] = useState(false);
@ -156,12 +153,11 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
instanceId,
mandateId: mandateId || '',
featureInstanceId: instanceId,
surface: 'workflowAutomation',
surface: 'graphEditor',
}), [instanceId, mandateId]);
const [versions, setVersions] = useState<AutoVersion[]>([]);
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
const [versionLoading, setVersionLoading] = useState(false);
const didBootstrapEmptyCanvasRef = useRef(false);
const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId);
@ -304,7 +300,7 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
const applyGraphWithSync = useCallback(
(
graph: WorkflowGraph | null | undefined,
graph: Automation2Graph | null | undefined,
wfInvocations: WorkflowEntryPoint[] | undefined,
opts?: { skipHistory?: boolean }
) => {
@ -312,7 +308,7 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
pushCanvasHistoryPastFromCurrent();
}
setInvocations(wfInvocations ?? []);
const g: WorkflowGraph = graph ?? { nodes: [], connections: [] };
const g: Automation2Graph = graph ?? { nodes: [], connections: [] };
const { nodes, connections } = fromApiGraph(g, nodeTypes);
setCanvasNodes(nodes);
setCanvasConnections(connections);
@ -321,7 +317,7 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
);
const handleFromApiGraph = useCallback(
(graph: WorkflowGraph, wfInvocations?: WorkflowEntryPoint[]) => {
(graph: Automation2Graph, wfInvocations?: WorkflowEntryPoint[]) => {
applyGraphWithSync(graph, wfInvocations);
},
[applyGraphWithSync]
@ -357,7 +353,7 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
setExecuteResult(null);
try {
const ep = currentWorkflowId ? invocations[0]?.id : undefined;
const result = await executeGraph(request, graph, currentWorkflowId ?? undefined, {
const result = await executeGraph(request, instanceId, graph, currentWorkflowId ?? undefined, {
...(ep ? { entryPointId: ep } : {}),
});
setExecuteResult(result);
@ -406,7 +402,7 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
setSaving(true);
try {
if (currentWorkflowId) {
const updated = await updateWorkflow(request, currentWorkflowId, {
const updated = await updateWorkflow(request, instanceId, currentWorkflowId, {
graph,
invocations,
targetFeatureInstanceId,
@ -423,12 +419,11 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
setSaving(false);
return;
}
const created = await createWorkflow(request, {
const created = await createWorkflow(request, instanceId, {
label: label.trim() || t('Neuer Workflow'),
graph,
invocations,
targetFeatureInstanceId,
mandateId,
});
setCurrentWorkflowId(created.id);
setInvocations(created.invocations ?? []);
@ -440,12 +435,12 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
} finally {
setSaving(false);
}
}, [request, mandateId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors, targetFeatureInstanceId, hasCanvasStartNode]);
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors, targetFeatureInstanceId, hasCanvasStartNode]);
const handleLoad = useCallback(
async (workflowId: string) => {
try {
const wf = await fetchWorkflow(request, workflowId);
const wf = await fetchWorkflow(request, instanceId, workflowId);
if (wf.graph) {
handleFromApiGraph(wf.graph, wf.invocations);
} else {
@ -467,7 +462,7 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, []);
try {
const result = await fetchWorkflows(request);
const result = await fetchWorkflows(request, instanceId);
setWorkflows(Array.isArray(result) ? result : result.items);
} catch (refreshErr) {
console.error(`${LOG} workflows refresh failed`, refreshErr);
@ -480,7 +475,7 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
});
}
},
[request, handleFromApiGraph, applyGraphWithSync, t]
[request, instanceId, handleFromApiGraph, applyGraphWithSync, t]
);
const handleWorkflowSelect = useCallback(
@ -548,10 +543,11 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
);
const loadNodeTypes = useCallback(async () => {
if (!instanceId) return;
setLoading(true);
setError(null);
try {
const data = await fetchNodeTypes(request, mandateId || '', language);
const data = await fetchNodeTypes(request, instanceId, language);
setNodeTypes(data.nodeTypes);
setCategories(data.categories);
if (data.portTypeCatalog) {
@ -568,16 +564,17 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
} finally {
setLoading(false);
}
}, [language, request]);
}, [instanceId, language, request]);
const loadWorkflows = useCallback(async () => {
if (!instanceId) return;
try {
const result = await fetchWorkflows(request, { mandateId: mandateId || undefined });
const result = await fetchWorkflows(request, instanceId);
setWorkflows(Array.isArray(result) ? result : result.items);
} catch (e) {
console.error(`${LOG} loadWorkflows failed`, e);
}
}, [request, mandateId]);
}, [instanceId, request]);
useEffect(() => {
loadNodeTypes();
@ -601,22 +598,8 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
useEffect(() => {
if (loading || nodeTypes.length === 0) return;
if (currentWorkflowId || initialWorkflowId) {
didBootstrapEmptyCanvasRef.current = false;
return;
}
if (didBootstrapEmptyCanvasRef.current) return;
didBootstrapEmptyCanvasRef.current = true;
if (canvasNodes.length === 0 && canvasConnections.length === 0 && invocations.length === 0) {
return;
}
console.debug(`${LOG} bootstrapping empty canvas`, {
currentWorkflowId,
initialWorkflowId,
canvasNodes: canvasNodes.length,
canvasConnections: canvasConnections.length,
invocations: invocations.length,
});
if (currentWorkflowId || initialWorkflowId) return;
if (canvasNodes.length > 0) return;
applyGraphWithSync({ nodes: [], connections: [] }, [], {
skipHistory: true,
});
@ -626,9 +609,8 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
currentWorkflowId,
initialWorkflowId,
canvasNodes.length,
canvasConnections.length,
invocations.length,
applyGraphWithSync,
t,
]);
const toggleCategory = useCallback((id: string) => {
@ -667,17 +649,17 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
);
const loadVersions = useCallback(async () => {
if (!currentWorkflowId) {
if (!instanceId || !currentWorkflowId) {
setVersions([]);
return;
}
try {
const v = await fetchVersions(request, currentWorkflowId);
const v = await fetchVersions(request, instanceId, currentWorkflowId);
setVersions(v);
} catch (e) {
console.error(`${LOG} loadVersions failed`, e);
}
}, [currentWorkflowId, request]);
}, [instanceId, currentWorkflowId, request]);
useEffect(() => {
loadVersions();
@ -698,9 +680,10 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
const handlePublishVersion = useCallback(
async (versionId: string) => {
if (!instanceId) return;
setVersionLoading(true);
try {
await publishVersion(request, versionId);
await publishVersion(request, instanceId, versionId);
await loadVersions();
} catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
@ -708,14 +691,15 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
setVersionLoading(false);
}
},
[request, loadVersions]
[request, instanceId, loadVersions]
);
const handleUnpublishVersion = useCallback(
async (versionId: string) => {
if (!instanceId) return;
setVersionLoading(true);
try {
await unpublishVersion(request, versionId);
await unpublishVersion(request, instanceId, versionId);
await loadVersions();
} catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
@ -723,14 +707,15 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
setVersionLoading(false);
}
},
[request, loadVersions]
[request, instanceId, loadVersions]
);
const handleArchiveVersion = useCallback(
async (versionId: string) => {
if (!instanceId) return;
setVersionLoading(true);
try {
await archiveVersion(request, versionId);
await archiveVersion(request, instanceId, versionId);
await loadVersions();
} catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
@ -738,14 +723,14 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
setVersionLoading(false);
}
},
[request, loadVersions]
[request, instanceId, loadVersions]
);
const handleCreateDraft = useCallback(async () => {
if (!currentWorkflowId) return;
if (!instanceId || !currentWorkflowId) return;
setVersionLoading(true);
try {
const draft = await createDraftVersion(request, currentWorkflowId);
const draft = await createDraftVersion(request, instanceId, currentWorkflowId);
await loadVersions();
setCurrentVersionId(draft.id);
} catch (e: unknown) {
@ -753,16 +738,16 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
} finally {
setVersionLoading(false);
}
}, [request, currentWorkflowId, loadVersions]);
}, [request, instanceId, currentWorkflowId, loadVersions]);
// Template: save current workflow as template
const [templateSaving, setTemplateSaving] = useState(false);
const handleSaveAsTemplate = useCallback(
async (scope: AutoTemplateScope) => {
if (!currentWorkflowId) return;
if (!instanceId || !currentWorkflowId) return;
setTemplateSaving(true);
try {
await createTemplateFromWorkflow(request, currentWorkflowId, scope);
await createTemplateFromWorkflow(request, instanceId, currentWorkflowId, scope);
setExecuteResult({ success: true, error: undefined } as unknown as ExecuteGraphResponse);
} catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
@ -770,15 +755,16 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
setTemplateSaving(false);
}
},
[request, currentWorkflowId]
[request, instanceId, currentWorkflowId]
);
// Template: new workflow from template
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
const handleNewFromTemplate = useCallback(
async (templateId: string) => {
if (!instanceId) return;
try {
const wf = await copyTemplate(request, templateId);
const wf = await copyTemplate(request, instanceId, templateId);
setWorkflows((prev) => [...prev, wf]);
setCurrentWorkflowId(wf.id);
if (wf.graph) handleFromApiGraph(wf.graph, wf.invocations);
@ -787,7 +773,7 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
}
},
[request, handleFromApiGraph]
[request, instanceId, handleFromApiGraph]
);
@ -945,20 +931,12 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
activeTab={udbTab as UdbTab}
onTabChange={(tab) => setUdbTab(tab as LeftTab)}
hideTabs={['chats']}
onFileSelect={async (fileId, fileName) => {
if (fileName?.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION)) {
try {
const result = await importWorkflowFromFile(request, { fileId });
await loadWorkflows();
if (result?.workflow?.id) handleWorkflowSelect(result.workflow.id);
} catch (e) {
console.error('[workflowAutomation] workflow file import failed', e);
}
return;
}
onFileSelect?.(fileId, fileName);
}}
onFileSelect={onFileSelect}
onSourcesChanged={onSourcesChanged}
onWorkflowImportedFromFile={async (workflowId) => {
await loadWorkflows();
handleWorkflowSelect(workflowId);
}}
/>
)}
</div>
@ -1030,12 +1008,12 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
stickyNotes={canvasStickyNotes}
onStickyNotesChange={setCanvasStickyNotes}
onExternalDrop={async (mime, payload) => {
if (mime !== 'application/json+workflow') return false;
if (mime !== 'application/json+workflow' || !instanceId) return false;
const p = payload as { files?: Array<{ id: string }> } | undefined;
const fileId = p?.files?.[0]?.id;
if (!fileId) return false;
try {
const result = await importWorkflowFromFile(request, { fileId });
const result = await importWorkflowFromFile(request, instanceId, { fileId });
await loadWorkflows();
if (result?.workflow?.id) handleWorkflowSelect(result.workflow.id);
return true;
@ -1048,7 +1026,7 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
</div>
{configurableSelected && selectedNode && (
<div className={styles.nodeConfigPanelWrap} data-suppress-flow-node-hotkeys="">
<WorkflowDataFlowProvider
<Automation2DataFlowProvider
node={selectedNode}
nodes={canvasNodes}
connections={canvasConnections}
@ -1073,7 +1051,7 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
request={request}
verboseSchema={verboseSchema}
/>
</WorkflowDataFlowProvider>
</Automation2DataFlowProvider>
</div>
)}
</div>
@ -1128,4 +1106,4 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
);
};
export default WorkflowFlowEditor;
export default Automation2FlowEditor;

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* CanvasHeader - Workflow controls, version selector, and execute result.
*/
@ -25,11 +23,12 @@ import {
HiOutlineArrowUturnRight,
HiOutlineTrash,
HiOutlineDocumentDuplicate,
HiOutlineArrowLongRight,
HiOutlineChatBubbleLeftEllipsis,
HiOutlineSquares2X2,
} from 'react-icons/hi2';
import type { WorkflowDefinition, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowAutomationApi';
import styles from './WorkflowFlowEditor.module.css';
import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { getUserDataCache } from '../../../utils/userCache';
@ -62,7 +61,7 @@ export interface CanvasHeaderCanvasEditProps {
}
interface CanvasHeaderProps {
workflows: WorkflowDefinition[];
workflows: Automation2Workflow[];
currentWorkflowId: string | null;
onWorkflowSelect: (workflowId: string | null) => void;
onNew: () => void;

View file

@ -1,9 +1,7 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* EditorChatPanel
*
* AI Chat sidebar for the WorkflowAutomation editor.
* AI Chat sidebar for the GraphicalEditor.
* Streams responses via SSE (same pattern as Workspace chat).
* File & data-source attachment UX mirrors WorkspaceInput:
* - Files: drag & drop from FilesTab (UDB) onto input area, or click in UDB
@ -89,7 +87,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
// Load persisted chat history from the backend whenever the workflow changes.
// The chat is stored in `ChatWorkflow.linkedWorkflowId == workflowId` and is
// returned by `GET /api/workflow-automation/{workflowId}/chat/messages`.
// returned by `GET /api/workflows/{instanceId}/{workflowId}/chat/messages`.
// For an unsaved workflow (workflowId == null) we just clear the panel.
useEffect(() => {
if (!workflowId) {
@ -101,7 +99,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
setHistoryLoading(true);
try {
const res = await api.get<PersistedEditorChatResponse>(
`/api/workflow-automation/${workflowId}/chat/messages`,
`/api/workflows/${instanceId}/${workflowId}/chat/messages`,
);
if (cancelled) return;
const persisted = (res.data?.messages || []).map((m): ChatMessage => ({
@ -168,7 +166,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
const baseURL = api.defaults.baseURL || '';
const cleanup = startSseStream({
url: `${baseURL}/api/workflow-automation/${workflowId}/chat/stream`,
url: `${baseURL}/api/workflows/${instanceId}/${workflowId}/chat/stream`,
body,
handlers: {
onChunk: (event) => {
@ -229,7 +227,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
: m));
}
try {
await api.post(`/api/workflow-automation/${workflowId}/chat/stop`);
await api.post(`/api/workflows/${instanceId}/${workflowId}/chat/stop`);
} catch {
}
abortRef.current?.();

View file

@ -1,19 +1,17 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* EditorWorkflowChatList
*
* UDB "Chats" tab content for the WorkflowAutomation editor: each AutoWorkflow
* is treated as one editor chat session. Lists workflows already loaded by the
* parent editor (no extra fetch), supports search and "+ Neu" to start a fresh
* UDB "Chats" tab content for the GraphicalEditor: each AutoWorkflow is treated
* as one editor chat session. Lists workflows already loaded by the parent
* editor (no extra fetch), supports search and "+ Neu" to start a fresh
* workflow chat. Mirrors the spirit of the Workspace ChatsTab but uses
* WorkflowAutomation data instead of the workspace endpoint.
* GraphicalEditor data instead of the workspace endpoint.
*/
import React, { useMemo, useState } from 'react';
import type { WorkflowDefinition } from '../../../api/workflowAutomationApi';
import type { Automation2Workflow } from '../../../api/workflowApi';
interface EditorWorkflowChatListProps {
workflows: WorkflowDefinition[];
workflows: Automation2Workflow[];
currentWorkflowId: string | null;
onSelect: (workflowId: string | null) => void;
onNew: () => void;

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* FlowCanvas - Workflow graph canvas with nodes and connection lines.
* Nodes have 4 connection handles (one per side), drag nodes to add, connect with arrows.
@ -15,15 +13,13 @@ import React, {
useRef,
useState,
} from 'react';
import type { GraphDefinedSchemaRef, NodeType } from '../../../api/workflowAutomationApi';
import styles from './WorkflowFlowEditor.module.css';
import type { GraphDefinedSchemaRef, NodeType } from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { AiBadge } from '../nodes/shared/AiBadge';
import { switchOutputLabel } from '../nodes/shared/graphUtils';
const LOG = '[FlowCanvas]';
export interface CanvasNode {
id: string;
type: string;
@ -846,8 +842,6 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
const onHistoryCheckpointRef = useRef(onHistoryCheckpoint);
onHistoryCheckpointRef.current = onHistoryCheckpoint;
const onSelectionChangeRef = useRef(onSelectionChange);
onSelectionChangeRef.current = onSelectionChange;
const emitHistoryCheckpoint = useCallback(() => {
onHistoryCheckpointRef.current?.();
@ -1025,19 +1019,12 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
]
);
const lastEmittedSelectionRef = useRef<{ nodeId: string | null; signature: string | null }>({
nodeId: null,
signature: null,
});
useEffect(() => {
const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null;
const signature = node ? JSON.stringify(node) : null;
const last = lastEmittedSelectionRef.current;
if (last.nodeId === selectedNodeId && last.signature === signature) return;
lastEmittedSelectionRef.current = { nodeId: selectedNodeId, signature };
onSelectionChangeRef.current?.(node);
}, [selectedNodeId, nodes]);
if (onSelectionChange) {
const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null;
onSelectionChange(node);
}
}, [selectedNodeId, nodes, onSelectionChange]);
const handleConnectionClick = useCallback((e: React.MouseEvent, connId: string) => {
e.stopPropagation();
@ -1101,11 +1088,6 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
console.debug(`${LOG} drop received`, {
types: Array.from(e.dataTransfer.types),
clientX: e.clientX,
clientY: e.clientY,
});
// 1) externe Drop-Targets (z. B. ``application/json+workflow`` aus UDB-FilesTab)
if (onExternalDrop) {
const reservedMimes = new Set([
@ -1131,35 +1113,16 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
}
// 2) Standard: Node-Type aus der NodeSidebar
const raw = e.dataTransfer.getData('application/json');
if (!raw || !containerRef.current) {
console.debug(`${LOG} drop ignored`, {
hasRaw: Boolean(raw),
hasContainer: Boolean(containerRef.current),
});
return;
}
if (!raw || !containerRef.current) return;
try {
const { type } = JSON.parse(raw);
const el = containerRef.current;
const rect = el.getBoundingClientRect();
const x = (e.clientX - rect.left - panOffset.x) / zoom - NODE_WIDTH / 2;
const y = (e.clientY - rect.top - panOffset.y) / zoom - NODE_HEIGHT / 2;
console.debug(`${LOG} placing node from drop`, {
type,
raw,
dropX: x,
dropY: y,
panOffset,
zoom,
});
onDropNodeType(type, Math.max(0, x), Math.max(0, y));
emitHistoryCheckpoint();
} catch (error) {
console.debug(`${LOG} drop parse failed`, {
raw,
error,
});
}
} catch (_) {}
},
[onDropNodeType, onExternalDrop, panOffset, zoom, emitHistoryCheckpoint]
);

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* NodeConfigPanel - Generic parameter renderer for all node types.
* Renders each parameter using FRONTEND_TYPE_RENDERERS based on frontendType.
@ -7,15 +5,14 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import type { CanvasNode } from './FlowCanvas';
import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, PortSchema } from '../../../api/workflowAutomationApi';
import type { ApiRequestFunction } from '../../../api/workflowAutomationApi';
import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, PortSchema } from '../../../api/workflowApi';
import type { ApiRequestFunction } from '../../../api/workflowApi';
import { getLabel } from '../nodes/shared/utils';
import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers';
import { ContextBuilderRenderer } from '../nodes/frontendTypeRenderers/ContextBuilderRenderer';
import { RequiredAttributePicker } from '../nodes/shared/RequiredAttributePicker';
import { findRequiredErrors } from '../nodes/shared/paramValidation';
import { useWorkflowDataFlow } from '../context/WorkflowDataFlowContext';
import styles from './WorkflowFlowEditor.module.css';
import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { AccordionList } from '../../UiComponents/AccordionList';
@ -212,7 +209,7 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
[onParametersChange]
);
const dataFlow = useWorkflowDataFlow();
const dataFlow = useAutomation2DataFlow();
const portTypeCatalog: Record<string, PortSchema> = (dataFlow?.portTypeCatalog as Record<string, PortSchema> | undefined) ?? {};
// Phase-4 Schicht-4 — Pflicht-Params zuerst sortieren, damit der User
@ -256,7 +253,6 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
for (const param of sortedParameters) {
if (param.frontendType === 'hidden') continue;
if (param.name === 'context') continue;
if (CONTEXT_EXTRACT_CHUNK_SET.has(param.name)) continue;
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) continue;
@ -382,15 +378,6 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
t,
]);
const extractContentContextParam = useMemo((): NodeTypeParameter | null => {
if (!node || !nodeType || node.type !== CONTEXT_EXTRACT_CONTENT_NODE_TYPE) return null;
const param = sortedParameters.find((p) => p.name === 'context') ?? null;
if (!param) return null;
if (param.frontendType === 'hidden') return null;
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) return null;
return param;
}, [node, nodeType, sortedParameters, params]);
if (!node || !nodeType) return null;
const isTrigger = node.type.startsWith('trigger.');
@ -496,71 +483,11 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
</div>
)}
{extractContentAccordionItems !== null ? (
<>
{extractContentContextParam ? (
<div
key={`${node.id}-${extractContentContextParam.name}`}
style={{ marginBottom: 8, minWidth: 0, maxWidth: '100%' }}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 2,
flexWrap: 'wrap',
minWidth: 0,
}}
>
{extractContentContextParam.required && (
<span
title={t('Pflichtfeld')}
style={{ color: 'var(--danger-color, #dc3545)', fontWeight: 700, flexShrink: 0 }}
>
*
</span>
)}
{verboseSchema && extractContentContextParam.type && (
<span
title={t('Parameter-Typ')}
style={{
fontSize: 10,
fontWeight: 600,
color: 'var(--text-secondary)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
padding: '1px 6px',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{extractContentContextParam.type}
</span>
)}
</div>
<ContextBuilderRenderer
param={extractContentContextParam}
value={workflowParamUiValue(params, extractContentContextParam)}
onChange={(val: unknown) => updateParam(extractContentContextParam.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
onPatchParams={patchParams}
/>
</div>
) : null}
{extractContentAccordionItems.length > 0 ? (
<AccordionList<string>
key={`${node.id}-extract-accordion`}
defaultOpenId={null}
items={extractContentAccordionItems}
/>
) : null}
</>
<AccordionList<string>
key={`${node.id}-extract-accordion`}
defaultOpenId={null}
items={extractContentAccordionItems}
/>
) : (
parameters.map((param: NodeTypeParameter) => {
// Safety net: hidden params have no UI footprint at all — no row,

View file

@ -1,16 +1,14 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* NodeListItem - Draggable node type item for the sidebar.
* Used in both regular categories and I/O sub-groups.
*/
import React from 'react';
import type { NodeType } from '../../../api/workflowAutomationApi';
import type { NodeType } from '../../../api/workflowApi';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { getCategoryIcon } from '../nodes/shared/utils';
import type { GetLabelFn } from '../nodes/shared/utils';
import styles from './WorkflowFlowEditor.module.css';
import styles from './Automation2FlowEditor.module.css';
import { AiBadge } from '../nodes/shared/AiBadge';
interface NodeListItemProps {

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* NodeSidebar - Sidebar with searchable, collapsible node list.
* Groups node types by category (start, input, flow, data, ai, email, sharepoint).
@ -7,11 +5,11 @@
import React, { useMemo } from 'react';
import { FaChevronDown, FaChevronRight } from 'react-icons/fa';
import type { NodeType, NodeTypeCategory } from '../../../api/workflowAutomationApi';
import type { NodeType, NodeTypeCategory } from '../../../api/workflowApi';
import { CATEGORY_ORDER, HIDDEN_NODE_IDS } from '../nodes/shared/constants';
import { getLabel } from '../nodes/shared/utils';
import { NodeListItem } from './NodeListItem';
import styles from './WorkflowFlowEditor.module.css';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* RunTracingPanel
*
@ -9,7 +7,7 @@
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useApiRequest } from '../../../hooks/useApi';
import type { AutoStepLog } from '../../../api/workflowAutomationApi';
import type { AutoStepLog } from '../../../api/workflowApi';
import api from '../../../api';
import { useLanguage } from '../../../providers/language/LanguageContext';
@ -100,7 +98,7 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
setLoading(true);
try {
const data = await request({
url: `/api/workflow-automation/runs/${runId}/steps`,
url: `/api/workflows/${instanceId}/runs/${runId}/steps`,
method: 'get',
});
setSteps(data?.steps || []);
@ -117,7 +115,7 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
loadSteps();
const baseUrl = api.defaults.baseURL || '';
const url = `${baseUrl}/api/workflow-automation/runs/${runId}/stream`;
const url = `${baseUrl}/api/workflows/${instanceId}/runs/${runId}/stream`;
const es = new EventSource(url, { withCredentials: true });
eventSourceRef.current = es;

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* TemplatePicker - modal to browse and select a workflow template for creating a new workflow.
*/
@ -11,8 +9,8 @@ import {
type AutoWorkflowTemplate,
type AutoTemplateScope,
type ApiRequestFunction,
} from '../../../api/workflowAutomationApi';
import styles from './WorkflowFlowEditor.module.css';
} from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
interface TemplatePickerProps {
@ -52,7 +50,7 @@ export const TemplatePicker: React.FC<TemplatePickerProps> = ({
setLoading(true);
try {
const scope = activeScope === 'all' ? undefined : activeScope;
const result = await fetchTemplates(request, scope);
const result = await fetchTemplates(request, instanceId, scope);
setTemplates(Array.isArray(result) ? result : result.items);
} catch {
setTemplates([]);

View file

@ -1,6 +1,4 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
export { WorkflowFlowEditor, WorkflowFlowEditor as FlowEditor } from './editor/WorkflowFlowEditor';
export { Automation2FlowEditor, Automation2FlowEditor as FlowEditor } from './editor/Automation2FlowEditor';
export type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './editor/EditorChatPanel';
export { FlowCanvas, STICKY_NOTE_PALETTE, STICKY_NOTE_DEFAULT_COLOR_ID, STICKY_NOTE_DEFAULT_HEIGHT, getStickyNotePaletteEntry } from './editor/FlowCanvas';
export type { CanvasNode, CanvasConnection, CanvasStickyNote, FlowCanvasHandle, FlowCanvasViewportEditState } from './editor/FlowCanvas';

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* One text field per option the text the end user sees in the dropdown.
* Stored as { value, label } with the same string so payload and UI stay in sync.

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* Form node config - draggable fields, types, required toggle
*/
@ -8,8 +6,8 @@ import React from 'react';
import { FaGripVertical, FaTimes } from 'react-icons/fa';
import type { FormField, NodeConfigRendererProps } from '../shared/types';
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import styles from '../../editor/WorkflowFlowEditor.module.css';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { FormFieldOptionsEditor } from './FormFieldOptionsEditor';
import {
deriveFormFieldPayloadKey,
@ -21,7 +19,7 @@ import { useLanguage } from '../../../../providers/language/LanguageContext';
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
const ctx = useWorkflowDataFlow();
const ctx = useAutomation2DataFlow();
const fieldTypeOptions = ctx?.formFieldTypes?.length
? ctx.formFieldTypes
: FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' }));

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* Helpers for optional select/multiselect rows on workflow form field definitions.
*/

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
export { FormNodeConfig } from './FormNodeConfig';
export { FormFieldOptionsEditor } from './FormFieldOptionsEditor';
export type { FormFieldOptionRow } from './formFieldOptionsUtils';

View file

@ -1,15 +1,13 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* Backend-driven case list for flow.switch (depends on value dataRef).
*/
import React from 'react';
import type { FieldRendererProps } from './index';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { isRef, type DataRef } from '../shared/dataRef';
import { toApiGraph } from '../shared/graphUtils';
import { fetchConditionMeta, type ConditionOperatorDef } from '../../../../api/workflowAutomationApi';
import { fetchConditionMeta, type ConditionOperatorDef } from '../../../../api/workflowApi';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export interface SwitchCase {
@ -118,7 +116,7 @@ export const CaseListEditor: React.FC<FieldRendererProps> = ({
allParams,
}) => {
const { t } = useLanguage();
const dataFlow = useWorkflowDataFlow();
const dataFlow = useAutomation2DataFlow();
const dependsOn =
param.frontendOptions && typeof param.frontendOptions === 'object'
? String((param.frontendOptions as Record<string, unknown>).dependsOn ?? 'value')
@ -159,7 +157,7 @@ export const CaseListEditor: React.FC<FieldRendererProps> = ({
if (dataFlow?.instanceId && dataFlow.request) {
setLoading(true);
fetchConditionMeta(dataFlow.request, {
fetchConditionMeta(dataFlow.request, dataFlow.instanceId, {
graph: toApiGraph(dataFlow.nodes, dataFlow.connections),
nodeId: dataFlow.currentNodeId,
ref: { type: 'ref', nodeId: ref.nodeId, path: ref.path },

View file

@ -1,395 +0,0 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* clickupList hierarchical ClickUp list picker via connector browse API.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { fetchBrowse, type BrowseEntry } from '../../../../api/workflowAutomationApi';
import { fetchClickupList } from '../../../../api/clickupApi';
import type { FieldRendererProps } from './index';
import {
clickupBrowseParentPath,
formatListPickerValue,
isClickupContainerEntry,
isClickupListEntry,
parseClickupListPath,
resolveListPathFromValue,
} from './clickupPathUtils';
const CLICKUP_PURPLE = '#7B68EE';
const glassPanel: React.CSSProperties = {
marginTop: 6,
borderRadius: 10,
border: '1px solid rgba(123, 104, 238, 0.35)',
background: 'rgba(255, 255, 255, 0.72)',
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
boxShadow:
'0 4px 24px rgba(123, 104, 238, 0.12), 0 0 0 1px rgba(255, 255, 255, 0.5) inset',
padding: 8,
};
const glassTrigger: React.CSSProperties = {
display: 'flex',
width: '100%',
alignItems: 'stretch',
borderRadius: 8,
border: '1px solid rgba(123, 104, 238, 0.4)',
background: 'linear-gradient(135deg, rgba(255,255,255,0.9) 0%, rgba(123,104,238,0.08) 100%)',
boxShadow: '0 0 12px rgba(123, 104, 238, 0.15)',
overflow: 'hidden',
};
export const ClickUpListPicker: React.FC<FieldRendererProps> = ({
param,
value,
onChange,
allParams,
instanceId,
request,
onPatchParams,
nodeType,
}) => {
const { t } = useLanguage();
const dependsOn = (param.frontendOptions?.dependsOn as string | undefined) || 'connectionReference';
const connectionReference = (allParams?.[dependsOn] as string | undefined) || '';
const hasConnection = !!connectionReference && typeof connectionReference === 'string';
const [panelOpen, setPanelOpen] = useState(false);
const [browsePath, setBrowsePath] = useState('/');
const [items, setItems] = useState<BrowseEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pickedLabel, setPickedLabel] = useState<string | null>(null);
const strVal = typeof value === 'string' ? value : value != null ? String(value) : '';
const loadBrowse = useCallback(
async (path: string) => {
if (!request || !instanceId || !connectionReference) return;
setLoading(true);
setError(null);
try {
const res = await fetchBrowse(request, connectionReference, 'clickup', path);
setItems(res.items);
setBrowsePath(res.path || path);
} catch (err: unknown) {
setItems([]);
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
},
[request, instanceId, connectionReference],
);
useEffect(() => {
if (!panelOpen || !hasConnection) return;
void loadBrowse(browsePath);
}, [panelOpen, hasConnection, browsePath, loadBrowse]);
useEffect(() => {
if (!strVal) {
setPickedLabel(null);
return;
}
const pathFromVal = resolveListPathFromValue(strVal, param.name);
if (pathFromVal) {
const parsed = parseClickupListPath(pathFromVal);
if (parsed.listId && request && connectionReference) {
let cancelled = false;
fetchClickupList(request, connectionReference, parsed.listId)
.then((data) => {
if (cancelled) return;
const name = typeof data.name === 'string' ? data.name : null;
setPickedLabel(name || parsed.listId || strVal);
})
.catch(() => {
if (!cancelled) setPickedLabel(parsed.listId || strVal);
});
return () => {
cancelled = true;
};
}
setPickedLabel(parsed.listId || strVal);
return;
}
if (param.name === 'listId' && strVal && request && connectionReference) {
let cancelled = false;
fetchClickupList(request, connectionReference, strVal)
.then((data) => {
if (cancelled) return;
setPickedLabel(typeof data.name === 'string' ? data.name : strVal);
})
.catch(() => {
if (!cancelled) setPickedLabel(strVal);
});
return () => {
cancelled = true;
};
}
setPickedLabel(strVal);
}, [strVal, param.name, request, connectionReference]);
const shouldPatchTeamId =
nodeType === 'clickup.searchTasks' || Object.prototype.hasOwnProperty.call(allParams ?? {}, 'teamId');
const selectList = useCallback(
(entry: BrowseEntry) => {
const listPath = entry.path;
const stored = formatListPickerValue(listPath, param.name);
const { teamId, listId } = parseClickupListPath(listPath);
if (shouldPatchTeamId && onPatchParams && teamId) {
const patch: Record<string, unknown> = { [param.name]: stored };
patch.teamId = teamId;
onPatchParams(patch);
} else {
onChange(stored);
}
setPickedLabel(entry.name || listId || stored);
setPanelOpen(false);
},
[param.name, shouldPatchTeamId, onPatchParams, onChange],
);
const navigateInto = useCallback((entry: BrowseEntry) => {
if (!isClickupContainerEntry(entry.metadata, entry.isFolder)) return;
setBrowsePath(entry.path);
}, []);
const goUp = useCallback(() => {
setBrowsePath((p) => clickupBrowseParentPath(p));
}, []);
const clearSelection = useCallback(() => {
if (shouldPatchTeamId && onPatchParams) {
const patch: Record<string, unknown> = { [param.name]: '' };
if (nodeType === 'clickup.searchTasks') {
patch.teamId = '';
}
onPatchParams(patch);
} else {
onChange('');
}
setPickedLabel(null);
}, [shouldPatchTeamId, onPatchParams, onChange, param.name, nodeType]);
const triggerLabel = strVal
? pickedLabel ?? '…'
: t('ClickUp-Liste wählen');
const breadcrumb =
browsePath === '/'
? t('Workspaces')
: browsePath.replace(/^\/team\//, '').replace(/\//g, ' ');
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>
{param.description || param.name}
</label>
{!request || !instanceId ? (
<div style={{ fontSize: 11, color: '#888' }}>
{t('Listen-Browser nicht verfügbar (keine API-Anbindung).')}
</div>
) : (
<>
<div style={{ ...glassTrigger, opacity: hasConnection ? 1 : 0.55 }}>
<button
type="button"
disabled={!hasConnection}
onClick={() => {
if (!hasConnection) return;
setPanelOpen((o) => {
if (!o) setBrowsePath('/');
return !o;
});
}}
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
minWidth: 0,
padding: '8px 10px',
border: 'none',
background: 'transparent',
cursor: hasConnection ? 'pointer' : 'not-allowed',
fontSize: 12,
textAlign: 'left',
color: 'var(--text-primary, #334155)',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
{hasConnection ? triggerLabel : t('Zuerst {field} wählen', { field: dependsOn })}
</span>
<span aria-hidden style={{ flexShrink: 0, fontSize: 10, opacity: 0.65 }}>
{panelOpen ? '▾' : '▸'}
</span>
</button>
{strVal ? (
<button
type="button"
title={t('Auswahl aufheben')}
aria-label={t('Auswahl aufheben')}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
clearSelection();
}}
style={{
flexShrink: 0,
width: 36,
border: 'none',
borderLeft: '1px solid rgba(123, 104, 238, 0.25)',
background: 'transparent',
cursor: 'pointer',
fontSize: 16,
lineHeight: 1,
color: 'var(--text-secondary, #64748b)',
padding: 0,
}}
>
×
</button>
) : null}
</div>
{panelOpen && hasConnection && (
<div style={glassPanel}>
{error && (
<div style={{ fontSize: 11, color: '#c00', marginBottom: 6 }}>{error}</div>
)}
<div
style={{
display: 'flex',
gap: 6,
alignItems: 'center',
marginBottom: 6,
flexWrap: 'wrap',
}}
>
{browsePath !== '/' && (
<button
type="button"
onClick={goUp}
style={{
padding: '3px 8px',
borderRadius: 6,
border: '1px solid rgba(123, 104, 238, 0.35)',
background: 'rgba(255,255,255,0.6)',
cursor: 'pointer',
fontSize: 11,
}}
>
{t('Hoch')}
</button>
)}
<button
type="button"
onClick={() => loadBrowse(browsePath)}
title={t('Neu laden')}
style={{
padding: '3px 8px',
borderRadius: 6,
border: '1px solid rgba(123, 104, 238, 0.35)',
background: 'rgba(255,255,255,0.6)',
cursor: 'pointer',
fontSize: 11,
}}
>
</button>
<span style={{ fontSize: 11, color: '#555', flex: 1, minWidth: 0 }}>
{breadcrumb}
</span>
</div>
<div
style={{
maxHeight: 220,
overflow: 'auto',
borderRadius: 6,
border: '1px solid rgba(123, 104, 238, 0.2)',
background: 'rgba(255, 255, 255, 0.85)',
}}
>
{loading && (
<div style={{ padding: 8, fontSize: 11, color: '#888' }}>{t('Lade')}</div>
)}
{!loading && items.length === 0 && (
<div style={{ padding: 8, fontSize: 11, color: '#888' }}>
{t('Keine Einträge')}
</div>
)}
{!loading &&
items.map((item) => {
const isList = isClickupListEntry(item.metadata);
const canNavigate = isClickupContainerEntry(item.metadata, item.isFolder);
return (
<div
key={item.path}
style={{
display: 'flex',
alignItems: 'center',
padding: '4px 8px',
borderBottom: '1px solid rgba(123, 104, 238, 0.08)',
fontSize: 12,
}}
>
<span
role="button"
tabIndex={0}
onClick={() => {
if (isList) selectList(item);
else if (canNavigate) navigateInto(item);
}}
onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
if (isList) selectList(item);
else if (canNavigate) navigateInto(item);
}}
style={{
flex: 1,
cursor: isList || canNavigate ? 'pointer' : 'default',
userSelect: 'none',
}}
title={isList ? t('Liste wählen') : canNavigate ? t('Öffnen') : undefined}
>
{isList ? '📋' : canNavigate ? '📁' : '·'} {item.name}
</span>
{isList && (
<button
type="button"
onClick={() => selectList(item)}
style={{
padding: '2px 8px',
borderRadius: 6,
border: `1px solid ${CLICKUP_PURPLE}`,
background: CLICKUP_PURPLE,
color: '#fff',
cursor: 'pointer',
fontSize: 11,
boxShadow: '0 0 8px rgba(123, 104, 238, 0.45)',
}}
>
{t('Wählen')}
</button>
)}
</div>
);
})}
</div>
</div>
)}
</>
)}
</div>
);
};

View file

@ -1,15 +1,13 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* Backend-driven condition editor for flow.ifElse (depends on Item dataRef).
*/
import React from 'react';
import type { FieldRendererProps } from './index';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { isRef, type DataRef } from '../shared/dataRef';
import { toApiGraph } from '../shared/graphUtils';
import { fetchConditionMeta, type ConditionOperatorDef } from '../../../../api/workflowAutomationApi';
import { fetchConditionMeta, type ConditionOperatorDef } from '../../../../api/workflowApi';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export interface StructuredCondition {
@ -43,7 +41,7 @@ export const ConditionEditor: React.FC<FieldRendererProps> = ({
allParams,
}) => {
const { t } = useLanguage();
const dataFlow = useWorkflowDataFlow();
const dataFlow = useAutomation2DataFlow();
const dependsOn =
param.frontendOptions && typeof param.frontendOptions === 'object'
? String((param.frontendOptions as Record<string, unknown>).dependsOn ?? 'Item')
@ -85,7 +83,7 @@ export const ConditionEditor: React.FC<FieldRendererProps> = ({
if (dataFlow?.instanceId && dataFlow.request) {
setLoading(true);
fetchConditionMeta(dataFlow.request, {
fetchConditionMeta(dataFlow.request, dataFlow.instanceId, {
graph: toApiGraph(dataFlow.nodes, dataFlow.connections),
nodeId: dataFlow.currentNodeId,
ref: { type: 'ref', nodeId: ref.nodeId, path: ref.path },

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* One place to configure context.setContext rows: target key, then either
* upstream picker, a fixed literal, or a human task.
@ -7,7 +5,7 @@
import React from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { DataPicker } from '../shared/DataPicker';
import { isRef, isSystemVar, type DataRef, type SystemVarRef } from '../shared/dataRef';
import type { FieldRendererProps } from './index';
@ -176,7 +174,7 @@ const REMOVE_BTN: React.CSSProperties = {
export const ContextAssignmentsEditor: React.FC<FieldRendererProps> = ({ param, value, onChange, allParams }) => {
const { t } = useLanguage();
const dataFlow = useWorkflowDataFlow();
const dataFlow = useAutomation2DataFlow();
const rows = normalizeRows(value, allParams);
const [pickerRow, setPickerRow] = React.useState<number | null>(null);

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* ContextBuilderRenderer multi-select context binding for AI nodes.
*
@ -13,7 +11,7 @@
import React from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { DataPicker } from '../shared/DataPicker';
import { isRef, type DataRef, type SystemVarRef } from '../shared/dataRef';
import type { FieldRendererProps } from './index';
@ -54,7 +52,7 @@ const REMOVE_BTN: React.CSSProperties = {
export const ContextBuilderRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const dataFlow = useWorkflowDataFlow();
const dataFlow = useAutomation2DataFlow();
const [pickerOpen, setPickerOpen] = React.useState(false);
const dragIndex = React.useRef<number | null>(null);

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* DataRefRenderer Pick-not-Push attribute binding using the existing
* hierarchical DataPicker.
@ -12,14 +10,14 @@
import React from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { DataPicker } from '../shared/DataPicker';
import { isRef, type DataRef, type SystemVarRef } from '../shared/dataRef';
import type { FieldRendererProps } from './index';
export const DataRefRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const dataFlow = useWorkflowDataFlow();
const dataFlow = useAutomation2DataFlow();
const [pickerOpen, setPickerOpen] = React.useState(false);
const currentRef = isRef(value) ? (value as DataRef) : null;

View file

@ -1,11 +1,9 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* FeatureInstancePicker renderer for frontendType="featureInstance".
*
* Modeled on ConnectionPicker. Loads mandate-scoped FeatureInstances filtered
* by `frontendOptions.featureCode` (e.g. "trustee", "redmine") via
* GET /api/workflow-automation/options/feature.instance?featureCode=<code>
* GET /api/workflows/{instanceId}/options/feature.instance?featureCode=<code>
*
* Behavior matches the rest of the editor:
* - 0 results -> hint to create a feature instance for this mandate
@ -44,7 +42,7 @@ export const FeatureInstancePicker: React.FC<FieldRendererProps> = ({
setLoading(true);
setLoadError(null);
request({
url: `/api/workflow-automation/options/feature.instance?featureCode=${encodeURIComponent(featureCode)}`,
url: `/api/workflows/${instanceId}/options/feature.instance?featureCode=${encodeURIComponent(featureCode)}`,
method: 'get',
})
.then((res: unknown) => {

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* TemplateTextarea Freitext mit eingebetteten {{nodeId.path}} Tokens.
* Tokens werden zur Laufzeit von resolveParameterReferences aufgeloest (Gateway).
@ -7,11 +5,11 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import type { FieldRendererProps } from './index';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { DataPicker } from '../shared/DataPicker';
import { formatRefLabel, isRef, isSystemVar, type DataRef, type SystemVarRef } from '../shared/dataRef';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import styles from '../../editor/WorkflowFlowEditor.module.css';
import styles from '../../editor/Automation2FlowEditor.module.css';
const _TEMPLATE_TOKEN_RE = /\{\{\s*([^}]+?)\s*\}\}/g;
@ -62,7 +60,7 @@ function _parseTokensInTemplate(
export const TemplateTextareaRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const dataFlow = useWorkflowDataFlow();
const dataFlow = useAutomation2DataFlow();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [pickerOpen, setPickerOpen] = useState(false);

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* userFileFolder FormGeneratorTree embedded: combobox-style trigger + expandable tree.
*/

View file

@ -1,30 +0,0 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { describe, expect, it } from 'vitest';
import {
clickupBrowseParentPath,
formatListPickerValue,
parseClickupListPath,
} from './clickupPathUtils';
describe('clickupPathUtils', () => {
it('parseClickupListPath extracts team and list ids', () => {
expect(parseClickupListPath('/team/abc/list/xyz')).toEqual({
teamId: 'abc',
listId: 'xyz',
});
expect(parseClickupListPath('')).toEqual({});
});
it('formatListPickerValue stores path or listId by param name', () => {
const path = '/team/abc/list/xyz';
expect(formatListPickerValue(path, 'pathQuery')).toBe(path);
expect(formatListPickerValue(path, 'listId')).toBe('xyz');
});
it('clickupBrowseParentPath walks up hierarchy', () => {
expect(clickupBrowseParentPath('/')).toBe('/');
expect(clickupBrowseParentPath('/team/t1')).toBe('/');
expect(clickupBrowseParentPath('/team/t1/space/s1')).toBe('/team/t1');
});
});

View file

@ -1,63 +0,0 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** Parse virtual ClickUp list paths: /team/{teamId}/list/{listId} */
const LIST_PATH_RE = /^\/team\/([^/]+)\/list\/([^/]+)$/;
export function parseClickupListPath(path: string): { teamId?: string; listId?: string } {
const p = (path || '').trim();
const m = p.match(LIST_PATH_RE);
if (!m) return {};
return { teamId: m[1], listId: m[2] };
}
/** Store path for pathQuery; raw list id for listId param. */
export function formatListPickerValue(listPath: string, paramName: string): string {
const { listId } = parseClickupListPath(listPath);
if (paramName === 'listId' && listId) return listId;
return listPath;
}
/** Resolve list path from stored value (path or legacy raw id). */
export function resolveListPathFromValue(value: string, paramName: string): string | null {
const v = (value || '').trim();
if (!v) return null;
if (LIST_PATH_RE.test(v)) return v;
if (paramName === 'listId' && /^[a-zA-Z0-9_-]+$/.test(v)) {
return null;
}
return v;
}
export function clickupBrowseParentPath(path: string): string {
const p = (path || '/').trim() || '/';
if (p === '/') return '/';
const folder = p.match(/^(\/team\/[^/]+\/space\/[^/]+)\/folder\/[^/]+$/);
if (folder) return folder[1];
const space = p.match(/^(\/team\/[^/]+)\/space\/[^/]+$/);
if (space) return space[1];
if (/^\/team\/[^/]+$/.test(p)) return '/';
const parts = p.split('/').filter(Boolean);
if (parts.length <= 1) return '/';
parts.pop();
return `/${parts.join('/')}`;
}
export function cuTypeFromEntry(metadata?: Record<string, unknown>): string {
const t = metadata?.cuType;
return typeof t === 'string' ? t : '';
}
export function isClickupListEntry(metadata?: Record<string, unknown>): boolean {
return cuTypeFromEntry(metadata) === 'list';
}
export function isClickupContainerEntry(
metadata: Record<string, unknown> | undefined,
isFolder: boolean,
): boolean {
const cu = cuTypeFromEntry(metadata);
if (cu === 'list') return false;
if (cu === 'team' || cu === 'space' || cu === 'folder') return true;
return isFolder && cu !== 'task';
}

View file

@ -1,15 +1,13 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* Generic FrontendType renderer registry.
* Maps frontendType strings to React components.
*/
import type { ComponentType } from 'react';
import type { NodeTypeParameter } from '../../../../api/workflowAutomationApi';
import type { ApiRequestFunction } from '../../../../api/workflowAutomationApi';
import type { NodeTypeParameter } from '../../../../api/workflowApi';
import type { ApiRequestFunction } from '../../../../api/workflowApi';
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { FormFieldOptionsEditor } from '../form/FormFieldOptionsEditor';
import {
deriveFormFieldPayloadKey,
@ -48,14 +46,13 @@ import {
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { toApiGraph } from '../shared/graphUtils';
import { postUpstreamPaths } from '../../../../api/workflowAutomationApi';
import { postUpstreamPaths } from '../../../../api/workflowApi';
import type { CanvasNode } from '../../editor/FlowCanvas';
import { DataRefRenderer } from './DataRefRenderer';
import { ContextBuilderRenderer } from './ContextBuilderRenderer';
import { ContextAssignmentsEditor } from './ContextAssignmentsEditor';
import { FeatureInstancePicker } from './FeatureInstancePicker';
import { UserFileFolderPicker } from './UserFileFolderPicker';
import { ClickUpListPicker } from './ClickUpListPicker';
import { ConditionEditor } from './ConditionEditor';
import { CaseListEditor } from './CaseListEditor';
import { TemplateTextareaRenderer } from './TemplateTextareaRenderer';
@ -302,7 +299,7 @@ const HiddenInput: React.FC<FieldRendererProps> = () => null;
const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, instanceId, request }) => {
const { t } = useLanguage();
const dataFlow = useWorkflowDataFlow();
const dataFlow = useAutomation2DataFlow();
const [connections, setConnections] = React.useState<Array<{ id: string; label: string }>>([]);
const [loadError, setLoadError] = React.useState<string | null>(null);
const [upstreamBindOptions, setUpstreamBindOptions] = React.useState<Array<{ key: string; label: string; ref: unknown }>>([]);
@ -312,7 +309,7 @@ const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange
if (!instanceId || !request) return;
const qs = authority ? `?authority=${encodeURIComponent(authority)}` : '';
setLoadError(null);
request({ url: `/api/workflow-automation/options/user.connection${qs}`, method: 'get' })
request({ url: `/api/workflows/${instanceId}/options/user.connection${qs}`, method: 'get' })
.then((res: unknown) => {
const data = res as { options?: Array<{ value: string; label: string }> };
setConnections((data?.options || []).map((o) => ({ id: o.value, label: o.label })));
@ -330,7 +327,7 @@ const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange
return;
}
const graph = toApiGraph(dataFlow.nodes as CanvasNode[], dataFlow.connections);
postUpstreamPaths(request, graph, dataFlow.currentNodeId)
postUpstreamPaths(request, instanceId, graph, dataFlow.currentNodeId)
.then(({ paths }) => {
const opts = paths
.filter(
@ -645,7 +642,7 @@ const SharepointPathPicker: React.FC<FieldRendererProps> = ({ param, value, onCh
const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const ctx = useWorkflowDataFlow();
const ctx = useAutomation2DataFlow();
const fieldTypeOptions = ctx?.formFieldTypes?.length
? ctx.formFieldTypes
: FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' }));
@ -757,10 +754,7 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
onChange={(e) => {
const typeId = e.target.value;
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
const subRow: Record<string, unknown> = {
...(nextFields[j] as Record<string, unknown>),
type: typeId,
};
const subRow = { ...(nextFields[j] as Record<string, unknown>), type: typeId };
if (formFieldTypeHasConfigurableOptions(typeId)) {
subRow.options = normalizeFormFieldOptions(subRow.options);
}
@ -1071,7 +1065,7 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
sharepointFolder: SharepointPathPicker,
sharepointFile: SharepointPathPicker,
userFileFolder: UserFileFolderPicker,
clickupList: ClickUpListPicker,
clickupList: FolderPicker,
clickupTask: FolderPicker,
caseList: CaseListEditor,
fieldBuilder: FieldBuilderEditor,

View file

@ -1,4 +1,2 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
export { ConditionEditor as IfElseNodeConfig } from '../frontendTypeRenderers/ConditionEditor';
export type { StructuredCondition } from '../frontendTypeRenderers/ConditionEditor';

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* Loop node config - Datenquelle für Iteration mit benutzerfreundlichen Labels.
* Z.B. für jedes Formularfeld, jede Datei aus Upload, jede E-Mail aus Suche.
@ -9,7 +7,7 @@ import React from 'react';
import type { NodeConfigRendererProps } from '../shared/types';
import { LoopItemsSelect } from '../shared/LoopItemsSelect';
import { createValue, isRef, isValue } from '../shared/dataRef';
import styles from '../../editor/WorkflowFlowEditor.module.css';
import styles from '../../editor/Automation2FlowEditor.module.css';
export const LoopNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const value = params.items;

View file

@ -1,3 +1 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
export { LoopNodeConfig } from './LoopNodeConfig';

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